feat: multi-module improvements across merchant, store, i18n, and customer systems
All checks were successful
All checks were successful
- Fix platform-grouped merchant sidebar menu with core items at root level - Add merchant store management (detail page, create store, team page) - Fix store settings 500 error by removing dead stripe/API tab - Move onboarding translations to module-owned locale files - Fix onboarding banner i18n with server-side rendering + context inheritance - Refactor login language selectors to use languageSelector() function (LANG-002) - Move HTTPException handling to global exception handler in merchant routes (API-003) - Add language selector to all login pages and portal headers - Fix customer module: drop order stats from customer model, add to orders module - Fix admin menu config visibility for super admin platform context - Fix storefront auth and layout issues - Add missing i18n translations for onboarding steps (en/fr/de/lb) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -274,6 +274,103 @@ class MenuService:
|
||||
# Merchant Menu
|
||||
# =========================================================================
|
||||
|
||||
def get_merchant_menu_by_platform(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
) -> tuple[list, list]:
|
||||
"""
|
||||
Get merchant menu items grouped by platform.
|
||||
|
||||
Returns two lists of DiscoveredMenuSection:
|
||||
- core_sections: items from core modules (always visible, no platform grouping)
|
||||
- platform_sections: items from non-core modules, with platform metadata
|
||||
|
||||
Each platform section has its label_key replaced with the platform name
|
||||
and a `platform_code` attribute added for frontend identification.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
merchant_id: Merchant ID
|
||||
|
||||
Returns:
|
||||
Tuple of (core_sections, platform_sections)
|
||||
"""
|
||||
from app.modules.billing.services.subscription_service import (
|
||||
subscription_service,
|
||||
)
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
|
||||
core_codes = {code for code, mod in MODULES.items() if mod.is_core}
|
||||
|
||||
# Get all sections from core modules (no platform filtering needed)
|
||||
core_sections = menu_discovery_service.get_menu_sections_for_frontend(
|
||||
db,
|
||||
FrontendType.MERCHANT,
|
||||
enabled_module_codes=core_codes,
|
||||
)
|
||||
# Filter to only items from core modules
|
||||
for section in core_sections:
|
||||
section.items = [i for i in section.items if i.module_code in core_codes]
|
||||
core_sections = [s for s in core_sections if s.items]
|
||||
|
||||
# Get platform-specific sections
|
||||
platform_sections = []
|
||||
platform_ids = subscription_service.get_active_subscription_platform_ids(
|
||||
db, merchant_id
|
||||
)
|
||||
|
||||
for pid in platform_ids:
|
||||
platform = platform_service.get_platform_by_id(db, pid)
|
||||
if not platform:
|
||||
continue
|
||||
|
||||
# Get modules enabled on this platform (excluding core — already at root)
|
||||
platform_module_codes = module_service.get_enabled_module_codes(db, pid)
|
||||
non_core_codes = platform_module_codes - core_codes
|
||||
|
||||
if not non_core_codes:
|
||||
continue # No platform-specific items
|
||||
|
||||
# Get sections for this platform's non-core modules
|
||||
sections = menu_discovery_service.get_menu_sections_for_frontend(
|
||||
db,
|
||||
FrontendType.MERCHANT,
|
||||
enabled_module_codes=non_core_codes,
|
||||
)
|
||||
# Filter to only items from non-core modules
|
||||
for section in sections:
|
||||
section.items = [
|
||||
i for i in section.items if i.module_code in non_core_codes
|
||||
]
|
||||
|
||||
# Flatten all platform sections into one section per platform
|
||||
all_items = []
|
||||
for section in sections:
|
||||
all_items.extend(section.items)
|
||||
|
||||
if not all_items:
|
||||
continue
|
||||
|
||||
# Create a single section for this platform
|
||||
from app.modules.core.services.menu_discovery_service import (
|
||||
DiscoveredMenuSection,
|
||||
)
|
||||
|
||||
platform_section = DiscoveredMenuSection(
|
||||
id=f"platform-{platform.code}",
|
||||
label_key=platform.name, # Use platform name directly
|
||||
icon=getattr(platform, "icon", None) or "globe-alt",
|
||||
order=30 + platform_ids.index(pid),
|
||||
is_super_admin_only=False,
|
||||
is_collapsible=True,
|
||||
items=sorted(all_items, key=lambda i: (i.section_order, i.order)),
|
||||
)
|
||||
platform_sections.append(platform_section)
|
||||
|
||||
return core_sections, platform_sections
|
||||
|
||||
def get_merchant_enabled_module_codes(
|
||||
self,
|
||||
db: Session,
|
||||
@@ -472,60 +569,6 @@ class MenuService:
|
||||
|
||||
return result
|
||||
|
||||
def get_user_menu_config(
|
||||
self,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
) -> list[MenuItemConfig]:
|
||||
"""
|
||||
Get admin menu configuration for a super admin user.
|
||||
|
||||
Super admins don't have platform context, so all modules are shown.
|
||||
Module enablement is always True for super admin menu config.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Super admin user ID
|
||||
|
||||
Returns:
|
||||
List of MenuItemConfig with current visibility state
|
||||
"""
|
||||
shown_items = self._get_shown_items(db, FrontendType.ADMIN, user_id=user_id)
|
||||
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)
|
||||
|
||||
result = []
|
||||
for item in all_items:
|
||||
# If no config exists (shown_items is None), show all by default
|
||||
# Otherwise, item is visible if in shown_items or mandatory
|
||||
is_visible = (
|
||||
shown_items is None
|
||||
or item.id in shown_items
|
||||
or item.id in mandatory_items
|
||||
)
|
||||
|
||||
result.append(
|
||||
MenuItemConfig(
|
||||
id=item.id,
|
||||
label=item.label_key,
|
||||
icon=item.icon,
|
||||
url=item.route,
|
||||
section_id=item.section_id,
|
||||
section_label=item.section_label_key,
|
||||
is_visible=is_visible,
|
||||
is_mandatory=item.id in mandatory_items,
|
||||
is_super_admin_only=item.is_super_admin_only,
|
||||
is_module_enabled=True, # Super admins see all modules
|
||||
module_code=item.module_code,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def update_menu_visibility(
|
||||
self,
|
||||
db: Session,
|
||||
@@ -692,54 +735,6 @@ class MenuService:
|
||||
f"Created {len(all_items) - len(mandatory_items)} hidden records for platform {platform_id}"
|
||||
)
|
||||
|
||||
def reset_user_menu_config(
|
||||
self,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""
|
||||
Reset menu configuration for a super admin user to defaults (all hidden except mandatory).
|
||||
|
||||
In opt-in model, reset means hide everything so user can opt-in to what they want.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Super admin user ID
|
||||
"""
|
||||
# Delete all existing records
|
||||
deleted = (
|
||||
db.query(AdminMenuConfig)
|
||||
.filter(
|
||||
AdminMenuConfig.frontend_type == FrontendType.ADMIN,
|
||||
AdminMenuConfig.user_id == user_id,
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
logger.info(f"Reset menu config for user {user_id}: deleted {deleted} rows")
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
configs = [
|
||||
AdminMenuConfig(
|
||||
frontend_type=FrontendType.ADMIN,
|
||||
platform_id=None,
|
||||
user_id=user_id,
|
||||
menu_item_id=item_id,
|
||||
is_visible=False,
|
||||
)
|
||||
for item_id in all_items
|
||||
if item_id not in mandatory_items
|
||||
]
|
||||
db.add_all(configs)
|
||||
|
||||
logger.info(
|
||||
f"Created {len(all_items) - len(mandatory_items)} hidden records for user {user_id}"
|
||||
)
|
||||
|
||||
def show_all_platform_menu_config(
|
||||
self,
|
||||
db: Session,
|
||||
@@ -789,52 +784,6 @@ class MenuService:
|
||||
f"Created {len(all_items) - len(mandatory_items)} visible records for platform {platform_id}"
|
||||
)
|
||||
|
||||
def show_all_user_menu_config(
|
||||
self,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""
|
||||
Show all menu items for a super admin user.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Super admin user ID
|
||||
"""
|
||||
# Delete all existing records
|
||||
deleted = (
|
||||
db.query(AdminMenuConfig)
|
||||
.filter(
|
||||
AdminMenuConfig.frontend_type == FrontendType.ADMIN,
|
||||
AdminMenuConfig.user_id == user_id,
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
logger.info(f"Show all menu config for user {user_id}: deleted {deleted} rows")
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
configs = [
|
||||
AdminMenuConfig(
|
||||
frontend_type=FrontendType.ADMIN,
|
||||
platform_id=None,
|
||||
user_id=user_id,
|
||||
menu_item_id=item_id,
|
||||
is_visible=True,
|
||||
)
|
||||
for item_id in all_items
|
||||
if item_id not in mandatory_items
|
||||
]
|
||||
db.add_all(configs)
|
||||
|
||||
logger.info(
|
||||
f"Created {len(all_items) - len(mandatory_items)} visible records for user {user_id}"
|
||||
)
|
||||
|
||||
def initialize_menu_config(
|
||||
self,
|
||||
db: Session,
|
||||
|
||||
@@ -39,11 +39,28 @@ class OnboardingAggregatorService:
|
||||
a unified interface for the dashboard onboarding banner.
|
||||
"""
|
||||
|
||||
def _get_store_platform_ids(self, db: Session, store_id: int) -> set[int]:
|
||||
"""Get platform IDs the store is actively subscribed to."""
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
|
||||
rows = (
|
||||
db.query(StorePlatform.platform_id)
|
||||
.filter(
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return {r.platform_id for r in rows}
|
||||
|
||||
def _get_enabled_providers(
|
||||
self, db: Session, platform_id: int
|
||||
self, db: Session, store_id: int, platform_id: int
|
||||
) -> list[tuple["ModuleDefinition", OnboardingProviderProtocol]]:
|
||||
"""
|
||||
Get onboarding providers from enabled modules.
|
||||
Get onboarding providers from modules enabled on the store's subscribed platforms.
|
||||
|
||||
Filters non-core modules to only those enabled on platforms the store
|
||||
is actively subscribed to, preventing cross-platform content leakage.
|
||||
|
||||
Returns:
|
||||
List of (module, provider) tuples for enabled modules with providers
|
||||
@@ -51,6 +68,11 @@ class OnboardingAggregatorService:
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.service import module_service
|
||||
|
||||
store_platform_ids = self._get_store_platform_ids(db, store_id)
|
||||
if not store_platform_ids:
|
||||
# Fallback to the passed platform_id if no subscriptions found
|
||||
store_platform_ids = {platform_id}
|
||||
|
||||
providers: list[tuple[ModuleDefinition, OnboardingProviderProtocol]] = []
|
||||
|
||||
for module in MODULES.values():
|
||||
@@ -59,13 +81,19 @@ class OnboardingAggregatorService:
|
||||
|
||||
# Core modules are always enabled, check others
|
||||
if not module.is_core:
|
||||
try:
|
||||
if not module_service.is_module_enabled(db, platform_id, module.code):
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to check if module {module.code} is enabled: {e}"
|
||||
)
|
||||
# Check if module is enabled on ANY of the store's subscribed platforms
|
||||
enabled_on_any = False
|
||||
for pid in store_platform_ids:
|
||||
try:
|
||||
if module_service.is_module_enabled(db, pid, module.code):
|
||||
enabled_on_any = True
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to check if module {module.code} is enabled "
|
||||
f"on platform {pid}: {e}"
|
||||
)
|
||||
if not enabled_on_any:
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -88,7 +116,7 @@ class OnboardingAggregatorService:
|
||||
Returns:
|
||||
Sorted list of OnboardingStepStatus objects
|
||||
"""
|
||||
providers = self._get_enabled_providers(db, platform_id)
|
||||
providers = self._get_enabled_providers(db, store_id, platform_id)
|
||||
steps: list[OnboardingStepStatus] = []
|
||||
|
||||
for module, provider in providers:
|
||||
|
||||
@@ -52,15 +52,33 @@ class StatsAggregatorService:
|
||||
when modules are disabled or providers fail.
|
||||
"""
|
||||
|
||||
def _get_store_platform_ids(self, db: Session, store_id: int) -> set[int]:
|
||||
"""Get platform IDs the store is actively subscribed to."""
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
|
||||
rows = (
|
||||
db.query(StorePlatform.platform_id)
|
||||
.filter(
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return {r.platform_id for r in rows}
|
||||
|
||||
def _get_enabled_providers(
|
||||
self, db: Session, platform_id: int
|
||||
self, db: Session, platform_id: int, store_id: int | None = None
|
||||
) -> list[tuple["ModuleDefinition", MetricsProviderProtocol]]:
|
||||
"""
|
||||
Get metrics providers from enabled modules.
|
||||
|
||||
When store_id is provided, filters to modules enabled on the store's
|
||||
subscribed platforms only (prevents cross-platform content leakage).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
platform_id: Platform ID to check module enablement
|
||||
store_id: Optional store ID for subscription-aware filtering
|
||||
|
||||
Returns:
|
||||
List of (module, provider) tuples for enabled modules with providers
|
||||
@@ -68,6 +86,14 @@ class StatsAggregatorService:
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.service import module_service
|
||||
|
||||
# Determine which platform IDs to check
|
||||
if store_id:
|
||||
check_platform_ids = self._get_store_platform_ids(db, store_id)
|
||||
if not check_platform_ids:
|
||||
check_platform_ids = {platform_id}
|
||||
else:
|
||||
check_platform_ids = {platform_id}
|
||||
|
||||
providers: list[tuple[ModuleDefinition, MetricsProviderProtocol]] = []
|
||||
|
||||
for module in MODULES.values():
|
||||
@@ -77,13 +103,18 @@ class StatsAggregatorService:
|
||||
|
||||
# Core modules are always enabled, check others
|
||||
if not module.is_core:
|
||||
try:
|
||||
if not module_service.is_module_enabled(db, platform_id, module.code):
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to check if module {module.code} is enabled: {e}"
|
||||
)
|
||||
enabled_on_any = False
|
||||
for pid in check_platform_ids:
|
||||
try:
|
||||
if module_service.is_module_enabled(db, pid, module.code):
|
||||
enabled_on_any = True
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to check if module {module.code} is enabled "
|
||||
f"on platform {pid}: {e}"
|
||||
)
|
||||
if not enabled_on_any:
|
||||
continue
|
||||
|
||||
# Get the provider instance
|
||||
@@ -119,7 +150,7 @@ class StatsAggregatorService:
|
||||
Returns:
|
||||
Dict mapping category name to list of MetricValue objects
|
||||
"""
|
||||
providers = self._get_enabled_providers(db, platform_id)
|
||||
providers = self._get_enabled_providers(db, platform_id, store_id=store_id)
|
||||
result: dict[str, list[MetricValue]] = {}
|
||||
|
||||
for module, provider in providers:
|
||||
|
||||
Reference in New Issue
Block a user