diff --git a/alembic/versions/core_001_initial.py b/alembic/versions/core_001_initial.py index 091be36a..6f89447a 100644 --- a/alembic/versions/core_001_initial.py +++ b/alembic/versions/core_001_initial.py @@ -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("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("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("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"), diff --git a/alembic/versions_backup/module_loyalty/loyalty_001_add_loyalty_module_tables.py b/alembic/versions_backup/module_loyalty/loyalty_001_add_loyalty_module_tables.py index b66f8b8b..cb4fee4a 100644 --- a/alembic/versions_backup/module_loyalty/loyalty_001_add_loyalty_module_tables.py +++ b/alembic/versions_backup/module_loyalty/loyalty_001_add_loyalty_module_tables.py @@ -248,7 +248,7 @@ def upgrade() -> None: existing_nullable=True) op.alter_column("platforms", "domain", 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) op.alter_column("platforms", "path_prefix", existing_type=sa.VARCHAR(length=50), @@ -518,7 +518,7 @@ def downgrade() -> None: op.alter_column("platforms", "domain", existing_type=sa.VARCHAR(length=255), 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) op.alter_column("platforms", "description", existing_type=sa.TEXT(), diff --git a/alembic/versions_backup/z4e5f6a7b8c9_add_multi_platform_support.py b/alembic/versions_backup/z4e5f6a7b8c9_add_multi_platform_support.py index ff61b342..a39b4139 100644 --- a/alembic/versions_backup/z4e5f6a7b8c9_add_multi_platform_support.py +++ b/alembic/versions_backup/z4e5f6a7b8c9_add_multi_platform_support.py @@ -174,7 +174,7 @@ def upgrade() -> None: supported_languages, is_active, is_public, theme_config, settings, created_at, updated_at) 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) """) ) diff --git a/alembic/versions_backup/z5f6g7h8i9j0_add_loyalty_platform.py b/alembic/versions_backup/z5f6g7h8i9j0_add_loyalty_platform.py index 644b3151..e68af1a0 100644 --- a/alembic/versions_backup/z5f6g7h8i9j0_add_loyalty_platform.py +++ b/alembic/versions_backup/z5f6g7h8i9j0_add_loyalty_platform.py @@ -35,7 +35,7 @@ def upgrade() -> None: supported_languages, is_active, is_public, theme_config, settings, created_at, updated_at) 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"}', '{"features": ["points", "rewards", "tiers", "analytics"]}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) diff --git a/app/core/frontend_detector.py b/app/core/frontend_detector.py index c0b34f1a..e1c3712b 100644 --- a/app/core/frontend_detector.py +++ b/app/core/frontend_detector.py @@ -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. Detection priority: -1. Admin subdomain (admin.oms.lu) +1. Admin subdomain (admin.omsflow.lu) 2. Path-based admin/store (/admin/*, /store/*, /api/v1/admin/*) 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/*) 6. Default to PLATFORM (marketing pages) @@ -62,7 +62,7 @@ class FrontendDetector: Detect frontend type from request. 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") 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": logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from subdomain") return FrontendType.ADMIN @@ -110,7 +110,7 @@ class FrontendDetector: logger.debug("[FRONTEND_DETECTOR] Detected PLATFORM from path") 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 and subdomain not in cls.RESERVED_SUBDOMAINS: logger.debug( @@ -138,7 +138,7 @@ class FrontendDetector: @classmethod 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. Handles special case of admin.localhost for development. @@ -195,13 +195,3 @@ class FrontendDetector: def is_api_request(cls, path: str) -> bool: """Check if request is for API endpoints (any frontend's 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) diff --git a/app/modules/__init__.py b/app/modules/__init__.py index 49b63de7..a7b48fea 100644 --- a/app/modules/__init__.py +++ b/app/modules/__init__.py @@ -73,7 +73,7 @@ from app.modules.registry import ( is_internal_module, ) 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 ( build_beat_schedule, discover_module_tasks, @@ -87,7 +87,6 @@ __all__ = [ "ScheduledTask", # Task support "ModuleTask", - "DatabaseTask", "discover_module_tasks", "build_beat_schedule", "parse_schedule", diff --git a/app/modules/analytics/schemas/__init__.py b/app/modules/analytics/schemas/__init__.py index 8bcefddc..87de2516 100644 --- a/app/modules/analytics/schemas/__init__.py +++ b/app/modules/analytics/schemas/__init__.py @@ -2,51 +2,22 @@ """ 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 ( - AdminDashboardResponse, CodeQualityDashboardStatsResponse, CustomerStatsResponse, - ImportStatsResponse, - MarketplaceStatsResponse, - OrderStatsBasicResponse, OrderStatsResponse, - PlatformStatsResponse, - ProductStatsResponse, - StatsResponse, StoreAnalyticsCatalog, StoreAnalyticsImports, StoreAnalyticsInventory, StoreAnalyticsResponse, - StoreCustomerStats, - StoreDashboardStatsResponse, - StoreInfo, - StoreOrderStats, - StoreProductStats, - StoreRevenueStats, - StoreStatsResponse, - UserStatsResponse, ValidatorStats, ) __all__ = [ - "StatsResponse", - "MarketplaceStatsResponse", - "ImportStatsResponse", - "UserStatsResponse", - "StoreStatsResponse", - "ProductStatsResponse", - "PlatformStatsResponse", - "OrderStatsBasicResponse", - "AdminDashboardResponse", - "StoreProductStats", - "StoreOrderStats", - "StoreCustomerStats", - "StoreRevenueStats", - "StoreInfo", - "StoreDashboardStatsResponse", "StoreAnalyticsImports", "StoreAnalyticsCatalog", "StoreAnalyticsInventory", diff --git a/app/modules/analytics/schemas/stats.py b/app/modules/analytics/schemas/stats.py index a8106bbc..3c2a1958 100644 --- a/app/modules/analytics/schemas/stats.py +++ b/app/modules/analytics/schemas/stats.py @@ -2,8 +2,8 @@ """ Analytics module schemas for statistics and reporting. -Base dashboard schemas are defined in core.schemas.dashboard. -This module re-exports them for backward compatibility and adds +Base dashboard schemas are defined in app.modules.core.schemas.dashboard. +Import them from there directly. This module contains only analytics-specific schemas (trends, reports, etc.). """ @@ -13,26 +13,6 @@ from typing import Any 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) # ============================================================================ @@ -151,22 +131,6 @@ class OrderStatsResponse(BaseModel): __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 "StoreAnalyticsImports", "StoreAnalyticsCatalog", diff --git a/app/modules/analytics/services/stats_service.py b/app/modules/analytics/services/stats_service.py index 7a54872b..7201ef3c 100644 --- a/app/modules/analytics/services/stats_service.py +++ b/app/modules/analytics/services/stats_service.py @@ -107,7 +107,7 @@ class StatsService: ) 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) .scalar() or 0 @@ -286,17 +286,10 @@ class StatsService: ) return { - # Schema-compatible fields (StoreStatsResponse) "total": total_stores, "verified": verified_stores, "pending": pending_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": ( (verified_stores / total_stores * 100) if total_stores > 0 else 0 ), @@ -485,16 +478,11 @@ class StatsService: ) return { - # Frontend-expected fields "total": total, "pending": pending, "processing": processing, "completed": completed, "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, } except SQLAlchemyError as e: @@ -505,9 +493,6 @@ class StatsService: "processing": 0, "completed": 0, "failed": 0, - "total_imports": 0, - "completed_imports": 0, - "failed_imports": 0, "success_rate": 0, } diff --git a/app/modules/billing/services/feature_service.py b/app/modules/billing/services/feature_service.py index c512a885..936bfc78 100644 --- a/app/modules/billing/services/feature_service.py +++ b/app/modules/billing/services/feature_service.py @@ -218,9 +218,20 @@ class FeatureService: self, db: Session, store_id: int, feature_code: str ) -> 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) if merchant_id is None or platform_id is None: diff --git a/app/modules/billing/services/subscription_service.py b/app/modules/billing/services/subscription_service.py index 5427edd5..b348b407 100644 --- a/app/modules/billing/services/subscription_service.py +++ b/app/modules/billing/services/subscription_service.py @@ -29,9 +29,7 @@ from datetime import UTC, datetime, timedelta from sqlalchemy.orm import Session, joinedload from app.exceptions import ResourceNotFoundException -from app.modules.billing.exceptions import ( - SubscriptionNotFoundException, # Re-exported for backward compatibility -) +from app.modules.billing.exceptions import SubscriptionNotFoundException from app.modules.billing.models import ( MerchantSubscription, SubscriptionStatus, @@ -159,9 +157,19 @@ class SubscriptionService: self, db: Session, store_id: int ) -> 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 diff --git a/app/modules/cms/definition.py b/app/modules/cms/definition.py index d3abe850..5fcf2f6b 100644 --- a/app/modules/cms/definition.py +++ b/app/modules/cms/definition.py @@ -121,7 +121,7 @@ def _get_admin_router(): def _get_store_router(): """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 diff --git a/app/modules/cms/routes/__init__.py b/app/modules/cms/routes/__init__.py index ed292a5a..79999aee 100644 --- a/app/modules/cms/routes/__init__.py +++ b/app/modules/cms/routes/__init__.py @@ -6,9 +6,9 @@ This module provides functions to register CMS routes with module-based access control. NOTE: Routers are NOT auto-imported to avoid circular dependencies. -Import directly from admin.py or store.py as needed: - from app.modules.cms.routes.admin import admin_router - from app.modules.cms.routes.store import store_router, store_media_router +Import directly from api/admin.py or api/store.py as needed: + from app.modules.cms.routes.api.admin import admin_router + from app.modules.cms.routes.api.store import store_router """ # 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): """Lazy import routers to avoid circular dependencies.""" 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 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 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 raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/cms/routes/api/__init__.py b/app/modules/cms/routes/api/__init__.py index 846684ee..accb381a 100644 --- a/app/modules/cms/routes/api/__init__.py +++ b/app/modules/cms/routes/api/__init__.py @@ -8,8 +8,8 @@ Provides REST API endpoints for content page management: - 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.store import router as store_router +from app.modules.cms.routes.api.admin import admin_router +from app.modules.cms.routes.api.store import store_router from app.modules.cms.routes.api.storefront import router as storefront_router __all__ = ["admin_router", "store_router", "storefront_router"] diff --git a/app/modules/cms/routes/api/admin.py b/app/modules/cms/routes/api/admin.py index 2b70ef84..7aa4c813 100644 --- a/app/modules/cms/routes/api/admin.py +++ b/app/modules/cms/routes/api/admin.py @@ -23,9 +23,6 @@ admin_router = APIRouter( dependencies=[Depends(require_module_access("cms", FrontendType.ADMIN))], ) -# For backwards compatibility with existing imports -router = admin_router - # Aggregate all CMS admin routes admin_router.include_router(admin_content_pages_router, tags=["admin-content-pages"]) admin_router.include_router(admin_images_router, tags=["admin-images"]) diff --git a/app/modules/cms/routes/api/store.py b/app/modules/cms/routes/api/store.py index 664ffaee..3f8ada52 100644 --- a/app/modules/cms/routes/api/store.py +++ b/app/modules/cms/routes/api/store.py @@ -18,7 +18,6 @@ ROUTE_CONFIG = { } store_router = APIRouter() -router = store_router # Alias for discovery compatibility # Aggregate all CMS store routes store_router.include_router(store_content_pages_router, tags=["store-content-pages"]) diff --git a/app/modules/cms/routes/pages/platform.py b/app/modules/cms/routes/pages/platform.py index deb73e00..757555a9 100644 --- a/app/modules/cms/routes/pages/platform.py +++ b/app/modules/cms/routes/pages/platform.py @@ -80,7 +80,7 @@ async def homepage( URL routing: - localhost:9999/ -> Main marketing site ('main' platform) - 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) """ # Get platform and store from middleware diff --git a/app/modules/cms/routes/store.py b/app/modules/cms/routes/store.py deleted file mode 100644 index 2632fc97..00000000 --- a/app/modules/cms/routes/store.py +++ /dev/null @@ -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"] diff --git a/app/modules/core/routes/api/admin_dashboard.py b/app/modules/core/routes/api/admin_dashboard.py index fd2dcdd8..49ed9249 100644 --- a/app/modules/core/routes/api/admin_dashboard.py +++ b/app/modules/core/routes/api/admin_dashboard.py @@ -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 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 @@ -20,7 +17,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api 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 ( AdminDashboardResponse, ImportStatsResponse, @@ -70,19 +67,7 @@ def _get_platform_id(request: Request, current_admin: UserContext) -> int: return 1 -def _extract_metric_value( - 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]: +def _widget_list_item_to_dict(item: WidgetListItem) -> dict[str, Any]: """Convert a WidgetListItem to a dictionary for API response.""" return { "id": item.id, @@ -95,15 +80,14 @@ def _widget_list_item_to_dict(item) -> dict[str, Any]: } -def _extract_widget_items( - widgets: dict[str, list], category: str, key: str +def _get_list_widget_items( + widgets: dict[str, list], key: str ) -> list[dict[str, Any]]: - """Extract items from a list widget for backward compatibility.""" - if category not in widgets: - return [] - for widget in widgets[category]: - if widget.key == key and isinstance(widget.data, ListWidget): - return [_widget_list_item_to_dict(item) for item in widget.data.items] + """Extract items from a list widget by key, searching all categories.""" + for category_widgets in widgets.values(): + for widget in category_widgets: + if widget.key == key and isinstance(widget.data, ListWidget): + return [_widget_list_item_to_dict(item) for item in widget.data.items] return [] @@ -116,59 +100,32 @@ def get_admin_dashboard( """Get admin dashboard with platform statistics (Admin only).""" platform_id = _get_platform_id(request, current_admin) - # Get aggregated metrics from all enabled modules - metrics = stats_aggregator.get_admin_dashboard_stats(db=db, platform_id=platform_id) + # Get flat metrics from all enabled modules + metrics = stats_aggregator.get_admin_stats_flat(db=db, platform_id=platform_id) # Get aggregated widgets from all enabled modules 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( platform={ "name": "Multi-Tenant Ecommerce Platform", "version": "1.0.0", }, users=UserStatsResponse( - total_users=int(total_users), - active_users=int(active_users), - inactive_users=int(inactive_users), - admin_users=int(admin_users), - activation_rate=float(activation_rate), + total_users=int(metrics.get("tenancy.total_users", 0)), + active_users=int(metrics.get("tenancy.active_users", 0)), + inactive_users=int(metrics.get("tenancy.inactive_users", 0)), + admin_users=int(metrics.get("tenancy.admin_users", 0)), + activation_rate=float(metrics.get("tenancy.user_activation_rate", 0)), ), stores=StoreStatsResponse( - total=int(total_stores), - verified=int(verified_stores), - pending=int(pending_stores), - inactive=int(inactive_stores), + total=int(metrics.get("tenancy.total_stores", 0)), + verified=int(metrics.get("tenancy.verified_stores", 0)), + pending=int(metrics.get("tenancy.pending_stores", 0)), + inactive=int(metrics.get("tenancy.inactive_stores", 0)), ), - recent_stores=recent_stores, - recent_imports=recent_imports, + recent_stores=_get_list_widget_items(widgets, "tenancy.recent_stores"), + recent_imports=_get_list_widget_items(widgets, "marketplace.recent_imports"), ) @@ -181,37 +138,17 @@ def get_comprehensive_stats( """Get comprehensive platform statistics (Admin only).""" platform_id = _get_platform_id(request, current_admin) - # Get aggregated metrics - metrics = stats_aggregator.get_admin_dashboard_stats(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 - ) + # Get flat metrics from all enabled modules + metrics = stats_aggregator.get_admin_stats_flat(db=db, platform_id=platform_id) return StatsResponse( - total_products=int(total_products), - unique_brands=int(unique_brands), + total_products=int(metrics.get("catalog.total_products", 0)), + unique_brands=int(metrics.get("marketplace.unique_brands", 0)), unique_categories=0, # TODO: Add category tracking - unique_marketplaces=int(unique_marketplaces), - unique_stores=int(unique_stores), - total_inventory_entries=int(inventory_entries), - total_inventory_quantity=int(inventory_quantity), + unique_marketplaces=int(metrics.get("marketplace.unique_marketplaces", 0)), + unique_stores=int(metrics.get("tenancy.total_stores", 0)), + total_inventory_entries=int(metrics.get("inventory.entries", 0)), + total_inventory_quantity=int(metrics.get("inventory.total_quantity", 0)), ) @@ -261,89 +198,39 @@ def get_platform_statistics( """Get comprehensive platform statistics (Admin only).""" platform_id = _get_platform_id(request, current_admin) - # Get aggregated metrics from all enabled modules - metrics = stats_aggregator.get_admin_dashboard_stats(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 - ) + # Get flat metrics from all enabled modules + metrics = stats_aggregator.get_admin_stats_flat(db=db, platform_id=platform_id) return PlatformStatsResponse( users=UserStatsResponse( - total_users=int(total_users), - active_users=int(active_users), - inactive_users=int(inactive_users), - admin_users=int(admin_users), - activation_rate=float(activation_rate), + total_users=int(metrics.get("tenancy.total_users", 0)), + active_users=int(metrics.get("tenancy.active_users", 0)), + inactive_users=int(metrics.get("tenancy.inactive_users", 0)), + admin_users=int(metrics.get("tenancy.admin_users", 0)), + activation_rate=float(metrics.get("tenancy.user_activation_rate", 0)), ), stores=StoreStatsResponse( - total=int(total_stores), - verified=int(verified_stores), - pending=int(pending_stores), - inactive=int(inactive_stores), + total=int(metrics.get("tenancy.total_stores", 0)), + verified=int(metrics.get("tenancy.verified_stores", 0)), + pending=int(metrics.get("tenancy.pending_stores", 0)), + inactive=int(metrics.get("tenancy.inactive_stores", 0)), ), products=ProductStatsResponse( - total_products=int(total_products), - active_products=int(active_products), + total_products=int(metrics.get("catalog.total_products", 0)), + active_products=int(metrics.get("catalog.active_products", 0)), out_of_stock=0, # TODO: Implement ), orders=OrderStatsBasicResponse( - total_orders=int(total_orders), + total_orders=int(metrics.get("orders.total", 0)), pending_orders=0, # TODO: Implement status tracking completed_orders=0, # TODO: Implement status tracking ), imports=ImportStatsResponse( - total=int(total_imports), - pending=int(pending_imports), - processing=int(processing_imports), - completed=int(completed_imports), - failed=int(failed_imports), - success_rate=float(import_success_rate), + total=int(metrics.get("marketplace.total_imports", 0)), + pending=int(metrics.get("marketplace.pending_imports", 0)), + processing=int(metrics.get("marketplace.processing_imports", 0)), + completed=int(metrics.get("marketplace.successful_imports", 0)), + failed=int(metrics.get("marketplace.failed_imports", 0)), + success_rate=float(metrics.get("marketplace.success_rate", 0)), ), ) diff --git a/app/modules/core/routes/api/admin_menu_config.py b/app/modules/core/routes/api/admin_menu_config.py index 28c4d24a..1f3ca276 100644 --- a/app/modules/core/routes/api/admin_menu_config.py +++ b/app/modules/core/routes/api/admin_menu_config.py @@ -205,7 +205,9 @@ async def get_platform_menu_config( ) # 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( 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})" ) - 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") @@ -334,7 +339,9 @@ async def get_user_menu_config( ) # 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( 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)" ) - 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) @@ -486,22 +495,31 @@ async def get_rendered_admin_menu( ) # 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 + # menu is a list of DiscoveredMenuSection dataclasses sections = [] - for section in menu.get("sections", []): + for section in menu: # Translate item labels translated_items = [] - for item in section.get("items", []): - translated_item = item.copy() - translated_item["label"] = _translate_label(item.get("label"), language) - translated_items.append(translated_item) + for item in section.items: + translated_items.append( + { + "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( MenuSectionResponse( - id=section["id"], - label=_translate_label(section.get("label"), language), + id=section.id, + label=_translate_label(section.label_key, language), items=translated_items, ) ) diff --git a/app/modules/core/services/menu_discovery_service.py b/app/modules/core/services/menu_discovery_service.py index 15f8158c..c94a0fd6 100644 --- a/app/modules/core/services/menu_discovery_service.py +++ b/app/modules/core/services/menu_discovery_service.py @@ -492,42 +492,6 @@ class MenuDiscoveryService: 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 menu_discovery_service = MenuDiscoveryService() diff --git a/app/modules/core/services/menu_service.py b/app/modules/core/services/menu_service.py index 70909dca..3080bf5c 100644 --- a/app/modules/core/services/menu_service.py +++ b/app/modules/core/services/menu_service.py @@ -102,7 +102,9 @@ class MenuService: # Validate menu item exists in registry all_items = menu_discovery_service.get_all_menu_item_ids(frontend_type) 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 # Check module enablement if platform is specified @@ -159,9 +161,7 @@ class MenuService: # Filter by module enablement if platform is specified if platform_id: - module_service.get_module_menu_items( - db, platform_id, frontend_type - ) + module_service.get_module_menu_items(db, platform_id, frontend_type) # Only keep items from enabled modules (or items not associated with any module) all_items = module_service.filter_menu_items_by_modules( db, platform_id, all_items, frontend_type @@ -228,7 +228,7 @@ class MenuService: user_id: int | None = None, is_super_admin: bool = False, store_code: str | None = None, - ) -> dict: + ) -> list: """ Get filtered menu structure for frontend rendering. @@ -248,10 +248,9 @@ class MenuService: store_code: Store code for URL placeholder replacement (store frontend) Returns: - Filtered menu structure ready for rendering + List of DiscoveredMenuSection ready for rendering """ - # Use the module-driven discovery service to get filtered menu - sections = menu_discovery_service.get_menu_for_frontend( + return menu_discovery_service.get_menu_for_frontend( db=db, frontend_type=frontend_type, platform_id=platform_id, @@ -260,9 +259,6 @@ class MenuService: 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) # ========================================================================= @@ -349,10 +345,10 @@ class MenuService: Returns: List of MenuItemConfig with current visibility state """ - shown_items = self._get_shown_items( - db, FrontendType.ADMIN, user_id=user_id + shown_items = self._get_shown_items(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 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 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: if item_id not in mandatory_items: @@ -665,7 +663,9 @@ class MenuService: # Create records with is_visible=True for all non-mandatory items 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: if item_id not in mandatory_items: @@ -717,11 +717,17 @@ class MenuService: return q.filter(AdminMenuConfig.user_id == user_id) # Check if any visible records exist (valid opt-in config) - visible_count = scope_query().filter( - AdminMenuConfig.is_visible == True # noqa: E712 - ).count() + visible_count = ( + scope_query() + .filter( + AdminMenuConfig.is_visible == True # noqa: E712 + ) + .count() + ) 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 # 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 deleted = scope_query().delete(synchronize_session="fetch") 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 all_items = menu_discovery_service.get_all_menu_item_ids(frontend_type) diff --git a/app/modules/dev_tools/models/architecture_scan.py b/app/modules/dev_tools/models/architecture_scan.py index 53cd203c..0ba822fc 100644 --- a/app/modules/dev_tools/models/architecture_scan.py +++ b/app/modules/dev_tools/models/architecture_scan.py @@ -3,8 +3,6 @@ Architecture Scan Models 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 ( diff --git a/app/modules/dev_tools/models/test_run.py b/app/modules/dev_tools/models/test_run.py index 5ccb541c..34a34a53 100644 --- a/app/modules/dev_tools/models/test_run.py +++ b/app/modules/dev_tools/models/test_run.py @@ -3,8 +3,6 @@ Test Run Models 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 ( diff --git a/app/modules/dev_tools/schemas/__init__.py b/app/modules/dev_tools/schemas/__init__.py index 7aebd692..a4bccd21 100644 --- a/app/modules/dev_tools/schemas/__init__.py +++ b/app/modules/dev_tools/schemas/__init__.py @@ -3,7 +3,6 @@ Dev-Tools module Pydantic schemas. 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 diff --git a/app/modules/dev_tools/tasks/code_quality.py b/app/modules/dev_tools/tasks/code_quality.py index 3d2ad2d4..75cb2465 100644 --- a/app/modules/dev_tools/tasks/code_quality.py +++ b/app/modules/dev_tools/tasks/code_quality.py @@ -3,8 +3,6 @@ Celery tasks for code quality scans. 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 diff --git a/app/modules/dev_tools/tasks/test_runner.py b/app/modules/dev_tools/tasks/test_runner.py index 1519a58c..9de2e760 100644 --- a/app/modules/dev_tools/tasks/test_runner.py +++ b/app/modules/dev_tools/tasks/test_runner.py @@ -3,8 +3,6 @@ Celery tasks for test 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 diff --git a/app/modules/inventory/migrations/versions/inventory_001_initial.py b/app/modules/inventory/migrations/versions/inventory_001_initial.py index 6e48f285..b23b3bd9 100644 --- a/app/modules/inventory/migrations/versions/inventory_001_initial.py +++ b/app/modules/inventory/migrations/versions/inventory_001_initial.py @@ -23,7 +23,6 @@ def upgrade() -> None: 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("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("reserved_quantity", sa.Integer(), nullable=True, server_default="0"), sa.Column("gtin", sa.String(), nullable=True, index=True), diff --git a/app/modules/inventory/models/inventory.py b/app/modules/inventory/models/inventory.py index 9bea20b0..e41a54c2 100644 --- a/app/modules/inventory/models/inventory.py +++ b/app/modules/inventory/models/inventory.py @@ -30,9 +30,6 @@ class Inventory(Base, TimestampMixin): warehouse = Column(String, nullable=False, default="strassen", index=True) 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) reserved_quantity = Column(Integer, default=0) @@ -53,7 +50,7 @@ class Inventory(Base, TimestampMixin): ) def __repr__(self): - return f"" + return f"" @property def available_quantity(self): diff --git a/app/modules/inventory/routes/api/admin.py b/app/modules/inventory/routes/api/admin.py index 59764fb8..6ca9f026 100644 --- a/app/modules/inventory/routes/api/admin.py +++ b/app/modules/inventory/routes/api/admin.py @@ -283,7 +283,7 @@ def delete_inventory( inventory = inventory_service.get_inventory_by_id_admin(db, inventory_id) store_id = inventory.store_id product_id = inventory.product_id - location = inventory.location + location = inventory.bin_location inventory_service.delete_inventory( db=db, diff --git a/app/modules/inventory/schemas/inventory.py b/app/modules/inventory/schemas/inventory.py index 2f9b8596..980b0fef 100644 --- a/app/modules/inventory/schemas/inventory.py +++ b/app/modules/inventory/schemas/inventory.py @@ -45,7 +45,7 @@ class InventoryResponse(BaseModel): id: int product_id: int store_id: int - location: str + bin_location: str quantity: int reserved_quantity: int gtin: str | None diff --git a/app/modules/inventory/services/inventory_import_service.py b/app/modules/inventory/services/inventory_import_service.py index 5d43b3f9..d38f593b 100644 --- a/app/modules/inventory/services/inventory_import_service.py +++ b/app/modules/inventory/services/inventory_import_service.py @@ -176,7 +176,6 @@ class InventoryImportService: store_id=store_id, warehouse=warehouse, bin_location=bin_loc, - location=bin_loc, # Legacy field quantity=quantity, gtin=ean, ) diff --git a/app/modules/inventory/services/inventory_metrics.py b/app/modules/inventory/services/inventory_metrics.py index eb43cfaf..0cb79668 100644 --- a/app/modules/inventory/services/inventory_metrics.py +++ b/app/modules/inventory/services/inventory_metrics.py @@ -81,7 +81,7 @@ class InventoryMetricsProvider: # 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) .scalar() or 0 diff --git a/app/modules/inventory/services/inventory_service.py b/app/modules/inventory/services/inventory_service.py index 79811156..2c715d95 100644 --- a/app/modules/inventory/services/inventory_service.py +++ b/app/modules/inventory/services/inventory_service.py @@ -86,8 +86,7 @@ class InventoryService: product_id=inventory_data.product_id, store_id=store_id, warehouse="strassen", # Default warehouse - bin_location=location, # Use location as bin location - location=location, # Keep for backward compatibility + bin_location=location, quantity=inventory_data.quantity, gtin=product.marketplace_product.gtin, # Optional reference ) @@ -154,8 +153,7 @@ class InventoryService: product_id=inventory_data.product_id, store_id=store_id, warehouse="strassen", # Default warehouse - bin_location=location, # Use location as bin location - location=location, # Keep for backward compatibility + bin_location=location, quantity=inventory_data.quantity, gtin=product.marketplace_product.gtin, ) @@ -445,7 +443,7 @@ class InventoryService: locations = [ InventoryLocationResponse( - location=inv.location, + location=inv.bin_location, quantity=inv.quantity, reserved_quantity=inv.reserved_quantity, available_quantity=inv.available_quantity, @@ -500,7 +498,7 @@ class InventoryService: query = db.query(Inventory).filter(Inventory.store_id == store_id) 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: query = query.filter(Inventory.quantity <= low_stock_threshold) @@ -541,7 +539,7 @@ class InventoryService: inventory.reserved_quantity = inventory_update.reserved_quantity 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) db.flush() @@ -624,7 +622,7 @@ class InventoryService: query = query.filter(Inventory.store_id == store_id) 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: query = query.filter(Inventory.quantity <= low_stock) @@ -668,7 +666,7 @@ class InventoryService: store_code=store.store_code if store else None, product_title=title, product_sku=product.store_sku if product else None, - location=inv.location, + location=inv.bin_location, quantity=inv.quantity, reserved_quantity=inv.reserved_quantity, available_quantity=inv.available_quantity, @@ -717,7 +715,7 @@ class InventoryService: # 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( @@ -768,7 +766,7 @@ class InventoryService: store_id=inv.store_id, store_name=store.name if store else None, product_title=title, - location=inv.location, + location=inv.bin_location, quantity=inv.quantity, reserved_quantity=inv.reserved_quantity, available_quantity=inv.available_quantity, @@ -808,7 +806,7 @@ class InventoryService: self, db: Session, store_id: int | None = None ) -> AdminInventoryLocationsResponse: """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: query = query.filter(Inventory.store_id == store_id) @@ -859,7 +857,7 @@ class InventoryService: store_code=store.store_code, product_title=title, product_sku=product.store_sku if product else None, - location=inv.location, + location=inv.bin_location, quantity=inv.quantity, reserved_quantity=inv.reserved_quantity, available_quantity=inv.available_quantity, @@ -874,7 +872,7 @@ class InventoryService: Inventory.store_id == store_id ) 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: total_query = total_query.filter(Inventory.quantity <= low_stock) total = total_query.scalar() or 0 @@ -940,7 +938,7 @@ class InventoryService: """Get inventory entry by product and location.""" return ( db.query(Inventory) - .filter(Inventory.product_id == product_id, Inventory.location == location) + .filter(Inventory.product_id == product_id, Inventory.bin_location == location) .first() ) diff --git a/app/modules/inventory/tests/unit/test_inventory_model.py b/app/modules/inventory/tests/unit/test_inventory_model.py index d12321e7..24ce1a6f 100644 --- a/app/modules/inventory/tests/unit/test_inventory_model.py +++ b/app/modules/inventory/tests/unit/test_inventory_model.py @@ -19,7 +19,6 @@ class TestInventoryModel: store_id=test_store.id, warehouse="strassen", bin_location="SA-10-01", - location="WAREHOUSE_A", quantity=150, reserved_quantity=10, gtin=test_product.marketplace_product.gtin, @@ -32,7 +31,6 @@ class TestInventoryModel: assert inventory.id is not None assert inventory.product_id == test_product.id assert inventory.store_id == test_store.id - assert inventory.location == "WAREHOUSE_A" assert inventory.bin_location == "SA-10-01" assert inventory.quantity == 150 assert inventory.reserved_quantity == 10 @@ -45,7 +43,6 @@ class TestInventoryModel: store_id=test_store.id, warehouse="strassen", bin_location="SA-10-01", - location="WAREHOUSE_A", quantity=100, ) db.add(inventory1) @@ -58,7 +55,6 @@ class TestInventoryModel: store_id=test_store.id, warehouse="strassen", bin_location="SA-10-01", - location="WAREHOUSE_A", quantity=50, ) db.add(inventory2) @@ -73,7 +69,6 @@ class TestInventoryModel: store_id=test_store.id, warehouse="strassen", bin_location="SA-10-01", - location="WAREHOUSE_A", quantity=100, ) db.add(inventory1) @@ -85,7 +80,6 @@ class TestInventoryModel: store_id=test_store.id, warehouse="strassen", bin_location="SA-10-02", - location="WAREHOUSE_B", quantity=50, ) db.add(inventory2) @@ -102,7 +96,6 @@ class TestInventoryModel: store_id=test_store.id, warehouse="strassen", bin_location="DEF-01-01", - location="DEFAULT_LOC", quantity=100, ) db.add(inventory) @@ -119,7 +112,6 @@ class TestInventoryModel: store_id=test_store.id, warehouse="strassen", bin_location="PROP-01-01", - location="PROP_TEST", quantity=200, reserved_quantity=50, ) @@ -136,7 +128,6 @@ class TestInventoryModel: store_id=test_store.id, warehouse="strassen", bin_location="REL-01-01", - location="REL_TEST", quantity=100, ) db.add(inventory) @@ -155,7 +146,6 @@ class TestInventoryModel: store_id=test_store.id, warehouse="strassen", bin_location="NOGTIN-01-01", - location="NO_GTIN", quantity=100, ) db.add(inventory) diff --git a/app/modules/inventory/tests/unit/test_inventory_schema.py b/app/modules/inventory/tests/unit/test_inventory_schema.py index 790aafce..32190330 100644 --- a/app/modules/inventory/tests/unit/test_inventory_schema.py +++ b/app/modules/inventory/tests/unit/test_inventory_schema.py @@ -183,7 +183,7 @@ class TestInventoryResponseSchema: "id": 1, "product_id": 1, "store_id": 1, - "location": "Warehouse A", + "bin_location": "Warehouse A", "quantity": 100, "reserved_quantity": 20, "gtin": "1234567890123", @@ -203,7 +203,7 @@ class TestInventoryResponseSchema: "id": 1, "product_id": 1, "store_id": 1, - "location": "Warehouse A", + "bin_location": "Warehouse A", "quantity": 100, "reserved_quantity": 30, "gtin": None, @@ -221,7 +221,7 @@ class TestInventoryResponseSchema: "id": 1, "product_id": 1, "store_id": 1, - "location": "Warehouse A", + "bin_location": "Warehouse A", "quantity": 10, "reserved_quantity": 50, # Over-reserved "gtin": None, diff --git a/app/modules/marketplace/routes/api/admin_marketplace.py b/app/modules/marketplace/routes/api/admin_marketplace.py index b725dbb3..f13a1fa1 100644 --- a/app/modules/marketplace/routes/api/admin_marketplace.py +++ b/app/modules/marketplace/routes/api/admin_marketplace.py @@ -12,8 +12,8 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, require_module_access 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.core.schemas.dashboard import ImportStatsResponse from app.modules.enums import FrontendType from app.modules.marketplace.schemas import ( AdminMarketplaceImportJobListResponse, diff --git a/app/modules/marketplace/schemas/marketplace_product.py b/app/modules/marketplace/schemas/marketplace_product.py index e51d9cb6..5d5ca6f8 100644 --- a/app/modules/marketplace/schemas/marketplace_product.py +++ b/app/modules/marketplace/schemas/marketplace_product.py @@ -1,9 +1,10 @@ # app/modules/marketplace/schemas/marketplace_product.py """Pydantic schemas for MarketplaceProduct API validation. -Note: title and description are stored in MarketplaceProductTranslation table, -but we keep them in the API schemas for convenience. The service layer -handles creating/updating translations separately. +Note: title and description are stored in MarketplaceProductTranslation table. +The service layer handles creating/updating translations separately via +dedicated title/description parameters. MarketplaceProductCreate includes +a required title field for API convenience. """ from datetime import datetime @@ -32,10 +33,6 @@ class MarketplaceProductBase(BaseModel): marketplace_product_id: str | None = None - # Localized fields (passed to translations) - title: str | None = None - description: str | None = None - # Links and media link: str | None = None image_link: str | None = None diff --git a/app/modules/marketplace/services/marketplace_product_service.py b/app/modules/marketplace/services/marketplace_product_service.py index a06a6974..32a7e8be 100644 --- a/app/modules/marketplace/services/marketplace_product_service.py +++ b/app/modules/marketplace/services/marketplace_product_service.py @@ -112,10 +112,7 @@ class MarketplaceProductService: ) # Create the product (without title/description - those go in translations) - product_dict = product_data.model_dump() - # Remove any title/description if present in schema (for backwards compatibility) - product_dict.pop("title", None) - product_dict.pop("description", None) + product_dict = product_data.model_dump(exclude={"title", "description"}) db_product = MarketplaceProduct(**product_dict) db.add(db_product) @@ -259,17 +256,21 @@ class MarketplaceProductService: MarketplaceProduct.store_name.ilike(search_term), MarketplaceProduct.brand.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.description.ilike(search_term), + MarketplaceProductTranslation.description.ilike( + search_term + ), ) ) .distinct() .subquery() ) - query = query.filter(MarketplaceProduct.id.in_( - db.query(id_subquery.c.id) - )) + query = query.filter( + MarketplaceProduct.id.in_(db.query(id_subquery.c.id)) + ) total = query.count() products = query.offset(skip).limit(limit).all() @@ -305,12 +306,10 @@ class MarketplaceProductService: try: product = self.get_product_by_id_or_raise(db, marketplace_product_id) - # Update fields - update_data = product_update.model_dump(exclude_unset=True) - - # Remove title/description from update data (handled separately) - update_data.pop("title", None) - update_data.pop("description", None) + # Update fields (exclude title/description - handled separately via translations) + update_data = product_update.model_dump( + exclude_unset=True, exclude={"title", "description"} + ) # Validate GTIN if being updated if "gtin" in update_data and update_data["gtin"]: @@ -447,14 +446,16 @@ class MarketplaceProductService: """ try: # 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: return None total_quantity = sum(entry.quantity for entry in inventory_entries) locations = [ InventoryLocationResponse( - location=entry.location, + location=entry.bin_location, quantity=entry.quantity, reserved_quantity=entry.reserved_quantity or 0, available_quantity=entry.quantity - (entry.reserved_quantity or 0), @@ -661,17 +662,13 @@ class MarketplaceProductService: .distinct() .subquery() ) - query = query.filter(MarketplaceProduct.id.in_( - db.query(id_subquery.c.id) - )) + query = query.filter(MarketplaceProduct.id.in_(db.query(id_subquery.c.id))) if marketplace: query = query.filter(MarketplaceProduct.marketplace == marketplace) if store_name: - query = query.filter( - MarketplaceProduct.store_name.ilike(f"%{store_name}%") - ) + query = query.filter(MarketplaceProduct.store_name.ilike(f"%{store_name}%")) if availability: query = query.filter(MarketplaceProduct.availability == availability) @@ -966,8 +963,12 @@ class MarketplaceProductService: primary_image_url=mp.image_link, additional_images=mp.additional_images, # === Digital product fields === - download_url=mp.download_url if hasattr(mp, "download_url") else None, - license_type=mp.license_type if hasattr(mp, "license_type") else None, + download_url=mp.download_url + if hasattr(mp, "download_url") + else None, + license_type=mp.license_type + if hasattr(mp, "license_type") + else None, ) db.add(product) @@ -990,12 +991,14 @@ class MarketplaceProductService: translations_copied += 1 copied += 1 - details.append({ - "id": mp.id, - "status": "copied", - "gtin": mp.gtin, - "translations_copied": translations_copied, - }) + details.append( + { + "id": mp.id, + "status": "copied", + "gtin": mp.gtin, + "translations_copied": translations_copied, + } + ) except SQLAlchemyError as e: logger.error(f"Failed to copy product {mp.id}: {str(e)}") diff --git a/app/modules/marketplace/tests/unit/test_product_service.py b/app/modules/marketplace/tests/unit/test_product_service.py index bcdc2e0e..3afacbb9 100644 --- a/app/modules/marketplace/tests/unit/test_product_service.py +++ b/app/modules/marketplace/tests/unit/test_product_service.py @@ -204,7 +204,7 @@ class TestProductService: def test_update_product_not_found(self, db): """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: 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""" original_title = test_marketplace_product.get_title() - update_data = MarketplaceProductUpdate(title="") + update_data = MarketplaceProductUpdate() 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 diff --git a/app/modules/orders/services/order_inventory_service.py b/app/modules/orders/services/order_inventory_service.py index fd704828..420f0bb7 100644 --- a/app/modules/orders/services/order_inventory_service.py +++ b/app/modules/orders/services/order_inventory_service.py @@ -70,7 +70,7 @@ class OrderInventoryService: ) .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: """Check if the order item uses a placeholder product.""" @@ -98,7 +98,7 @@ class OrderInventoryService: quantity_change=quantity_change, quantity_after=inventory.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, order_id=order.id, order_number=order.order_number, @@ -229,7 +229,7 @@ class OrderInventoryService: .first() ) if inventory: - location = inventory.location + location = inventory.bin_location if not location: if skip_missing: @@ -358,7 +358,7 @@ class OrderInventoryService: .first() ) if inventory: - location = inventory.location + location = inventory.bin_location if not location: if skip_missing: @@ -467,7 +467,7 @@ class OrderInventoryService: try: reserve_data = InventoryReserve( product_id=item.product_id, - location=inventory.location, + location=inventory.bin_location, quantity=item.quantity, ) updated_inventory = inventory_service.release_reservation( diff --git a/app/modules/payments/routes/__init__.py b/app/modules/payments/routes/__init__.py index c57d08e3..06190f7c 100644 --- a/app/modules/payments/routes/__init__.py +++ b/app/modules/payments/routes/__init__.py @@ -2,10 +2,7 @@ """ 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"] diff --git a/app/modules/registry.py b/app/modules/registry.py index abacf91d..23e01d7e 100644 --- a/app/modules/registry.py +++ b/app/modules/registry.py @@ -64,10 +64,10 @@ def _get_modules_by_tier() -> dict[str, dict[str, ModuleDefinition]]: return discover_modules_by_tier() -# Expose as module-level variables for backward compatibility -# These are computed lazily on first access +# Expose as module-level variables via lazy loading to avoid circular imports +# These are computed on first access using Python's module __getattr__ 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": return _get_all_modules() if name == "CORE_MODULES": diff --git a/app/modules/service.py b/app/modules/service.py index f15ab17f..59ae4fe1 100644 --- a/app/modules/service.py +++ b/app/modules/service.py @@ -5,11 +5,8 @@ Module service for platform module operations. Provides methods to check module enablement, get enabled modules, and filter menu items based on module configuration. -Module configuration can be stored in two places: -1. PlatformModule junction table (preferred, auditable) -2. Platform.settings["enabled_modules"] (fallback, legacy) - -If neither is configured, all modules are enabled (backwards compatibility). +Module configuration is stored in the PlatformModule junction table, +which provides auditability, per-module config, and explicit state tracking. """ import logging @@ -37,27 +34,16 @@ class ModuleService: Handles module enablement checking, module listing, and menu item filtering based on enabled modules. - Module configuration is stored in two places (with fallback): - 1. PlatformModule junction table (preferred, auditable) - 2. Platform.settings["enabled_modules"] (legacy fallback) + Module configuration is stored in the PlatformModule junction table, + which provides auditability, per-module config, and explicit state tracking. - The service checks the junction table first. If no records exist, - it falls back to the JSON settings for backwards compatibility. - - If neither is configured, all modules are enabled (backwards compatibility). + If no PlatformModule records exist for a platform, no optional modules are + enabled (only core modules). Use seed scripts or the admin API to configure + module enablement for each platform. Example PlatformModule records: 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}) - - 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. - Checks two sources with fallback: - 1. PlatformModule junction table (preferred, auditable) - 2. Platform.settings["enabled_modules"] (legacy fallback) - - If neither is configured, returns all module codes (backwards compatibility). + Uses the PlatformModule junction table exclusively. If no records exist, + returns only core modules (empty set of optional modules). Always includes core modules. Args: @@ -149,29 +132,17 @@ class ModuleService: """ platform = db.query(Platform).filter(Platform.id == platform_id).first() if not platform: - logger.warning(f"Platform {platform_id} not found, returning all modules") - return set(MODULES.keys()) + logger.warning(f"Platform {platform_id} not found, returning core modules only") + return get_core_module_codes() - # Try junction table first (preferred) + # Query junction table for enabled modules platform_modules = ( db.query(PlatformModule) .filter(PlatformModule.platform_id == platform_id) .all() ) - if platform_modules: - # Use junction table data - 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) + enabled_set = {pm.module_code for pm in platform_modules if pm.is_enabled} # Always include core modules core_codes = get_core_module_codes() @@ -187,72 +158,6 @@ class ModuleService: 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]: """ Resolve module dependencies by adding required modules. @@ -383,9 +288,7 @@ class ModuleService: """ Get module-specific configuration for a platform. - Checks two sources with fallback: - 1. PlatformModule.config (preferred, auditable) - 2. Platform.settings["module_config"] (legacy fallback) + Uses the PlatformModule junction table for configuration storage. Args: db: Database session @@ -395,7 +298,6 @@ class ModuleService: Returns: Module configuration dict (empty if not configured) """ - # Try junction table first (preferred) platform_module = ( db.query(PlatformModule) .filter( @@ -408,14 +310,7 @@ class ModuleService: if platform_module: return platform_module.config or {} - # Fallback to JSON settings (legacy) - platform = db.query(Platform).filter(Platform.id == platform_id).first() - if not platform: - return {} - - settings = platform.settings or {} - module_configs = settings.get("module_config", {}) - return module_configs.get(module_code, {}) + return {} def set_module_config( self, @@ -569,7 +464,7 @@ class ModuleService: Enable a single module for a platform. Also enables required dependencies. - Uses junction table for auditability when available. + Uses the PlatformModule junction table for auditability. Args: db: Database session @@ -589,9 +484,6 @@ class ModuleService: logger.error(f"Platform {platform_id} not found") 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) # Enable this module and its dependencies @@ -644,7 +536,7 @@ class ModuleService: Core modules cannot be disabled. Also disables modules that depend on this one. - Uses junction table for auditability when available. + Uses the PlatformModule junction table for auditability. Args: db: Database session @@ -669,9 +561,6 @@ class ModuleService: logger.error(f"Platform {platform_id} not found") 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) # Get modules to disable (this one + dependents) @@ -754,8 +643,9 @@ class ModuleService: """ platform = db.query(Platform).filter(Platform.code == platform_code).first() if not platform: - logger.warning(f"Platform '{platform_code}' not found, returning all modules") - return list(MODULES.values()) + logger.warning(f"Platform '{platform_code}' not found, returning core modules only") + 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) diff --git a/app/modules/task_base.py b/app/modules/task_base.py index c7587998..7e5e12fe 100644 --- a/app/modules/task_base.py +++ b/app/modules/task_base.py @@ -166,7 +166,4 @@ class ModuleTask(Task): ) -# Alias for backward compatibility and clarity -DatabaseTask = ModuleTask - -__all__ = ["ModuleTask", "DatabaseTask"] +__all__ = ["ModuleTask"] diff --git a/app/modules/tenancy/models/admin.py b/app/modules/tenancy/models/admin.py index 69d9aaff..c6721906 100644 --- a/app/modules/tenancy/models/admin.py +++ b/app/modules/tenancy/models/admin.py @@ -57,9 +57,6 @@ class AdminAuditLog(Base, TimestampMixin): return f"" -# 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): """ diff --git a/app/modules/tenancy/models/merchant_domain.py b/app/modules/tenancy/models/merchant_domain.py index 75947b52..b674acf2 100644 --- a/app/modules/tenancy/models/merchant_domain.py +++ b/app/modules/tenancy/models/merchant_domain.py @@ -9,7 +9,7 @@ Individual stores can optionally override this with their own custom StoreDomain Domain Resolution Priority: 1. Store-specific custom domain (StoreDomain) -> highest priority 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 ( diff --git a/app/modules/tenancy/models/platform.py b/app/modules/tenancy/models/platform.py index 88366ef5..64522eeb 100644 --- a/app/modules/tenancy/models/platform.py +++ b/app/modules/tenancy/models/platform.py @@ -80,7 +80,7 @@ class Platform(Base, TimestampMixin): unique=True, nullable=True, index=True, - comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')", + comment="Production domain (e.g., 'omsflow.lu', 'rewardflow.lu')", ) path_prefix = Column( diff --git a/app/modules/tenancy/models/platform_module.py b/app/modules/tenancy/models/platform_module.py index 67a90e4f..4f546581 100644 --- a/app/modules/tenancy/models/platform_module.py +++ b/app/modules/tenancy/models/platform_module.py @@ -6,9 +6,6 @@ This junction table provides: - Auditability: Track when modules were enabled/disabled and by whom - Configuration: Per-module settings specific to each platform - 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 ( diff --git a/app/modules/tenancy/routes/api/admin_stores.py b/app/modules/tenancy/routes/api/admin_stores.py index 11f2ab73..bafd33a4 100644 --- a/app/modules/tenancy/routes/api/admin_stores.py +++ b/app/modules/tenancy/routes/api/admin_stores.py @@ -208,9 +208,8 @@ def update_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. -# This endpoint is kept for backwards compatibility but may be removed in future versions. @admin_stores_router.put("/{store_identifier}/verification", response_model=StoreDetailResponse) diff --git a/app/modules/tenancy/schemas/store.py b/app/modules/tenancy/schemas/store.py index 091ddd0b..7754174f 100644 --- a/app/modules/tenancy/schemas/store.py +++ b/app/modules/tenancy/schemas/store.py @@ -206,7 +206,7 @@ class StoreResponse(BaseModel): is_active: bool is_verified: bool - # Language Settings (optional with defaults for backward compatibility) + # Language Settings (optional with sensible defaults) default_language: str = "fr" dashboard_language: str = "fr" storefront_language: str = "fr" diff --git a/app/modules/tenancy/templates/tenancy/admin/platform-edit.html b/app/modules/tenancy/templates/tenancy/admin/platform-edit.html index b0a27c7d..1d2887be 100644 --- a/app/modules/tenancy/templates/tenancy/admin/platform-edit.html +++ b/app/modules/tenancy/templates/tenancy/admin/platform-edit.html @@ -197,7 +197,7 @@ diff --git a/docs/architecture/frontend-detection.md b/docs/architecture/frontend-detection.md index 99764331..62b6c1c8 100644 --- a/docs/architecture/frontend-detection.md +++ b/docs/architecture/frontend-detection.md @@ -8,9 +8,9 @@ The application serves multiple frontends from a single codebase: | 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/*` | -| **STOREFRONT** | Customer-facing shop | `/storefront/*`, `/stores/*`, `orion.oms.lu/*` | +| **STOREFRONT** | Customer-facing shop | `/storefront/*`, `/stores/*`, `orion.omsflow.lu/*` | | **PLATFORM** | Marketing pages | `/`, `/pricing`, `/about` | 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: ``` -1. Admin subdomain (admin.oms.lu) → ADMIN +1. Admin subdomain (admin.omsflow.lu) → ADMIN 2. Path-based detection: - /admin/* or /api/v1/admin/* → ADMIN - /store/* or /api/v1/store/* → STORE - /storefront/*, /shop/*, /stores/* → STOREFRONT - /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 5. Default → PLATFORM ``` @@ -133,7 +133,7 @@ from app.modules.enums import FrontendType # Full detection frontend_type = FrontendDetector.detect( - host="orion.oms.lu", + host="orion.omsflow.lu", path="/products", has_store_context=True ) @@ -167,10 +167,10 @@ if FrontendDetector.is_storefront(host, path, has_store_context=True): | Request | Host | Path | Frontend | |---------|------|------|----------| -| Admin subdomain | admin.oms.lu | /dashboard | ADMIN | -| Store subdomain | orion.oms.lu | /products | STOREFRONT | +| Admin subdomain | admin.omsflow.lu | /dashboard | ADMIN | +| Store subdomain | orion.omsflow.lu | /products | STOREFRONT | | Custom domain | mybakery.lu | /products | STOREFRONT | -| Platform root | oms.lu | /pricing | PLATFORM | +| Platform root | omsflow.lu | /pricing | PLATFORM | ## Request State diff --git a/docs/architecture/middleware.md b/docs/architecture/middleware.md index a16e7b3b..9473df9c 100644 --- a/docs/architecture/middleware.md +++ b/docs/architecture/middleware.md @@ -20,7 +20,7 @@ This middleware layer is **system-wide** and enables the multi-tenant architectu **What it does**: - 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/`) - Default to `main` platform for localhost without prefix - 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 │ @@ -135,13 +135,13 @@ Injects: request.state.store = **Detection Priority** (handled by `FrontendDetector`): ```python -1. Admin subdomain (admin.oms.lu) → ADMIN +1. Admin subdomain (admin.omsflow.lu) → ADMIN 2. Path-based detection: - /admin/* or /api/v1/admin/* → ADMIN - /store/* or /api/v1/store/* → STORE - /storefront/*, /shop/*, /stores/* → STOREFRONT - /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 5. Default → PLATFORM ``` diff --git a/docs/architecture/multi-platform-cms.md b/docs/architecture/multi-platform-cms.md index 111aa353..0a26ed63 100644 --- a/docs/architecture/multi-platform-cms.md +++ b/docs/architecture/multi-platform-cms.md @@ -123,7 +123,7 @@ The system uses different URL patterns for development vs production: **Production (custom domains):** - 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 @@ -259,7 +259,7 @@ Request: GET /about 1. Insert platform record: ```sql 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: @@ -270,7 +270,7 @@ Request: GET /about 3. Configure routing: - **Development:** Access at `localhost:9999/platforms/loyalty/` - - **Production:** Access at `loyalty.lu/` + - **Production:** Access at `rewardflow.lu/` - Platform detected automatically by `PlatformContextMiddleware` - No additional route configuration needed @@ -285,5 +285,5 @@ Request: GET /about | Platform | Code | Dev URL | Prod URL | |----------|------|---------|----------| | Main Marketing | `main` | `localhost:9999/` | `orion.lu/` | -| OMS | `oms` | `localhost:9999/platforms/oms/` | `oms.lu/` | -| Loyalty | `loyalty` | `localhost:9999/platforms/loyalty/` | `loyalty.lu/` | +| OMS | `oms` | `localhost:9999/platforms/oms/` | `omsflow.lu/` | +| Loyalty | `loyalty` | `localhost:9999/platforms/loyalty/` | `rewardflow.lu/` | diff --git a/docs/architecture/url-routing/overview.md b/docs/architecture/url-routing/overview.md index 8f2d8073..398046e9 100644 --- a/docs/architecture/url-routing/overview.md +++ b/docs/architecture/url-routing/overview.md @@ -63,14 +63,14 @@ Orion supports multiple platforms (OMS, Loyalty, Site Builder), each with its ow |-----|----------------| | `orion.lu/` | Main marketing site homepage | | `orion.lu/about` | Main marketing site about page | -| `oms.lu/` | OMS platform homepage | -| `oms.lu/pricing` | OMS platform pricing page | -| `oms.lu/admin/` | Admin panel for OMS platform | -| `oms.lu/store/{code}/` | Store dashboard on OMS | +| `omsflow.lu/` | OMS platform homepage | +| `omsflow.lu/pricing` | OMS platform pricing page | +| `omsflow.lu/admin/` | Admin panel for OMS platform | +| `omsflow.lu/store/{code}/` | Store dashboard on OMS | | `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 @@ -83,9 +83,9 @@ Dev: Storefront: http://localhost:8000/platforms/oms/stores/{store_code}/storefront/ Prod: - Platform: https://oms.lu/ - Admin: https://oms.lu/admin/ - Store: https://oms.lu/store/{store_code}/ + Platform: https://omsflow.lu/ + Admin: https://omsflow.lu/admin/ + Store: https://omsflow.lu/store/{store_code}/ Storefront: https://mybakery.lu/storefront/ (store's custom domain) ``` @@ -98,9 +98,9 @@ Dev: Storefront: http://localhost:8000/platforms/loyalty/stores/{store_code}/storefront/ Prod: - Platform: https://loyalty.lu/ - Admin: https://loyalty.lu/admin/ - Store: https://loyalty.lu/store/{store_code}/ + Platform: https://rewardflow.lu/ + Admin: https://rewardflow.lu/admin/ + Store: https://rewardflow.lu/store/{store_code}/ Storefront: https://myrewards.lu/storefront/ (store's custom domain) ``` @@ -112,7 +112,7 @@ Request arrives ▼ ┌─────────────────────────────────────┐ │ Check: Is this production domain? │ -│ (oms.lu, loyalty.lu, etc.) │ +│ (omsflow.lu, rewardflow.lu, etc.) │ └─────────────────────────────────────┘ │ ├── YES → Route to that platform @@ -139,8 +139,8 @@ Request arrives | Platform | Code | Dev URL | Prod Domain | |----------|------|---------|-------------| | Main Marketing | `main` | `localhost:8000/` | `orion.lu` | -| OMS | `oms` | `localhost:8000/platforms/oms/` | `oms.lu` | -| Loyalty | `loyalty` | `localhost:8000/platforms/loyalty/` | `loyalty.lu` | +| OMS | `oms` | `localhost:8000/platforms/oms/` | `omsflow.lu` | +| Loyalty | `loyalty` | `localhost:8000/platforms/loyalty/` | `rewardflow.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. diff --git a/docs/archive/multi-platform-cms-architecture-implementation-plan.md b/docs/archive/multi-platform-cms-architecture-implementation-plan.md index 9153838c..88eca8b9 100644 --- a/docs/archive/multi-platform-cms-architecture-implementation-plan.md +++ b/docs/archive/multi-platform-cms-architecture-implementation-plan.md @@ -204,7 +204,7 @@ Migration: `alembic/versions/z5f6g7h8i9j0_add_loyalty_platform.py` Inserts Loyalty platform with: - code: `loyalty` - name: `Loyalty+` -- domain: `loyalty.lu` +- domain: `rewardflow.lu` - path_prefix: `loyalty` - theme_config: purple color scheme @@ -256,8 +256,8 @@ Inserts Loyalty platform with: | Platform | Code | Dev URL | Prod URL | |----------|------|---------|----------| | Main Marketing | `main` | `localhost:9999/` | `orion.lu/` | -| OMS | `oms` | `localhost:9999/platforms/oms/` | `oms.lu/` | -| Loyalty | `loyalty` | `localhost:9999/platforms/loyalty/` | `loyalty.lu/` | +| OMS | `oms` | `localhost:9999/platforms/oms/` | `omsflow.lu/` | +| Loyalty | `loyalty` | `localhost:9999/platforms/loyalty/` | `rewardflow.lu/` | ### 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 │ @@ -324,7 +324,7 @@ Included in `docs/architecture/multi-platform-cms.md`: ### Three-Tier Content Resolution ``` -Customer visits: oms.lu/stores/orion/about +Customer visits: omsflow.lu/stores/orion/about │ ▼ ┌─────────────────────────────────────────────────────────────┐ diff --git a/docs/deployment/hetzner-server-setup.md b/docs/deployment/hetzner-server-setup.md index 4c66360b..b894c1b2 100644 --- a/docs/deployment/hetzner-server-setup.md +++ b/docs/deployment/hetzner-server-setup.md @@ -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):** - - [ ] 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 !!! 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) - Template renamed: `homepage-wizamart.html` → `homepage-orion.html` - **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` - `.dockerignore` added — prevents `.env` from being baked into Docker images - `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 | `flower` | `91.99.65.229` | 300 | -### oms.lu (OMS Platform) — TODO +### omsflow.lu (OMS Platform) — TODO | 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 | `flower` | `2a01:4f8:1c1a:b39c::1` | 300 | -Repeat for `oms.lu` and `rewardflow.lu`. +Repeat for `omsflow.lu` and `rewardflow.lu`. !!! 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` @@ -502,14 +502,14 @@ www.wizard.lu { redir https://wizard.lu{uri} permanent } -# ─── Platform 2: OMS (oms.lu) ─────────────────────────────── -# Uncomment after DNS is configured for oms.lu -# oms.lu { +# ─── Platform 2: OMS (omsflow.lu) ─────────────────────────────── +# Uncomment after DNS is configured for omsflow.lu +# omsflow.lu { # reverse_proxy localhost:8001 # } # -# www.oms.lu { -# redir https://oms.lu{uri} permanent +# www.omsflow.lu { +# redir https://omsflow.lu{uri} permanent # } # ─── Platform 3: Loyalty+ (rewardflow.lu) ────────────────── @@ -537,14 +537,14 @@ flower.wizard.lu { ``` !!! 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: | Platform | code | domain | |---|---|---| | Main | `main` | `wizard.lu` | - | OMS | `oms` | `oms.lu` | + | OMS | `oms` | `omsflow.lu` | | Loyalty+ | `loyalty` | `rewardflow.lu` | Start Caddy: @@ -588,17 +588,17 @@ cd ~/gitea && docker compose up -d gitea 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 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) -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 -*.oms.lu { +*.omsflow.lu { reverse_proxy localhost:8001 } @@ -1099,7 +1099,7 @@ docker stats --no-stream |---|---|---|---| | Orion API | 8000 | 8001 | `api.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) | | PostgreSQL | 5432 | 5432 | (internal only) | | Redis | 6379 | 6380 | (internal only) | @@ -1250,7 +1250,7 @@ After Caddy is configured: | Gitea | `https://git.wizard.lu` | | Flower | `https://flower.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) | Direct IP access (temporary, until firewall rules are removed): diff --git a/docs/features/user-journeys/loyalty.md b/docs/features/user-journeys/loyalty.md index 7351f8c2..4dd75478 100644 --- a/docs/features/user-journeys/loyalty.md +++ b/docs/features/user-journeys/loyalty.md @@ -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. 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 | 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) | | 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" 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) 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):** | Page | Production URL | |------|----------------| -| Loyalty Dashboard | `https://bookstore.loyalty.lu/account/loyalty` | -| Transaction History | `https://bookstore.loyalty.lu/account/loyalty/history` | -| Self-Enrollment | `https://bookstore.loyalty.lu/loyalty/join` | -| Enrollment Success | `https://bookstore.loyalty.lu/loyalty/join/success` | +| Loyalty Dashboard | `https://bookstore.rewardflow.lu/account/loyalty` | +| Transaction History | `https://bookstore.rewardflow.lu/account/loyalty/history` | +| Self-Enrollment | `https://bookstore.rewardflow.lu/loyalty/join` | +| Enrollment Success | `https://bookstore.rewardflow.lu/loyalty/join/success` | **Storefront API:** | Method | Production URL | |--------|----------------| -| GET card | `https://bookstore.loyalty.lu/api/storefront/loyalty/card` | -| GET transactions | `https://bookstore.loyalty.lu/api/storefront/loyalty/transactions` | -| POST enroll | `https://bookstore.loyalty.lu/api/storefront/loyalty/enroll` | -| GET program | `https://bookstore.loyalty.lu/api/storefront/loyalty/program` | +| GET card | `https://bookstore.rewardflow.lu/api/storefront/loyalty/card` | +| GET transactions | `https://bookstore.rewardflow.lu/api/storefront/loyalty/transactions` | +| POST enroll | `https://bookstore.rewardflow.lu/api/storefront/loyalty/enroll` | +| GET program | `https://bookstore.rewardflow.lu/api/storefront/loyalty/program` | **Store backend (staff/owner):** | Page | Production URL | |------|----------------| -| Store Login | `https://bookstore.loyalty.lu/store/BOOKSTORE/login` | -| Terminal | `https://bookstore.loyalty.lu/store/BOOKSTORE/loyalty/terminal` | -| Cards | `https://bookstore.loyalty.lu/store/BOOKSTORE/loyalty/cards` | -| Settings | `https://bookstore.loyalty.lu/store/BOOKSTORE/loyalty/settings` | -| Stats | `https://bookstore.loyalty.lu/store/BOOKSTORE/loyalty/stats` | +| Store Login | `https://bookstore.rewardflow.lu/store/BOOKSTORE/login` | +| Terminal | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/terminal` | +| Cards | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/cards` | +| Settings | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/settings` | +| Stats | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/stats` | **Store API:** | Method | Production URL | |--------|----------------| -| GET program | `https://bookstore.loyalty.lu/api/store/loyalty/program` | -| POST stamp | `https://bookstore.loyalty.lu/api/store/loyalty/stamp` | -| POST points | `https://bookstore.loyalty.lu/api/store/loyalty/points` | -| POST enroll | `https://bookstore.loyalty.lu/api/store/loyalty/cards/enroll` | -| POST lookup | `https://bookstore.loyalty.lu/api/store/loyalty/cards/lookup` | +| GET program | `https://bookstore.rewardflow.lu/api/store/loyalty/program` | +| POST stamp | `https://bookstore.rewardflow.lu/api/store/loyalty/stamp` | +| POST points | `https://bookstore.rewardflow.lu/api/store/loyalty/points` | +| POST enroll | `https://bookstore.rewardflow.lu/api/store/loyalty/cards/enroll` | +| POST lookup | `https://bookstore.rewardflow.lu/api/store/loyalty/cards/lookup` | ### Platform Admin & Public API (always on platform domain) | Page / Endpoint | Production URL | |-----------------|----------------| -| Admin Programs | `https://loyalty.lu/admin/loyalty/programs` | -| Admin Analytics | `https://loyalty.lu/admin/loyalty/analytics` | -| Admin Merchant Detail | `https://loyalty.lu/admin/loyalty/merchants/{id}` | -| Admin Merchant Settings | `https://loyalty.lu/admin/loyalty/merchants/{id}/settings` | -| Admin API - Programs | `GET https://loyalty.lu/api/admin/loyalty/programs` | -| Admin API - Stats | `GET https://loyalty.lu/api/admin/loyalty/stats` | -| Public API - Program | `GET https://loyalty.lu/api/loyalty/programs/ORION` | -| Apple Wallet Pass | `GET https://loyalty.lu/api/loyalty/passes/apple/{serial}.pkpass` | +| Admin Programs | `https://rewardflow.lu/admin/loyalty/programs` | +| Admin Analytics | `https://rewardflow.lu/admin/loyalty/analytics` | +| Admin Merchant Detail | `https://rewardflow.lu/admin/loyalty/merchants/{id}` | +| Admin Merchant Settings | `https://rewardflow.lu/admin/loyalty/merchants/{id}/settings` | +| Admin API - Programs | `GET https://rewardflow.lu/api/admin/loyalty/programs` | +| Admin API - Stats | `GET https://rewardflow.lu/api/admin/loyalty/stats` | +| Public API - Program | `GET https://rewardflow.lu/api/loyalty/programs/ORION` | +| Apple Wallet Pass | `GET https://rewardflow.lu/api/loyalty/passes/apple/{serial}.pkpass` | ### 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) | | FASHIONHUB | Fashion Group | `fashionhub.store` | `fashionhub.store` (store override) | -| WIZAGADGETS | WizaCorp | _(none)_ | `wizagadgets.loyalty.lu` (subdomain fallback) | -| WIZAHOME | WizaCorp | _(none)_ | `wizahome.loyalty.lu` (subdomain fallback) | -| FASHIONOUTLET | Fashion Group | _(none)_ | `fashionoutlet.loyalty.lu` (subdomain fallback) | -| BOOKSTORE | BookWorld | _(none)_ | `bookstore.loyalty.lu` (subdomain fallback) | -| BOOKDIGITAL | BookWorld | _(none)_ | `bookdigital.loyalty.lu` (subdomain fallback) | +| WIZAGADGETS | WizaCorp | _(none)_ | `wizagadgets.rewardflow.lu` (subdomain fallback) | +| WIZAHOME | WizaCorp | _(none)_ | `wizahome.rewardflow.lu` (subdomain fallback) | +| FASHIONOUTLET | Fashion Group | _(none)_ | `fashionoutlet.rewardflow.lu` (subdomain fallback) | +| BOOKSTORE | BookWorld | _(none)_ | `bookstore.rewardflow.lu` (subdomain fallback) | +| BOOKDIGITAL | BookWorld | _(none)_ | `bookdigital.rewardflow.lu` (subdomain fallback) | !!! example "After merchant domain registration" 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 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: - Dev: `http://localhost:9999/platforms/loyalty/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: - API Dev: `GET http://localhost:9999/platforms/loyalty/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: - 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}` 2. The API returns a `verification_token` for DNS verification 3. Get DNS 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}` 5. Verify the domain: - 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: - 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}` 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: - 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}` 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) @@ -509,11 +509,11 @@ flowchart TD 1. Login as `john.owner@wizacorp.com` at: - Dev: `http://localhost:9999/platforms/loyalty/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: - Dev: `http://localhost:9999/platforms/loyalty/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: - Dev: `POST http://localhost:9999/platforms/loyalty/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` 9. Verify program is live - check from another store (same merchant): - 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:** @@ -649,19 +649,19 @@ flowchart TD 1. Visit the public enrollment page: - Dev: `http://localhost:9999/platforms/loyalty/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) 3. Submit enrollment: - Dev: `POST http://localhost:9999/platforms/loyalty/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: - 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 (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: - 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): - Dev: `http://localhost:9999/platforms/loyalty/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 Prod: `GET https://orion.shop/api/storefront/loyalty/card` 3. View full transaction history: - Dev: `http://localhost:9999/platforms/loyalty/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 Prod: `GET https://orion.shop/api/storefront/loyalty/transactions` @@ -698,22 +698,22 @@ flowchart TD 1. Login as admin 2. View all 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: - 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: - 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: - 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 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 7. Check other merchants: - 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: - 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:** diff --git a/main.py b/main.py index 994854fb..5b70cfb6 100644 --- a/main.py +++ b/main.py @@ -521,8 +521,8 @@ async def store_root_path( # # URL Structure (Production - domain-based): # - orion.lu/ → Main marketing site -# - oms.lu/ → OMS platform homepage -# - loyalty.lu/ → Loyalty platform homepage +# - omsflow.lu/ → OMS platform homepage +# - rewardflow.lu/ → Loyalty platform homepage # ============================================================================ diff --git a/middleware/platform_context.py b/middleware/platform_context.py index a74156ec..1490f509 100644 --- a/middleware/platform_context.py +++ b/middleware/platform_context.py @@ -6,7 +6,7 @@ Detects platform from host/domain/path and injects into request.state. This middleware runs BEFORE StoreContextMiddleware to establish platform context. 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/*) URL Structure: @@ -42,7 +42,7 @@ class PlatformContextManager: Detect platform context from request. 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" 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 if "." in host_without_port: # This could be: - # - Platform domain: oms.lu, loyalty.lu - # - Store subdomain: store.oms.lu + # - Platform domain: omsflow.lu, rewardflow.lu + # - Store subdomain: store.omsflow.lu # - Custom domain: shop.mymerchant.com # We detect platform domain vs subdomain by checking if it's a root domain 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 { "domain": host_without_port, "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 "." in host_without_port: parts = host_without_port.split(".") - if len(parts) == 2: # Root domain like oms.lu + if len(parts) == 2: # Root domain like omsflow.lu return { "domain": host_without_port, "detection_method": "domain", diff --git a/scripts/seed/init_production.py b/scripts/seed/init_production.py index f2486fd5..e53ca617 100644 --- a/scripts/seed/init_production.py +++ b/scripts/seed/init_production.py @@ -37,7 +37,7 @@ from app.core.config import ( from app.core.database import SessionLocal from app.core.environment import is_production 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 ( permission_discovery_service, ) @@ -140,7 +140,7 @@ def create_default_platforms(db: Session) -> list[Platform]: "code": "oms", "name": "Orion OMS", "description": "Order Management System for multi-store e-commerce", - "domain": "oms.lu", + "domain": "omsflow.lu", "path_prefix": "oms", "default_language": "fr", "supported_languages": ["fr", "de", "en"], @@ -447,6 +447,50 @@ def create_subscription_tiers(db: Session, platform: Platform) -> int: 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: """Verify that RBAC schema is in place.""" @@ -531,6 +575,10 @@ def initialize_production(db: Session, auth_manager: AuthManager): else: 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 db.commit() print_success("All changes committed") @@ -546,10 +594,12 @@ def print_summary(db: Session): setting_count = db.query(AdminSetting).count() platform_count = db.query(Platform).count() tier_count = db.query(SubscriptionTier).filter(SubscriptionTier.is_active.is_(True)).count() + module_count = db.query(PlatformModule).count() print("\n📊 Database Status:") print(f" Admin users: {user_count}") print(f" Platforms: {platform_count}") + print(f" Platform mods: {module_count}") print(f" Admin settings: {setting_count}") print(f" Sub. tiers: {tier_count}") diff --git a/tests/fixtures/auth_fixtures.py b/tests/fixtures/auth_fixtures.py index af49dc87..b32bcabe 100644 --- a/tests/fixtures/auth_fixtures.py +++ b/tests/fixtures/auth_fixtures.py @@ -49,7 +49,7 @@ def test_admin(db, auth_manager): hashed_password=hashed_password, role="admin", 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.commit() @@ -130,7 +130,7 @@ def another_admin(db, auth_manager): hashed_password=hashed_password, role="admin", is_active=True, - is_super_admin=True, # Super admin for backward compatibility + is_super_admin=True, # Full platform access ) db.add(admin) db.commit() diff --git a/tests/fixtures/store_fixtures.py b/tests/fixtures/store_fixtures.py index d6efca1e..a269ab84 100644 --- a/tests/fixtures/store_fixtures.py +++ b/tests/fixtures/store_fixtures.py @@ -192,7 +192,6 @@ def test_inventory(db, test_product): store_id=test_product.store_id, warehouse="strassen", bin_location=f"SA-10-{unique_id[:2]}", - location=f"WAREHOUSE_A_{unique_id}", quantity=100, reserved_quantity=10, gtin=test_product.marketplace_product.gtin, @@ -213,7 +212,6 @@ def multiple_inventory_entries(db, multiple_products, test_store): gtin=product.gtin, warehouse="strassen", bin_location=f"SA-{i:02d}-01", - location=f"LOC_{i}", quantity=10 + (i * 5), reserved_quantity=i, store_id=test_store.id, diff --git a/tests/integration/middleware/test_merchant_domain_flow.py b/tests/integration/middleware/test_merchant_domain_flow.py index 7c0f9bf7..0ae79d7c 100644 --- a/tests/integration/middleware/test_merchant_domain_flow.py +++ b/tests/integration/middleware/test_merchant_domain_flow.py @@ -7,7 +7,7 @@ requests, including: - Merchant domain → platform resolved → store resolved - Store-specific domain overrides merchant domain - 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 @@ -95,7 +95,7 @@ class TestMerchantDomainFlow: assert data["store_code"] == store.store_code 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( "/middleware-test/subdomain-detection", headers={ diff --git a/tests/unit/core/test_frontend_detector.py b/tests/unit/core/test_frontend_detector.py index 8c896338..859d1c58 100644 --- a/tests/unit/core/test_frontend_detector.py +++ b/tests/unit/core/test_frontend_detector.py @@ -12,7 +12,7 @@ Tests cover: 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 @@ -22,7 +22,7 @@ class TestFrontendDetectorAdmin: def test_detect_admin_from_subdomain(self): """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 def test_detect_admin_from_subdomain_with_port(self): @@ -42,7 +42,7 @@ class TestFrontendDetectorAdmin: def test_detect_admin_nested_path(self): """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 @@ -62,7 +62,7 @@ class TestFrontendDetectorStore: def test_detect_store_nested_path(self): """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 def test_stores_plural_not_store_dashboard(self): @@ -92,7 +92,7 @@ class TestFrontendDetectorStorefront: def test_detect_storefront_from_store_subdomain(self): """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 def test_detect_storefront_from_store_context(self): @@ -115,7 +115,7 @@ class TestFrontendDetectorPlatform: def test_detect_platform_from_marketing_page(self): """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 def test_detect_platform_from_about(self): @@ -135,7 +135,7 @@ class TestFrontendDetectorPriority: def test_admin_subdomain_priority_over_path(self): """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 def test_admin_path_priority_over_store_context(self): @@ -148,7 +148,7 @@ class TestFrontendDetectorPriority: def test_path_priority_over_subdomain(self): """Test that explicit path takes priority for store/storefront.""" # /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 @@ -159,20 +159,20 @@ class TestFrontendDetectorHelpers: def test_strip_port(self): """Test port stripping from host.""" 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" def test_get_subdomain(self): """Test subdomain extraction.""" - assert FrontendDetector._get_subdomain("orion.oms.lu") == "orion" - assert FrontendDetector._get_subdomain("admin.oms.lu") == "admin" - assert FrontendDetector._get_subdomain("oms.lu") is None + assert FrontendDetector._get_subdomain("orion.omsflow.lu") == "orion" + assert FrontendDetector._get_subdomain("admin.omsflow.lu") == "admin" + assert FrontendDetector._get_subdomain("omsflow.lu") is None assert FrontendDetector._get_subdomain("localhost") is None assert FrontendDetector._get_subdomain("127.0.0.1") is None def test_is_admin(self): """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", "/store/settings") is False @@ -185,13 +185,13 @@ class TestFrontendDetectorHelpers: def test_is_storefront(self): """Test is_storefront convenience method.""" 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 def test_is_platform(self): """Test is_platform convenience method.""" 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 def test_is_api_request(self): @@ -201,46 +201,21 @@ class TestFrontendDetectorHelpers: 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 class TestReservedSubdomains: """Test suite for reserved subdomain handling.""" def test_www_subdomain_not_storefront(self): """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 def test_api_subdomain_not_storefront(self): """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 def test_portal_subdomain_not_storefront(self): """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 diff --git a/tests/unit/middleware/test_frontend_type.py b/tests/unit/middleware/test_frontend_type.py index c17b3fa8..88c68d3a 100644 --- a/tests/unit/middleware/test_frontend_type.py +++ b/tests/unit/middleware/test_frontend_type.py @@ -80,7 +80,7 @@ class TestFrontendTypeMiddleware: request = Mock(spec=Request) request.url = Mock(path="/products") - request.headers = {"host": "orion.oms.lu"} + request.headers = {"host": "orion.omsflow.lu"} mock_store = Mock() mock_store.name = "Test Store" request.state = Mock(clean_path="/products", store=mock_store) diff --git a/tests/unit/middleware/test_platform_context.py b/tests/unit/middleware/test_platform_context.py index 09b251f4..fe59ea19 100644 --- a/tests/unit/middleware/test_platform_context.py +++ b/tests/unit/middleware/test_platform_context.py @@ -13,7 +13,7 @@ Tests cover: URL Structure: - Main marketing site: localhost:9999/ (no prefix) -> 'main' 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 @@ -42,35 +42,35 @@ class TestPlatformContextManager: # ======================================================================== 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.headers = {"host": "oms.lu"} + request.headers = {"host": "omsflow.lu"} request.url = Mock(path="/pricing") context = PlatformContextManager.detect_platform_context(request) assert context is not None assert context["detection_method"] == "domain" - assert context["domain"] == "oms.lu" - assert context["host"] == "oms.lu" + assert context["domain"] == "omsflow.lu" + assert context["host"] == "omsflow.lu" assert context["original_path"] == "/pricing" def test_detect_domain_with_port(self): """Test domain detection with port number.""" request = Mock(spec=Request) - request.headers = {"host": "loyalty.lu:8443"} + request.headers = {"host": "rewardflow.lu:8443"} request.url = Mock(path="/features") context = PlatformContextManager.detect_platform_context(request) assert context is not None assert context["detection_method"] == "domain" - assert context["domain"] == "loyalty.lu" + assert context["domain"] == "rewardflow.lu" def test_detect_domain_three_level_not_detected(self): """Test that three-level domains (subdomains) are not detected as platform domains.""" request = Mock(spec=Request) - request.headers = {"host": "store.oms.lu"} + request.headers = {"host": "store.omsflow.lu"} request.url = Mock(path="/shop") 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 - context = {"detection_method": "domain", "domain": "oms.lu"} + context = {"detection_method": "domain", "domain": "omsflow.lu"} platform = PlatformContextManager.get_platform_from_context(mock_db, context) @@ -398,7 +398,7 @@ class TestPlatformContextManager: request = Mock(spec=Request) 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) @@ -598,7 +598,7 @@ class TestPlatformContextMiddleware: scope = { "type": "http", "path": "/pricing", - "headers": [(b"host", b"oms.lu")], + "headers": [(b"host", b"omsflow.lu")], } receive = AsyncMock() @@ -918,7 +918,7 @@ class TestEdgeCases: def test_admin_subdomain_with_production_domain(self): """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): """Test static file detection is case-insensitive.""" @@ -945,7 +945,7 @@ class TestURLRoutingSummary: - Main marketing: localhost:9999/ -> 'main' platform, path unchanged - OMS platform: localhost:9999/platforms/oms/pricing -> 'oms' platform, path=/pricing - 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): @@ -987,11 +987,11 @@ class TestURLRoutingSummary: def test_production_domain_routing(self): """Document: Production domains don't rewrite path.""" request = Mock(spec=Request) - request.headers = {"host": "oms.lu"} + request.headers = {"host": "omsflow.lu"} request.url = Mock(path="/pricing") context = PlatformContextManager.detect_platform_context(request) 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 diff --git a/tests/unit/services/test_admin_service.py b/tests/unit/services/test_admin_service.py index 4bec1b3a..dbdbb5e4 100644 --- a/tests/unit/services/test_admin_service.py +++ b/tests/unit/services/test_admin_service.py @@ -203,17 +203,17 @@ class TestAdminService: """Test getting store statistics""" stats = stats_service.get_store_statistics(db) - assert "total_stores" in stats - assert "active_stores" in stats - assert "verified_stores" in stats + assert "total" in stats + assert "verified" in stats + assert "pending" in stats + assert "inactive" in stats assert "verification_rate" in stats - assert isinstance(stats["total_stores"], int) - assert isinstance(stats["active_stores"], int) - assert isinstance(stats["verified_stores"], int) + assert isinstance(stats["total"], int) + assert isinstance(stats["verified"], int) assert isinstance(stats["verification_rate"], int | float) - assert stats["total_stores"] >= 1 + assert stats["total"] >= 1 # Error Handling Tests 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""" stats = stats_service.get_store_statistics(empty_db) - assert stats["total_stores"] == 0 - assert stats["active_stores"] == 0 - assert stats["verified_stores"] == 0 + assert stats["total"] == 0 + assert stats["verified"] == 0 + assert stats["inactive"] == 0 assert stats["verification_rate"] == 0 diff --git a/tests/unit/services/test_stats_service.py b/tests/unit/services/test_stats_service.py index bb9b6bd8..32ab0042 100644 --- a/tests/unit/services/test_stats_service.py +++ b/tests/unit/services/test_stats_service.py @@ -362,21 +362,20 @@ class TestStatsService: """Test getting store statistics for admin dashboard.""" stats = self.service.get_store_statistics(db) - assert "total_stores" in stats - assert "active_stores" in stats - assert "inactive_stores" in stats - assert "verified_stores" in stats + assert "total" in stats + assert "verified" in stats + assert "pending" in stats + assert "inactive" in stats assert "verification_rate" in stats - assert stats["total_stores"] >= 1 - assert stats["active_stores"] >= 1 + assert stats["total"] >= 1 def test_get_store_statistics_calculates_rates(self, db, test_store): """Test store statistics calculates rates correctly.""" stats = self.service.get_store_statistics(db) - if stats["total_stores"] > 0: - expected_rate = stats["verified_stores"] / stats["total_stores"] * 100 + if stats["total"] > 0: + expected_rate = stats["verified"] / stats["total"] * 100 assert abs(stats["verification_rate"] - expected_rate) < 0.01 def test_get_store_statistics_database_error(self, db): @@ -422,9 +421,9 @@ class TestStatsService: """Test getting import statistics.""" stats = self.service.get_import_statistics(db) - assert "total_imports" in stats - assert "completed_imports" in stats - assert "failed_imports" in stats + assert "total" in stats + assert "completed" in stats + assert "failed" in stats assert "success_rate" in stats def test_get_import_statistics_with_jobs( @@ -433,15 +432,15 @@ class TestStatsService: """Test import statistics with existing jobs.""" stats = self.service.get_import_statistics(db) - assert stats["total_imports"] >= 1 - assert stats["completed_imports"] >= 1 # test job has completed status + assert stats["total"] >= 1 + assert stats["completed"] >= 1 # test job has completed status def test_get_import_statistics_calculates_rate(self, db): """Test import statistics calculates success rate.""" stats = self.service.get_import_statistics(db) - if stats["total_imports"] > 0: - expected_rate = stats["completed_imports"] / stats["total_imports"] * 100 + if stats["total"] > 0: + expected_rate = stats["completed"] / stats["total"] * 100 assert abs(stats["success_rate"] - expected_rate) < 0.01 else: assert stats["success_rate"] == 0 @@ -452,9 +451,9 @@ class TestStatsService: stats = self.service.get_import_statistics(db) # Should return default values, not raise exception - assert stats["total_imports"] == 0 - assert stats["completed_imports"] == 0 - assert stats["failed_imports"] == 0 + assert stats["total"] == 0 + assert stats["completed"] == 0 + assert stats["failed"] == 0 assert stats["success_rate"] == 0 # ==================== Private Helper Method Tests ==================== @@ -538,7 +537,6 @@ class TestStatsService: gtin=f"123456789{unique_id[:4]}", warehouse="strassen", bin_location=f"ST-{unique_id[:2]}-01", - location=f"LOCATION2_{unique_id}", quantity=25, reserved_quantity=5, store_id=test_inventory.store_id,