feat: complete dynamic menu system across all frontends
All checks were successful
All checks were successful
- 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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
139
app/modules/core/routes/api/store_menu.py
Normal file
139
app/modules/core/routes/api/store_menu.py
Normal 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,
|
||||
)
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user