refactor: remove all backward compatibility code across 70 files
Some checks failed
Some checks failed
Clean up 28 backward compatibility instances identified in the codebase. The app is not live, so all shims are replaced with the target architecture: - Remove legacy Inventory.location column (use bin_location exclusively) - Remove dashboard _extract_metric_value helper (use flat metrics dict) - Remove legacy stat field duplicates (total_stores, total_imports, etc.) - Remove 13 re-export shims and class aliases across modules - Remove module-enabling JSON fallback (use PlatformModule junction table) - Remove menu_to_legacy_format() conversion (return dataclasses directly) - Remove title/description from MarketplaceProductBase schema - Clean billing convenience method docstrings - Clean test fixtures and backward-compat comments - Add PlatformModule seeding to init_production.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"<Inventory(product_id={self.product_id}, location='{self.location}', quantity={self.quantity})>"
|
||||
return f"<Inventory(product_id={self.product_id}, warehouse='{self.warehouse}', bin='{self.bin_location}', quantity={self.quantity})>"
|
||||
|
||||
@property
|
||||
def available_quantity(self):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -166,7 +166,4 @@ class ModuleTask(Task):
|
||||
)
|
||||
|
||||
|
||||
# Alias for backward compatibility and clarity
|
||||
DatabaseTask = ModuleTask
|
||||
|
||||
__all__ = ["ModuleTask", "DatabaseTask"]
|
||||
__all__ = ["ModuleTask"]
|
||||
|
||||
@@ -57,9 +57,6 @@ class AdminAuditLog(Base, TimestampMixin):
|
||||
return f"<AdminAuditLog(id={self.id}, action='{self.action}', target={self.target_type}:{self.target_id})>"
|
||||
|
||||
|
||||
# AdminNotification has been moved to app/modules/messaging/models/admin_notification.py
|
||||
# It's re-exported via models/database/__init__.py for backwards compatibility
|
||||
|
||||
|
||||
class AdminSetting(Base, TimestampMixin):
|
||||
"""
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.domain"
|
||||
placeholder="e.g., oms.lu"
|
||||
placeholder="e.g., omsflow.lu"
|
||||
:disabled="saving"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user