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

@@ -66,7 +66,6 @@ core_module = ModuleDefinition(
"dashboard",
"settings",
"email-templates",
"my-menu",
],
FrontendType.STORE: [
"dashboard",
@@ -112,15 +111,6 @@ core_module = ModuleDefinition(
order=10,
is_mandatory=True,
),
MenuItemDefinition(
id="my-menu",
label_key="core.menu.my_menu",
icon="view-grid",
route="/admin/my-menu",
order=30,
is_mandatory=True,
is_super_admin_only=True,
),
],
),
],

View File

@@ -72,7 +72,6 @@
"dashboard": "Dashboard",
"platform_settings": "Plattform-Einstellungen",
"general": "Allgemein",
"my_menu": "Mein Menü",
"account_settings": "Kontoeinstellungen",
"profile": "Profil",
"settings": "Einstellungen"

View File

@@ -92,7 +92,6 @@
"dashboard": "Dashboard",
"platform_settings": "Platform Settings",
"general": "General",
"my_menu": "My Menu",
"account_settings": "Account Settings",
"profile": "Profile",
"settings": "Settings"

View File

@@ -72,7 +72,6 @@
"dashboard": "Tableau de bord",
"platform_settings": "Paramètres de la plateforme",
"general": "Général",
"my_menu": "Mon menu",
"account_settings": "Paramètres du compte",
"profile": "Profil",
"settings": "Paramètres"

View File

@@ -72,7 +72,6 @@
"dashboard": "Dashboard",
"platform_settings": "Plattform-Astellungen",
"general": "Allgemeng",
"my_menu": "Mäi Menü",
"account_settings": "Kont-Astellungen",
"profile": "Profil",
"settings": "Astellungen"

View File

@@ -100,12 +100,15 @@ class AdminMenuConfig(Base, TimestampMixin):
comment="Platform scope - applies to users/stores of this platform",
)
# DEPRECATED: user_id scoping is no longer used. Super admins now use platform
# selection instead of personal menu config. DB migration to drop this column
# is a separate task.
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
index=True,
comment="User scope - applies to this specific super admin (admin frontend only)",
comment="DEPRECATED - User scope no longer used. Kept for migration compatibility.",
)
# ========================================================================

View File

@@ -6,8 +6,6 @@ Provides menu visibility configuration for admin and store frontends:
- GET /menu-config/platforms/{platform_id} - Get menu config for a platform
- PUT /menu-config/platforms/{platform_id} - Update menu visibility for a platform
- POST /menu-config/platforms/{platform_id}/reset - Reset to defaults
- GET /menu-config/user - Get current user's menu config (super admins)
- PUT /menu-config/user - Update current user's menu config (super admins)
- GET /menu/admin - Get rendered admin menu for current user
- GET /menu/store - Get rendered store menu for current platform
@@ -316,108 +314,6 @@ async def reset_platform_menu_config(
return {"success": True, "message": "Menu configuration reset to defaults"}
# =============================================================================
# User Menu Configuration (Super Admin Only)
# =============================================================================
@router.get("/user", response_model=MenuConfigResponse)
async def get_user_menu_config(
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Get the current super admin's personal menu configuration.
Only super admins can configure their own admin menu.
"""
items = menu_service.get_user_menu_config(db, current_user.id)
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} fetched their personal menu config"
)
# Use user's preferred language, falling back to middleware-resolved language
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
)
@router.put("/user")
async def update_user_menu_visibility(
update_data: MenuVisibilityUpdateRequest,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Update visibility for a single menu item for the current super admin.
Super admin only. Cannot hide mandatory items.
"""
menu_service.update_menu_visibility(
db=db,
frontend_type=FrontendType.ADMIN,
menu_item_id=update_data.menu_item_id,
is_visible=update_data.is_visible,
user_id=current_user.id,
)
db.commit()
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} updated personal menu: "
f"{update_data.menu_item_id}={update_data.is_visible}"
)
return {"success": True, "message": "Menu visibility updated"}
@router.post("/user/reset", response_model=MenuActionResponse)
async def reset_user_menu_config(
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Reset the current super admin's menu configuration (hide all except mandatory).
Super admin only.
"""
menu_service.reset_user_menu_config(db, current_user.id)
db.commit()
logger.info(
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"
)
@router.post("/user/show-all", response_model=MenuActionResponse)
async def show_all_user_menu_config(
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Show all menu items for the current super admin.
Super admin only.
"""
menu_service.show_all_user_menu_config(db, current_user.id)
db.commit()
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} enabled all menu items"
)
return MenuActionResponse(success=True, message="All menu items are now visible")
@router.post("/platforms/{platform_id}/show-all")
async def show_all_platform_menu_config(
platform_id: int = Path(..., description="Platform ID"),
@@ -467,11 +363,12 @@ async def get_rendered_admin_menu(
Used by the frontend to render the sidebar.
"""
if current_user.is_super_admin:
# Super admin: use user-level config
# Super admin: use platform config if platform selected, else global (all modules)
platform_id = current_user.token_platform_id
menu = menu_service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.ADMIN,
user_id=current_user.id,
platform_id=platform_id,
is_super_admin=True,
)
else:

View File

@@ -5,9 +5,11 @@ Merchant menu rendering endpoint.
Provides the dynamic sidebar menu for the merchant portal:
- GET /menu/render/merchant - Get rendered merchant menu for current user
Menu sections are driven by module definitions (FrontendType.MERCHANT).
Only modules enabled on platforms the merchant is actively subscribed to
will appear in the sidebar.
Menu sections are grouped by platform:
- Core items (dashboard, billing, account) appear at root level
- Platform-specific items are grouped under their platform name
- No AdminMenuConfig visibility filtering — menu is purely driven by
module definitions + module enablement + subscription status
"""
import logging
@@ -38,6 +40,9 @@ class MenuSectionResponse(BaseModel):
id: str
label: str | None = None
icon: str | None = None
platform_code: str | None = None
is_collapsible: bool = False
items: list[dict[str, Any]]
@@ -83,10 +88,12 @@ async def get_rendered_merchant_menu(
"""
Get the rendered merchant menu for the current user.
Returns the filtered menu structure based on modules enabled
on platforms the merchant is subscribed to.
Returns a platform-grouped menu structure:
- Core sections (dashboard, billing, account) at root level
- Platform-specific sections grouped under platform name
Used by the merchant frontend to render the sidebar dynamically.
Menu visibility is driven by module definitions + module enablement
+ subscription status. No AdminMenuConfig filtering.
"""
# Resolve the merchant for this user (via service layer)
merchant = menu_service.get_merchant_for_menu(db, current_user.id)
@@ -98,32 +105,25 @@ async def get_rendered_merchant_menu(
sections=[],
)
# Get union of enabled module codes across all subscribed platforms
enabled_codes = menu_service.get_merchant_enabled_module_codes(db, merchant.id)
# Resolve primary platform for AdminMenuConfig visibility lookup
primary_platform_id = menu_service.get_merchant_primary_platform_id(
# Get platform-grouped menu
core_sections, platform_sections = menu_service.get_merchant_menu_by_platform(
db, merchant.id
)
# Get filtered menu using enabled_module_codes override + platform visibility
menu = menu_service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
platform_id=primary_platform_id,
enabled_module_codes=enabled_codes,
)
# Resolve language
language = current_user.preferred_language or getattr(
request.state, "language", "en"
)
# Translate section and item labels
sections = []
for section in menu:
# Build response sections
all_sections = []
# Core sections first (translated labels)
for section in core_sections:
translated_items = []
for item in section.items:
if not item.is_module_enabled:
continue
translated_items.append(
{
"id": item.id,
@@ -133,15 +133,59 @@ async def get_rendered_merchant_menu(
}
)
sections.append(
MenuSectionResponse(
id=section.id,
label=_translate_label(section.label_key, language),
items=translated_items,
if translated_items:
all_sections.append(
MenuSectionResponse(
id=section.id,
label=_translate_label(section.label_key, language),
icon=section.icon,
is_collapsible=section.is_collapsible,
items=translated_items,
)
)
)
# Platform sections (platform name as label, collapsible)
for section in platform_sections:
translated_items = []
for item in section.items:
if not item.is_module_enabled:
continue
translated_items.append(
{
"id": item.id,
"label": _translate_label(item.label_key, language),
"icon": item.icon,
"url": item.route,
}
)
if translated_items:
# Extract platform_code from section id (format: "platform-{code}")
platform_code = section.id.removeprefix("platform-")
all_sections.append(
MenuSectionResponse(
id=section.id,
label=section.label_key, # Already platform name, not a translation key
icon=section.icon,
platform_code=platform_code,
is_collapsible=True,
items=translated_items,
)
)
# Sort order: 1) Dashboard (main), 2) Platform sections, 3) Billing, 4) Account
# Core sections use their definition order; platform sections slot in between
# main=0, platform=25, billing=50, account=900
def _section_sort_key(s):
if s.platform_code:
return 25, s.id
# Map known core section IDs to their display order
core_order = {"main": 0, "billing": 50, "account": 900}
return core_order.get(s.id, 500), s.id
all_sections.sort(key=_section_sort_key)
return RenderedMenuResponse(
frontend_type=FrontendType.MERCHANT.value,
sections=sections,
sections=all_sections,
)

View File

@@ -224,14 +224,6 @@ def get_store_settings(
"is_verified": domain.is_verified,
})
# Get Stripe info from subscription (read-only, masked)
stripe_info = None
if store.subscription and store.subscription.stripe_customer_id:
stripe_info = {
"has_stripe_customer": True,
"customer_id_masked": f"cus_***{store.subscription.stripe_customer_id[-4:]}",
}
return {
# General info
"store_code": store.store_code,
@@ -297,9 +289,6 @@ def get_store_settings(
"domains": domains,
"default_subdomain": f"{store.subdomain}.letzshop.lu",
# Stripe info (read-only)
"stripe_info": stripe_info,
# Options for dropdowns
"options": {
"supported_languages": SUPPORTED_LANGUAGES,

View File

@@ -18,6 +18,7 @@ from app.modules.core.utils.page_context import get_admin_context
from app.modules.enums import FrontendType
from app.modules.tenancy.models import User
from app.templates_config import templates
from app.utils.i18n import get_jinja2_globals
router = APIRouter()
@@ -57,10 +58,13 @@ async def admin_login_page(
if current_user:
return RedirectResponse(url="/admin/dashboard", status_code=302)
return templates.TemplateResponse("tenancy/admin/login.html", {
language = getattr(request.state, "language", "en")
context = {
"request": request,
"current_language": getattr(request.state, "language", "en"),
})
"current_language": language,
**get_jinja2_globals(language),
}
return templates.TemplateResponse("tenancy/admin/login.html", context)
@router.get("/select-platform", response_class=HTMLResponse, include_in_schema=False)
@@ -69,18 +73,15 @@ async def admin_select_platform_page(
current_user: User | None = Depends(get_current_admin_optional),
):
"""
Render platform selection page for platform admins.
Render platform selection page for admins.
Platform admins with access to multiple platforms must select
which platform they want to manage before accessing the dashboard.
Super admins are redirected to dashboard (they have global access).
Super admins can optionally select a platform to scope their view.
"""
if not current_user:
return RedirectResponse(url="/admin/login", status_code=302)
if current_user.is_super_admin:
return RedirectResponse(url="/admin/dashboard", status_code=302)
return templates.TemplateResponse(
"tenancy/admin/select-platform.html",
{"request": request, "user": current_user},
@@ -124,26 +125,6 @@ async def admin_settings_page(
)
@router.get("/my-menu", response_class=HTMLResponse, include_in_schema=False)
async def admin_my_menu_config(
request: Request,
current_user: User = Depends(require_menu_access("my-menu", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render personal menu configuration page for super admins.
Allows super admins to customize their own sidebar menu.
"""
# Only super admins can configure their own menu
if not current_user.is_super_admin:
return RedirectResponse(url="/admin/settings", status_code=302)
return templates.TemplateResponse(
"core/admin/my-menu-config.html",
get_admin_context(request, db, current_user),
)
@router.get("/features", response_class=HTMLResponse, include_in_schema=False)
async def admin_features_page(
request: Request,

View File

@@ -24,6 +24,7 @@ from app.modules.core.utils.page_context import get_context_for_frontend
from app.modules.enums import FrontendType
from app.modules.tenancy.schemas.auth import UserContext
from app.templates_config import templates
from app.utils.i18n import get_jinja2_globals
ROUTE_CONFIG = {
"prefix": "",
@@ -67,10 +68,13 @@ async def merchant_login_page(
if current_user:
return RedirectResponse(url="/merchants/dashboard", status_code=302)
return templates.TemplateResponse("tenancy/merchant/login.html", {
language = getattr(request.state, "language", "fr")
context = {
"request": request,
"current_language": getattr(request.state, "language", "fr"),
})
"current_language": language,
**get_jinja2_globals(language),
}
return templates.TemplateResponse("tenancy/merchant/login.html", context)
# ============================================================================

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:

View File

@@ -264,7 +264,7 @@ function languageSelector(currentLang, enabledLanguages) {
return {
isLangOpen: false,
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['fr', 'de', 'en'],
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
languageNames: {
'en': 'English',
'fr': 'Français',

View File

@@ -6,6 +6,27 @@
// Create custom logger for login page
const loginLog = window.LogConfig.createLogger('LOGIN');
function languageSelector(currentLang, enabledLanguages) {
return {
currentLang: currentLang || 'en',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
async setLanguage(lang) {
if (lang === this.currentLang) return;
try {
// noqa: JS-008 - Login page has no apiClient; raw fetch is intentional
await fetch('/api/v1/platform/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang }),
});
window.location.reload();
} catch (error) {
loginLog.error('Failed to set language:', error);
}
},
};
}
function adminLogin() {
return {
dark: false,
@@ -17,6 +38,10 @@ function adminLogin() {
error: null,
success: null,
errors: {},
rememberMe: false,
showForgotPassword: false,
forgotPasswordEmail: '',
forgotPasswordLoading: false,
init() {
// Guard against multiple initialization
@@ -196,6 +221,14 @@ function adminLogin() {
window.location.href = '/admin/select-platform';
return;
}
if (platformsResponse.is_super_admin && !platformsResponse.current_platform_id) {
// Super admin with no platform selected - offer platform selection
loginLog.info('Super admin without platform, redirecting to platform selector...');
this.success = 'Login successful! Select a platform or stay in global mode...';
window.location.href = '/admin/select-platform';
return;
}
} catch (platformError) {
loginLog.warn('Could not check platforms, proceeding to dashboard:', platformError);
}
@@ -233,6 +266,28 @@ function adminLogin() {
}
},
async handleForgotPassword() {
if (!this.forgotPasswordEmail) {
this.error = 'Please enter your email address';
return;
}
this.forgotPasswordLoading = true;
this.clearErrors();
try {
const response = await apiClient.post('/admin/auth/forgot-password', {
email: this.forgotPasswordEmail
});
this.success = response.message || 'If an account exists with that email, a reset link has been sent.';
this.forgotPasswordEmail = '';
} catch (error) {
// Show generic message to prevent email enumeration
this.success = 'If an account exists with that email, a reset link has been sent.';
} finally {
this.forgotPasswordLoading = false;
}
},
toggleDarkMode() {
loginLog.debug('Toggling dark mode...');
this.dark = !this.dark;

View File

@@ -1,177 +0,0 @@
// static/admin/js/my-menu-config.js
// Personal menu configuration for super admins
//
// NOTE: The page method for loading user menu config is named loadUserMenuConfig()
// (not loadMenuConfig()) to avoid shadowing the sidebar's loadMenuConfig() inherited
// from data() via the spread operator. Shadowing caused the sidebar to never populate
// its menuData, resulting in a blank sidebar on this page.
const myMenuConfigLog = window.LogConfig?.loggers?.myMenuConfig || window.LogConfig?.createLogger?.('myMenuConfig') || console;
function adminMyMenuConfig() {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Page-specific state
currentPage: 'my-menu',
loading: true,
error: null,
successMessage: null,
saving: false,
// Data
menuConfig: null,
showShowAllModal: false,
showHideAllModal: false,
// Computed grouped items
get groupedItems() {
if (!this.menuConfig?.items) return [];
// Group items by section
const sections = {};
for (const item of this.menuConfig.items) {
const sectionId = item.section_id;
if (!sections[sectionId]) {
sections[sectionId] = {
id: sectionId,
label: item.section_label,
isSuperAdminOnly: item.is_super_admin_only,
items: [],
visibleCount: 0
};
}
sections[sectionId].items.push(item);
if (item.is_visible) {
sections[sectionId].visibleCount++;
}
}
// Convert to array and maintain order
return Object.values(sections);
},
async init() {
// Guard against multiple initialization
if (window._adminMyMenuConfigInitialized) {
myMenuConfigLog.warn('Already initialized, skipping');
return;
}
window._adminMyMenuConfigInitialized = true;
myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZING ===');
try {
// Load core translations for confirmations
await I18n.loadModule('core');
await this.loadUserMenuConfig();
myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZED ===');
} catch (error) {
myMenuConfigLog.error('Failed to initialize my menu config page:', error);
this.error = 'Failed to load page data. Please refresh.';
}
},
async refresh() {
this.error = null;
this.successMessage = null;
await this.loadUserMenuConfig();
},
async loadUserMenuConfig() {
this.loading = true;
this.error = null;
try {
this.menuConfig = await apiClient.get('/admin/menu-config/user');
myMenuConfigLog.info('Loaded menu config:', {
totalItems: this.menuConfig?.total_items,
visibleItems: this.menuConfig?.visible_items
});
} catch (error) {
myMenuConfigLog.error('Failed to load menu config:', error);
this.error = error.message || 'Failed to load menu configuration';
} finally {
this.loading = false;
}
},
async toggleVisibility(item) {
if (item.is_mandatory) {
myMenuConfigLog.warn('Cannot toggle mandatory item:', item.id);
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
const newVisibility = !item.is_visible;
try {
await apiClient.put('/admin/menu-config/user', {
menu_item_id: item.id,
is_visible: newVisibility
});
// Update local state
item.is_visible = newVisibility;
// Update counts
if (newVisibility) {
this.menuConfig.visible_items++;
this.menuConfig.hidden_items--;
} else {
this.menuConfig.visible_items--;
this.menuConfig.hidden_items++;
}
myMenuConfigLog.info('Toggled visibility:', item.id, newVisibility);
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to toggle visibility:', error);
this.error = error.message || 'Failed to update menu visibility';
this.saving = false;
}
},
async showAll() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
await apiClient.post('/admin/menu-config/user/show-all');
myMenuConfigLog.info('Showed all menu items');
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to show all menu items:', error);
this.error = error.message || 'Failed to show all menu items';
this.saving = false;
}
},
async resetToDefaults() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
await apiClient.post('/admin/menu-config/user/reset');
myMenuConfigLog.info('Reset menu config to defaults');
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to reset menu config:', error);
this.error = error.message || 'Failed to reset menu configuration';
this.saving = false;
}
}
};
}

View File

@@ -5,6 +5,27 @@
// Use centralized logger
const loginLog = window.LogConfig.createLogger('MERCHANT-LOGIN');
function languageSelector(currentLang, enabledLanguages) {
return {
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
async setLanguage(lang) {
if (lang === this.currentLang) return;
try {
// noqa: JS-008 - Login page has no apiClient; raw fetch is intentional
await fetch('/api/v1/platform/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang }),
});
window.location.reload();
} catch (error) {
loginLog.error('Failed to set language:', error);
}
},
};
}
function merchantLogin() {
return {
dark: false,
@@ -134,6 +155,7 @@ function merchantLogin() {
},
// Forgot password state
rememberMe: false,
showForgotPassword: false,
forgotPasswordEmail: '',
forgotPasswordLoading: false,

View File

@@ -15,7 +15,9 @@ storeDashLog.info('[STORE DASHBOARD] data function exists?', typeof data);
* Fetches onboarding steps from API, supports session-scoped dismiss.
*/
function onboardingBanner() {
const t = (key, vars) => I18n.t(key, vars);
return {
t,
visible: false,
steps: [],
totalSteps: 0,
@@ -30,7 +32,19 @@ function onboardingBanner() {
try {
const response = await apiClient.get('/store/dashboard/onboarding');
this.steps = response.steps || [];
const steps = response.steps || [];
// Load module translations BEFORE setting reactive data
// Keys are like "tenancy.onboarding...." — first segment is the module
const modules = new Set();
for (const step of steps) {
const mod = step.title_key?.split('.')[0];
if (mod) modules.add(mod);
}
await Promise.all([...modules].map(m => I18n.loadModule(m)));
// Now set reactive data — Alpine re-renders with translations ready
this.steps = steps;
this.totalSteps = response.total_steps || 0;
this.completedSteps = response.completed_steps || 0;
this.progressPercentage = response.progress_percentage || 0;

View File

@@ -253,7 +253,7 @@ function languageSelector(currentLang, enabledLanguages) {
return {
isLangOpen: false,
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['fr', 'de', 'en'],
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
languageNames: {
'en': 'English',
'fr': 'Français',

View File

@@ -1,200 +0,0 @@
{# app/templates/admin/my-menu-config.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/modals.html' import confirm_modal %}
{% block title %}My Menu{% endblock %}
{% block alpine_data %}adminMyMenuConfig(){% endblock %}
{% block content %}
{{ page_header('My Menu Configuration', subtitle='Customize your personal admin sidebar', back_url='/admin/settings') }}
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
{{ error_state('Error', show_condition='error') }}
<!-- Info Box -->
<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-start">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3')"></span>
<div>
<p class="text-sm text-blue-800 dark:text-blue-200">
This configures <strong>your personal</strong> admin sidebar menu. These settings only affect your view.
</p>
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
To configure menus for platform admins or stores, go to <a href="/admin/platforms" class="underline hover:no-underline">Platforms</a> and select a platform's Menu Configuration.
</p>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="grid gap-4 mb-6 md:grid-cols-3">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('view-grid', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Total Items</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.total_items || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Visible</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.visible_items || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-gray-500 bg-gray-100 rounded-full dark:text-gray-100 dark:bg-gray-600">
<span x-html="$icon('eye-off', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Hidden</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.hidden_items || 0"></p>
</div>
</div>
</div>
<!-- Actions -->
<div class="mb-4 flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
Toggle visibility for menu items. Mandatory items cannot be hidden.
</p>
<div class="flex gap-2">
<button
@click="showShowAllModal = true"
:disabled="saving"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
>
<span x-html="$icon('eye', 'w-4 h-4 mr-2')"></span>
Show All
</button>
<button
@click="showHideAllModal = true"
:disabled="saving"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
>
<span x-html="$icon('eye-off', 'w-4 h-4 mr-2')"></span>
Hide All
</button>
</div>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex items-center justify-center py-12 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<span x-html="$icon('refresh', 'w-8 h-8 animate-spin text-purple-600')"></span>
<span class="ml-3 text-gray-500 dark:text-gray-400">Loading menu configuration...</span>
</div>
<!-- Menu Items by Section -->
<div x-show="!loading" class="space-y-6">
<template x-for="section in groupedItems" :key="section.id">
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<!-- Section Header -->
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="section.label || 'Main'"></h3>
<span
x-show="section.isSuperAdminOnly"
class="ml-2 px-2 py-0.5 text-xs font-medium rounded bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
>
Super Admin Only
</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="`${section.visibleCount}/${section.items.length} visible`"></span>
</div>
</div>
<!-- Section Items -->
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="item in section.items" :key="item.id">
<div class="flex items-center justify-between px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30">
<div class="flex items-center">
<span x-html="$icon(item.icon, 'w-5 h-5 text-gray-400 mr-3')"></span>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="item.label"></p>
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="item.url"></p>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Mandatory Badge -->
<span
x-show="item.is_mandatory"
class="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
Mandatory
</span>
<!-- Toggle Switch -->
<button
@click="toggleVisibility(item)"
:disabled="item.is_mandatory || saving"
:class="{
'bg-purple-600': item.is_visible,
'bg-gray-200 dark:bg-gray-600': !item.is_visible,
'opacity-50 cursor-not-allowed': item.is_mandatory
}"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
role="switch"
:aria-checked="item.is_visible"
>
<span
:class="{
'translate-x-5': item.is_visible,
'translate-x-0': !item.is_visible
}"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
></span>
</button>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- Empty State -->
<div x-show="groupedItems.length === 0" class="bg-white rounded-lg shadow-xs dark:bg-gray-800 p-8 text-center">
<span x-html="$icon('view-grid', 'w-12 h-12 mx-auto text-gray-400')"></span>
<p class="mt-4 text-gray-500 dark:text-gray-400">No menu items available.</p>
</div>
</div>
<!-- Confirmation Modals -->
{{ confirm_modal(
'showAllModal',
'Show All Menu Items',
'This will make all menu items visible in your sidebar. Are you sure?',
'showAll()',
'showShowAllModal',
'Show All',
'Cancel',
'info',
'eye'
) }}
{{ confirm_modal(
'hideAllModal',
'Hide All Menu Items',
'This will hide all non-mandatory menu items from your sidebar. Are you sure?',
'resetToDefaults()',
'showHideAllModal',
'Hide All',
'Cancel',
'warning',
'eye-off'
) }}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('core_static', path='admin/js/my-menu-config.js') }}"></script>
{% endblock %}

View File

@@ -8,7 +8,7 @@
{% block alpine_data %}storeDashboard(){% endblock %}
{% from "shared/macros/feature_gate.html" import email_settings_warning, onboarding_banner %}
{% from "shared/macros/feature_gate.html" import email_settings_warning, onboarding_banner with context %}
{% block content %}
<!-- Email Settings Warning -->

File diff suppressed because it is too large Load Diff

View File

@@ -263,34 +263,49 @@ class TestMerchantMenuModuleGating:
def test_loyalty_appears_when_module_enabled(
self, client, db, menu_auth, menu_merchant, menu_subscription,
menu_loyalty_module,
menu_loyalty_module, menu_platform,
):
"""Loyalty section appears when loyalty module is enabled on subscribed platform."""
"""Loyalty items appear under platform section when module is enabled."""
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
# Loyalty now appears under a platform-{code} section
platform_section_id = f"platform-{menu_platform.code}"
section_ids = {s["id"] for s in data["sections"]}
assert "loyalty" in section_ids
assert platform_section_id in section_ids
# Check loyalty item exists in that platform section
platform_section = next(
s for s in data["sections"] if s["id"] == platform_section_id
)
item_ids = {i["id"] for i in platform_section["items"]}
assert "loyalty-overview" in item_ids
def test_loyalty_hidden_when_module_not_enabled(
self, client, db, menu_auth, menu_merchant, menu_subscription,
menu_platform,
):
"""Loyalty section is hidden when loyalty module is NOT enabled."""
"""No platform section when no non-core modules are enabled."""
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
platform_section_id = f"platform-{menu_platform.code}"
section_ids = {s["id"] for s in data["sections"]}
assert "loyalty" not in section_ids
assert platform_section_id not in section_ids
def test_loyalty_item_has_correct_route(
self, client, db, menu_auth, menu_merchant, menu_subscription,
menu_loyalty_module,
menu_loyalty_module, menu_platform,
):
"""Loyalty overview item has the correct URL."""
response = client.get(BASE, headers=menu_auth)
data = response.json()
loyalty = next(s for s in data["sections"] if s["id"] == "loyalty")
overview = next(i for i in loyalty["items"] if i["id"] == "loyalty-overview")
platform_section_id = f"platform-{menu_platform.code}"
platform_section = next(
s for s in data["sections"] if s["id"] == platform_section_id
)
overview = next(
i for i in platform_section["items"] if i["id"] == "loyalty-overview"
)
assert overview["url"] == "/merchants/loyalty/overview"
@@ -352,7 +367,8 @@ class TestMerchantMenuSubscriptionStatus:
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
section_ids = {s["id"] for s in response.json()["sections"]}
assert "loyalty" in section_ids
platform_section_id = f"platform-{menu_platform.code}"
assert platform_section_id in section_ids
def test_expired_subscription_hides_non_core_modules(
self, client, db, menu_auth, menu_merchant, menu_platform, menu_tier,
@@ -374,8 +390,9 @@ class TestMerchantMenuSubscriptionStatus:
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
section_ids = {s["id"] for s in response.json()["sections"]}
# Loyalty should NOT appear because subscription is expired
assert "loyalty" not in section_ids
platform_section_id = f"platform-{menu_platform.code}"
# Platform section should NOT appear because subscription is expired
assert platform_section_id not in section_ids
# Core sections always appear
assert "main" in section_ids
assert "billing" in section_ids
@@ -468,9 +485,20 @@ class TestMerchantMenuMultiPlatform:
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
section_ids = {s["id"] for s in response.json()["sections"]}
# Loyalty enabled on Platform A should appear in the union
assert "loyalty" in section_ids
data = response.json()
section_ids = {s["id"] for s in data["sections"]}
# Loyalty enabled on Platform A appears under platform-a's section
platform_a_section_id = f"platform-{platform_a.code}"
assert platform_a_section_id in section_ids
# Platform B has no non-core modules, so no section
platform_b_section_id = f"platform-{platform_b.code}"
assert platform_b_section_id not in section_ids
# Check loyalty item exists in Platform A section
pa_section = next(
s for s in data["sections"] if s["id"] == platform_a_section_id
)
item_ids = {i["id"] for i in pa_section["items"]}
assert "loyalty-overview" in item_ids
# Core sections always present
assert "main" in section_ids
assert "billing" in section_ids

View File

@@ -110,3 +110,62 @@ class TestMenuServiceMerchantRendering:
without_ids = {s.id for s in without_loyalty}
assert "loyalty" in with_ids
assert "loyalty" not in without_ids
@pytest.mark.unit
@pytest.mark.core
class TestMenuServiceMerchantByPlatform:
"""Test get_merchant_menu_by_platform grouping logic."""
def setup_method(self):
self.service = MenuService()
def test_core_sections_contain_only_core_items(self, db):
"""Core sections should only have items from core modules."""
from app.modules.core.services.menu_discovery_service import (
menu_discovery_service,
)
from app.modules.registry import MODULES
core_codes = {code for code, mod in MODULES.items() if mod.is_core}
core_sections = menu_discovery_service.get_menu_sections_for_frontend(
db,
FrontendType.MERCHANT,
enabled_module_codes=core_codes,
)
for section in core_sections:
section.items = [i for i in section.items if i.module_code in core_codes]
for section in core_sections:
for item in section.items:
assert item.module_code in core_codes, (
f"Non-core item '{item.id}' (module={item.module_code}) "
f"found in core section '{section.id}'"
)
def test_non_core_sections_for_loyalty(self, db):
"""Non-core modules like loyalty produce merchant menu sections."""
from app.modules.core.services.menu_discovery_service import (
menu_discovery_service,
)
from app.modules.registry import MODULES
core_codes = {code for code, mod in MODULES.items() if mod.is_core}
non_core = {"loyalty"}
sections = menu_discovery_service.get_menu_sections_for_frontend(
db,
FrontendType.MERCHANT,
enabled_module_codes=non_core,
)
sections = [
s
for s in sections
if any(i.module_code in non_core for i in s.items)
]
assert len(sections) > 0, "Loyalty module should produce merchant menu sections"
for section in sections:
for item in section.items:
assert item.module_code not in core_codes