feat: multi-module improvements across merchant, store, i18n, and customer systems
All checks were successful
CI / ruff (push) Successful in 12s
CI / pytest (push) Successful in 50m57s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Successful in 40s
CI / deploy (push) Successful in 51s

- 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:
2026-03-08 23:48:25 +01:00
parent f141cc4e6a
commit a77a8a3a98
113 changed files with 3741 additions and 2923 deletions

View File

@@ -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,

View File

@@ -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:

View File

@@ -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: