feat: complete dynamic menu system across all frontends
All checks were successful
CI / ruff (push) Successful in 11s
CI / pytest (push) Successful in 44m40s
CI / validate (push) Successful in 22s
CI / dependency-scanning (push) Successful in 28s
CI / docs (push) Successful in 39s
CI / deploy (push) Successful in 49s

- Add "Merchant Frontend" tab to admin menu-config page
- Merchant render endpoint now respects AdminMenuConfig visibility
  via get_merchant_primary_platform_id() platform resolution
- New store menu render endpoint (GET /store/core/menu/render/store)
  with platform-scoped visibility and store_code interpolation
- Store sidebar migrated from hardcoded Jinja2 macros to dynamic
  Alpine.js x-for rendering with loading skeleton and fallback
- Store init-alpine.js: add loadMenuConfig(), expandSectionForCurrentPage()
- Include store page route fixes, login template updates, and tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 02:14:42 +01:00
parent be248222bc
commit 506171503d
14 changed files with 1364 additions and 158 deletions

View File

@@ -101,10 +101,16 @@ async def get_rendered_merchant_menu(
# Get union of enabled module codes across all subscribed platforms
enabled_codes = menu_service.get_merchant_enabled_module_codes(db, merchant.id)
# Get filtered menu using enabled_module_codes override
# Resolve primary platform for AdminMenuConfig visibility lookup
primary_platform_id = menu_service.get_merchant_primary_platform_id(
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,
)

View File

@@ -5,15 +5,18 @@ Core module store API routes.
Aggregates:
- /dashboard/* - Dashboard statistics
- /settings/* - Store settings management
- /menu/* - Store menu rendering
"""
from fastapi import APIRouter
from .store_dashboard import store_dashboard_router
from .store_menu import store_menu_router
from .store_settings import store_settings_router
store_router = APIRouter()
# Aggregate sub-routers
store_router.include_router(store_dashboard_router, tags=["store-dashboard"])
store_router.include_router(store_menu_router, tags=["store-menu"])
store_router.include_router(store_settings_router, tags=["store-settings"])

View File

@@ -0,0 +1,139 @@
# app/modules/core/routes/api/store_menu.py
"""
Store menu rendering endpoint.
Provides the dynamic sidebar menu for the store portal:
- GET /menu/render/store - Get rendered store menu for current user
Menu sections are driven by module definitions (FrontendType.STORE).
Only modules enabled on the store's platform will appear in the sidebar.
Visibility is controlled by AdminMenuConfig records for the platform.
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_api
from app.core.database import get_db
from app.modules.core.services.menu_service import menu_service
from app.modules.enums import FrontendType
from app.modules.tenancy.services.store_service import store_service
from app.utils.i18n import translate
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
store_menu_router = APIRouter(prefix="/menu")
# =============================================================================
# Schemas
# =============================================================================
class MenuSectionResponse(BaseModel):
"""Menu section for rendering."""
id: str
label: str | None = None
items: list[dict[str, Any]]
class RenderedMenuResponse(BaseModel):
"""Rendered menu for frontend."""
frontend_type: str
sections: list[MenuSectionResponse]
# =============================================================================
# Helpers
# =============================================================================
def _translate_label(label_key: str | None, language: str) -> str | None:
"""Translate a label key, falling back to a readable version of the key."""
if not label_key:
return None
translated = translate(label_key, language=language)
# If translation returned the key itself, create a readable fallback
if translated == label_key:
parts = label_key.split(".")
last_part = parts[-1]
return last_part.replace("_", " ").title()
return translated
# =============================================================================
# Endpoint
# =============================================================================
@store_menu_router.get("/render/store", response_model=RenderedMenuResponse)
async def get_rendered_store_menu(
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_store_api),
):
"""
Get the rendered store menu for the current user.
Returns the filtered menu structure based on:
- Modules enabled on the store's platform
- AdminMenuConfig visibility for the platform
- Store code for URL placeholder replacement
Used by the store frontend to render the sidebar dynamically.
"""
# Get the store from the JWT token's store context
store = store_service.get_store_by_id(db, current_user.token_store_id)
# Resolve the store's platform via service layer
platform_id = menu_service.get_store_primary_platform_id(db, store.id)
# Get filtered menu with platform visibility and store_code interpolation
menu = menu_service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.STORE,
platform_id=platform_id,
store_code=store.subdomain,
)
# Resolve language
language = current_user.preferred_language or getattr(
request.state, "language", "en"
)
# Translate section and item labels
sections = []
for section in menu:
translated_items = []
for item in section.items:
translated_items.append(
{
"id": item.id,
"label": _translate_label(item.label_key, language),
"icon": item.icon,
"url": item.route,
}
)
sections.append(
MenuSectionResponse(
id=section.id,
label=_translate_label(section.label_key, language),
items=translated_items,
)
)
return RenderedMenuResponse(
frontend_type=FrontendType.STORE.value,
sections=sections,
)

View File

@@ -3,6 +3,7 @@
Core Store Page Routes (HTML rendering).
Store pages for core functionality:
- Dashboard
- Media library
- Notifications
"""
@@ -19,6 +20,35 @@ from app.templates_config import templates
router = APIRouter()
# ============================================================================
# STORE DASHBOARD
# ============================================================================
@router.get(
"/{store_code}/dashboard", response_class=HTMLResponse, include_in_schema=False
)
async def store_dashboard_page(
request: Request,
store_code: str = Path(..., description="Store code"),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render store dashboard.
JavaScript will:
- Load store info via API
- Load dashboard stats via API
- Load recent orders via API
- Handle all interactivity
"""
return templates.TemplateResponse(
"core/store/dashboard.html",
get_store_context(request, db, current_user, store_code),
)
# ============================================================================
# MEDIA LIBRARY
# ============================================================================

View File

@@ -325,6 +325,106 @@ class MenuService:
return all_enabled
def get_merchant_primary_platform_id(
self,
db: Session,
merchant_id: int,
) -> int | None:
"""
Get the primary platform ID for a merchant's visibility config.
Resolution order:
1. Platform from the store marked is_primary in StorePlatform
2. First active subscription's platform (fallback)
Args:
db: Database session
merchant_id: Merchant ID
Returns:
Platform ID or None if no active subscriptions
"""
from app.modules.billing.models.merchant_subscription import (
MerchantSubscription,
)
from app.modules.billing.models.subscription import SubscriptionStatus
from app.modules.tenancy.models import Store
from app.modules.tenancy.models.store_platform import StorePlatform
active_statuses = [
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.PAST_DUE.value,
SubscriptionStatus.CANCELLED.value,
]
# Try to find the primary store's platform
primary_platform_id = (
db.query(StorePlatform.platform_id)
.join(Store, Store.id == StorePlatform.store_id)
.join(
MerchantSubscription,
(MerchantSubscription.platform_id == StorePlatform.platform_id)
& (MerchantSubscription.merchant_id == merchant_id),
)
.filter(
Store.merchant_id == merchant_id,
Store.is_active == True, # noqa: E712
StorePlatform.is_primary == True, # noqa: E712
StorePlatform.is_active == True, # noqa: E712
MerchantSubscription.status.in_(active_statuses),
)
.first()
)
if primary_platform_id:
return primary_platform_id[0]
# Fallback: first active subscription's platform
first_sub = (
db.query(MerchantSubscription.platform_id)
.filter(
MerchantSubscription.merchant_id == merchant_id,
MerchantSubscription.status.in_(active_statuses),
)
.order_by(MerchantSubscription.id)
.first()
)
return first_sub[0] if first_sub else None
def get_store_primary_platform_id(
self,
db: Session,
store_id: int,
) -> int | None:
"""
Get the primary platform ID for a store's menu visibility config.
Prefers the active StorePlatform marked is_primary, falls back to
the first active StorePlatform by ID.
Args:
db: Database session
store_id: Store ID
Returns:
Platform ID or None if no active store-platform link
"""
from app.modules.tenancy.models.store_platform import StorePlatform
sp = (
db.query(StorePlatform.platform_id)
.filter(
StorePlatform.store_id == store_id,
StorePlatform.is_active == True, # noqa: E712
)
.order_by(StorePlatform.is_primary.desc(), StorePlatform.id)
.first()
)
return sp[0] if sp else None
def get_merchant_for_menu(
self,
db: Session,

View File

@@ -21,14 +21,20 @@ function getStoreSidebarSectionsFromStorage() {
} catch (e) {
console.warn('[STORE INIT-ALPINE] Failed to load sidebar state from localStorage:', e);
}
// Default: all sections open
return {
products: true,
sales: true,
customers: true,
shop: true,
account: true
};
// Default: empty (populated dynamically from API response)
return {};
}
function findSectionForPage(menuData, pageId) {
if (!menuData?.sections) return null;
for (const section of menuData.sections) {
for (const item of (section.items || [])) {
if (item.id === pageId) {
return section.id;
}
}
}
return null;
}
function saveStoreSidebarSectionsToStorage(sections) {
@@ -51,6 +57,10 @@ function data() {
store: null,
storeCode: null,
// Dynamic menu state
menuData: null,
menuLoading: false,
// Sidebar collapsible sections state
openSections: getStoreSidebarSectionsFromStorage(),
@@ -81,8 +91,9 @@ function data() {
this.dark = true;
}
// Load store info
// Load store info and dynamic menu
this.loadStoreInfo();
this.loadMenuConfig();
// Save last visited page (for redirect after login)
// Exclude login, logout, onboarding, error pages
@@ -111,6 +122,38 @@ function data() {
}
},
async loadMenuConfig() {
if (this.menuData || this.menuLoading) return;
if (typeof apiClient === 'undefined') return;
if (!localStorage.getItem('store_token')) return;
this.menuLoading = true;
try {
this.menuData = await apiClient.get('/store/core/menu/render/store');
// Initialize section open state from response
for (const section of (this.menuData?.sections || [])) {
if (this.openSections[section.id] === undefined) {
this.openSections[section.id] = true;
}
}
saveStoreSidebarSectionsToStorage(this.openSections);
this.expandSectionForCurrentPage();
} catch (e) {
console.debug('Menu config not loaded, using fallback:', e?.message || e);
} finally {
this.menuLoading = false;
}
},
expandSectionForCurrentPage() {
if (!this.menuData) return;
const sectionId = findSectionForPage(this.menuData, this.currentPage);
if (sectionId && !this.openSections[sectionId]) {
this.openSections[sectionId] = true;
saveStoreSidebarSectionsToStorage(this.openSections);
}
},
toggleSideMenu() {
this.isSideMenuOpen = !this.isSideMenuOpen;
},

View File

@@ -0,0 +1,200 @@
# app/modules/core/tests/integration/test_store_dashboard_routes.py
"""
Integration tests for store dashboard page route.
Tests the store dashboard page at:
GET /store/{store_code}/dashboard
Verifies:
- Dashboard renders without any onboarding check
- Dashboard requires authentication
- Dashboard is served from the core module (no marketplace dependency)
"""
import uuid
from datetime import UTC, datetime
import pytest
from app.api.deps import get_current_store_from_cookie_or_header
from app.modules.tenancy.models import Merchant, Platform, Store, User
from app.modules.tenancy.models.store_platform import StorePlatform
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def sd_owner(db):
"""Create a store owner user."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"sdowner_{uuid.uuid4().hex[:8]}@test.com",
username=f"sdowner_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def sd_platform(db):
"""Create a platform without marketplace enabled."""
platform = Platform(
code=f"sdp_{uuid.uuid4().hex[:8]}",
name="Loyalty Only Platform",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def sd_merchant(db, sd_owner):
"""Create a merchant."""
merchant = Merchant(
name="Dashboard Test Merchant",
owner_user_id=sd_owner.id,
contact_email=sd_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def sd_store(db, sd_merchant):
"""Create a store."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=sd_merchant.id,
store_code=f"SDSTORE_{uid.upper()}",
subdomain=f"sdstore{uid.lower()}",
name="Dashboard Test Store",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def sd_store_platform(db, sd_store, sd_platform):
"""Link store to a loyalty-only platform (no marketplace)."""
sp = StorePlatform(
store_id=sd_store.id,
platform_id=sd_platform.id,
is_active=True,
)
db.add(sp)
db.commit()
db.refresh(sp)
return sp
@pytest.fixture
def sd_auth(sd_owner, sd_store):
"""Override auth dependency for store cookie/header auth."""
user_context = UserContext(
id=sd_owner.id,
email=sd_owner.email,
username=sd_owner.username,
role="merchant_owner",
is_active=True,
token_store_id=sd_store.id,
token_store_code=sd_store.store_code,
)
def _override():
return user_context
app.dependency_overrides[get_current_store_from_cookie_or_header] = _override
yield {"Authorization": "Bearer fake-token"}
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
# ============================================================================
# Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.core
class TestStoreDashboardPage:
"""Tests for GET /store/{store_code}/dashboard."""
def test_dashboard_renders_for_authenticated_store_user(
self, client, db, sd_auth, sd_store, sd_store_platform
):
"""Dashboard page returns 200 for authenticated store user."""
response = client.get(
f"/store/{sd_store.subdomain}/dashboard",
headers=sd_auth,
follow_redirects=False,
)
assert response.status_code == 200
def test_dashboard_renders_without_onboarding_check(
self, client, db, sd_auth, sd_store, sd_store_platform
):
"""Dashboard loads without any onboarding redirect — no marketplace dependency.
On a loyalty-only platform, there's no StoreOnboarding record,
yet the dashboard should still render successfully.
"""
response = client.get(
f"/store/{sd_store.subdomain}/dashboard",
headers=sd_auth,
follow_redirects=False,
)
# Should NOT redirect to onboarding
assert response.status_code == 200
assert "location" not in response.headers
def test_dashboard_requires_auth(self, client):
"""Returns 401 without auth."""
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
response = client.get(
"/store/anystore/dashboard",
follow_redirects=False,
)
assert response.status_code == 401
@pytest.mark.integration
@pytest.mark.core
class TestStoreDashboardNoMarketplace:
"""Tests verifying dashboard has no marketplace module dependency."""
def test_dashboard_has_no_onboarding_import(self):
"""Core store routes module does not import OnboardingService."""
import app.modules.core.routes.pages.store as core_store_module
with open(core_store_module.__file__) as f:
source = f.read()
assert "OnboardingService" not in source
assert "onboarding" not in source.lower()
def test_dashboard_has_no_marketplace_import(self):
"""Core store routes module does not import from marketplace."""
import app.modules.core.routes.pages.store as core_store_module
with open(core_store_module.__file__) as f:
source = f.read()
assert "app.modules.marketplace" not in source