feat: implement module-driven context providers for dynamic page context
Introduces a module-driven context provider system that allows modules to dynamically contribute template context variables without hardcoding imports. Key changes: - Add context_providers field to ModuleDefinition in app/modules/base.py - Create unified get_context_for_frontend() that queries enabled modules only - Add context providers to CMS module (PLATFORM, STOREFRONT) - Add context providers to billing module (PLATFORM) - Fix SQLAlchemy cross-module relationship resolution (Order, AdminMenuConfig, MarketplaceImportJob) by ensuring models are imported before referencing - Document the entire system in docs/architecture/module-system.md Benefits: - Zero coupling: adding/removing modules requires no route handler changes - Lazy loading: module code only imported when that module is enabled - Per-platform customization: each platform loads only what it needs - Graceful degradation: one failing module doesn't break entire page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -371,6 +371,26 @@ class ModuleDefinition:
|
||||
tasks_path: str | None = None # Python import path, e.g., "app.modules.billing.tasks"
|
||||
scheduled_tasks: list[ScheduledTask] = field(default_factory=list)
|
||||
|
||||
# =========================================================================
|
||||
# Context Providers (Module-Driven Page Context)
|
||||
# =========================================================================
|
||||
# Callables that provide context data for page templates per frontend type.
|
||||
# Each provider receives (request, db, platform) and returns a dict.
|
||||
# This enables modules to contribute context only when enabled for a platform.
|
||||
#
|
||||
# Example:
|
||||
# def get_billing_platform_context(request, db, platform):
|
||||
# from app.modules.billing.models import TIER_LIMITS
|
||||
# return {"tiers": get_tiers_data()}
|
||||
#
|
||||
# billing_module = ModuleDefinition(
|
||||
# code="billing",
|
||||
# context_providers={
|
||||
# FrontendType.PLATFORM: get_billing_platform_context,
|
||||
# },
|
||||
# )
|
||||
context_providers: dict[FrontendType, Callable[..., dict[str, Any]]] = field(default_factory=dict)
|
||||
|
||||
# =========================================================================
|
||||
# Menu Item Methods (Legacy - uses menu_items dict of IDs)
|
||||
# =========================================================================
|
||||
@@ -695,6 +715,46 @@ class ModuleDefinition:
|
||||
else:
|
||||
return "optional"
|
||||
|
||||
# =========================================================================
|
||||
# Context Provider Methods
|
||||
# =========================================================================
|
||||
|
||||
def has_context_provider(self, frontend_type: FrontendType) -> bool:
|
||||
"""Check if this module has a context provider for a frontend type."""
|
||||
return frontend_type in self.context_providers
|
||||
|
||||
def get_context_contribution(
|
||||
self,
|
||||
frontend_type: FrontendType,
|
||||
request: Any,
|
||||
db: Any,
|
||||
platform: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get context contribution from this module for a frontend type.
|
||||
|
||||
Args:
|
||||
frontend_type: The frontend type (PLATFORM, ADMIN, VENDOR, STOREFRONT)
|
||||
request: FastAPI Request object
|
||||
db: Database session
|
||||
platform: Platform object (may be None for some contexts)
|
||||
|
||||
Returns:
|
||||
Dict of context variables, or empty dict if no provider
|
||||
|
||||
Note:
|
||||
Exceptions are caught and logged by the caller. This method
|
||||
may raise if the provider fails.
|
||||
"""
|
||||
provider = self.context_providers.get(frontend_type)
|
||||
if provider is None:
|
||||
return {}
|
||||
return provider(request, db, platform)
|
||||
|
||||
def get_supported_frontend_types(self) -> list[FrontendType]:
|
||||
"""Get list of frontend types this module provides context for."""
|
||||
return list(self.context_providers.keys())
|
||||
|
||||
# =========================================================================
|
||||
# Magic Methods
|
||||
# =========================================================================
|
||||
|
||||
@@ -6,9 +6,57 @@ Defines the billing module including its features, menu items,
|
||||
route configurations, and scheduled tasks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition, PermissionDefinition, ScheduledTask
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Context Providers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Provide billing context for platform/marketing pages.
|
||||
|
||||
Returns pricing tier data for the marketing pricing page.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.modules.billing.models import TIER_LIMITS, TierCode
|
||||
|
||||
tiers = []
|
||||
for tier_code, limits in TIER_LIMITS.items():
|
||||
tiers.append({
|
||||
"code": tier_code.value,
|
||||
"name": limits["name"],
|
||||
"price_monthly": limits["price_monthly_cents"] / 100,
|
||||
"price_annual": (limits["price_annual_cents"] / 100)
|
||||
if limits.get("price_annual_cents")
|
||||
else None,
|
||||
"orders_per_month": limits.get("orders_per_month"),
|
||||
"products_limit": limits.get("products_limit"),
|
||||
"team_members": limits.get("team_members"),
|
||||
"features": limits.get("features", []),
|
||||
"is_popular": tier_code == TierCode.PROFESSIONAL,
|
||||
"is_enterprise": tier_code == TierCode.ENTERPRISE,
|
||||
})
|
||||
|
||||
return {
|
||||
"tiers": tiers,
|
||||
"trial_days": settings.stripe_trial_days,
|
||||
"stripe_publishable_key": settings.stripe_publishable_key,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Router Lazy Imports
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_admin_router():
|
||||
"""Lazy import of admin router to avoid circular imports."""
|
||||
@@ -153,6 +201,10 @@ billing_module = ModuleDefinition(
|
||||
],
|
||||
},
|
||||
is_core=False, # Billing can be disabled (e.g., internal platforms)
|
||||
# Context providers for dynamic page context
|
||||
context_providers={
|
||||
FrontendType.PLATFORM: _get_platform_context,
|
||||
},
|
||||
# =========================================================================
|
||||
# Self-Contained Module Configuration
|
||||
# =========================================================================
|
||||
|
||||
@@ -12,9 +12,100 @@ This is a self-contained module with:
|
||||
- Templates: app.modules.cms.templates (namespaced as cms/)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition, PermissionDefinition
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Context Providers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Provide CMS context for platform/marketing pages.
|
||||
|
||||
Returns header and footer navigation pages for the marketing site.
|
||||
"""
|
||||
from app.modules.cms.services import content_page_service
|
||||
|
||||
platform_id = platform.id if platform else 1
|
||||
|
||||
header_pages = []
|
||||
footer_pages = []
|
||||
|
||||
try:
|
||||
header_pages = content_page_service.list_platform_pages(
|
||||
db, platform_id=platform_id, header_only=True, include_unpublished=False
|
||||
)
|
||||
footer_pages = content_page_service.list_platform_pages(
|
||||
db, platform_id=platform_id, footer_only=True, include_unpublished=False
|
||||
)
|
||||
logger.debug(
|
||||
f"[CMS] Platform context: {len(header_pages)} header, {len(footer_pages)} footer pages"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[CMS] Failed to load platform navigation pages: {e}")
|
||||
|
||||
return {
|
||||
"header_pages": header_pages,
|
||||
"footer_pages": footer_pages,
|
||||
"legal_pages": [], # TODO: Add legal pages support if needed
|
||||
}
|
||||
|
||||
|
||||
def _get_storefront_context(request: Any, db: Any, platform: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Provide CMS context for storefront (customer shop) pages.
|
||||
|
||||
Returns header and footer navigation pages for the vendor's shop.
|
||||
"""
|
||||
from app.modules.cms.services import content_page_service
|
||||
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
platform_id = platform.id if platform else 1
|
||||
|
||||
header_pages = []
|
||||
footer_pages = []
|
||||
|
||||
if vendor:
|
||||
try:
|
||||
header_pages = content_page_service.list_pages_for_vendor(
|
||||
db,
|
||||
platform_id=platform_id,
|
||||
vendor_id=vendor.id,
|
||||
header_only=True,
|
||||
include_unpublished=False,
|
||||
)
|
||||
footer_pages = content_page_service.list_pages_for_vendor(
|
||||
db,
|
||||
platform_id=platform_id,
|
||||
vendor_id=vendor.id,
|
||||
footer_only=True,
|
||||
include_unpublished=False,
|
||||
)
|
||||
logger.debug(
|
||||
f"[CMS] Storefront context for vendor {vendor.id}: "
|
||||
f"{len(header_pages)} header, {len(footer_pages)} footer pages"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[CMS] Failed to load storefront navigation pages: {e}")
|
||||
|
||||
return {
|
||||
"header_pages": header_pages,
|
||||
"footer_pages": footer_pages,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Router Lazy Imports
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_admin_router():
|
||||
"""Lazy import of admin router to avoid circular imports."""
|
||||
@@ -139,6 +230,11 @@ cms_module = ModuleDefinition(
|
||||
],
|
||||
},
|
||||
is_core=True, # CMS is a core module - content management is fundamental
|
||||
# Context providers for dynamic page context
|
||||
context_providers={
|
||||
FrontendType.PLATFORM: _get_platform_context,
|
||||
FrontendType.STOREFRONT: _get_storefront_context,
|
||||
},
|
||||
# Self-contained module configuration
|
||||
is_self_contained=True,
|
||||
services_path="app.modules.cms.services",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Core module utilities."""
|
||||
|
||||
from .page_context import (
|
||||
get_context_for_frontend,
|
||||
get_admin_context,
|
||||
get_vendor_context,
|
||||
get_storefront_context,
|
||||
@@ -9,6 +10,7 @@ from .page_context import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_context_for_frontend",
|
||||
"get_admin_context",
|
||||
"get_vendor_context",
|
||||
"get_storefront_context",
|
||||
|
||||
@@ -1,154 +1,284 @@
|
||||
# app/modules/core/utils/page_context.py
|
||||
"""
|
||||
Shared page context helpers for HTML page routes.
|
||||
Module-driven page context builder.
|
||||
|
||||
These functions build template contexts that include common variables
|
||||
needed across different frontends (admin, vendor, storefront, public).
|
||||
This module provides a unified, dynamic context building system for all frontend
|
||||
types. Instead of hardcoding module imports, it delegates context creation to
|
||||
modules themselves through their registered context providers.
|
||||
|
||||
Architecture:
|
||||
Each module can register context providers in its definition.py:
|
||||
|
||||
context_providers={
|
||||
FrontendType.PLATFORM: get_platform_context_contribution,
|
||||
FrontendType.VENDOR: get_vendor_context_contribution,
|
||||
}
|
||||
|
||||
The context builder then:
|
||||
1. Determines which modules are enabled for the platform
|
||||
2. Calls each enabled module's context provider for the requested frontend
|
||||
3. Merges all contributions into a single context dict
|
||||
|
||||
Benefits:
|
||||
- No hardcoded module imports in this file
|
||||
- Modules are fully isolated - adding/removing a module doesn't require changes here
|
||||
- Main platform with no modules enabled loads nothing extra
|
||||
- Each module controls its own context contribution
|
||||
|
||||
Frontend Types:
|
||||
- PLATFORM: Marketing pages (homepage, pricing, signup)
|
||||
- ADMIN: Platform admin dashboard
|
||||
- VENDOR: Vendor/merchant dashboard
|
||||
- STOREFRONT: Customer-facing shop pages
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.modules.core.services.platform_settings_service import platform_settings_service
|
||||
from app.modules.enums import FrontendType
|
||||
from app.utils.i18n import get_jinja2_globals
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_admin_context(
|
||||
def get_context_for_frontend(
|
||||
frontend_type: FrontendType,
|
||||
request: Request,
|
||||
current_user: User,
|
||||
db: Session | None = None,
|
||||
**extra_context,
|
||||
) -> dict:
|
||||
db: Session,
|
||||
**extra_context: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build template context for admin dashboard pages.
|
||||
Build context dynamically for any frontend type.
|
||||
|
||||
This is the main entry point for context building. It:
|
||||
1. Creates base context (request, i18n, platform info)
|
||||
2. Gets list of enabled modules for the platform
|
||||
3. Calls each module's context provider for this frontend type
|
||||
4. Merges all contributions
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
current_user: Authenticated admin user
|
||||
db: Optional database session
|
||||
**extra_context: Additional variables for template
|
||||
frontend_type: The frontend type (PLATFORM, ADMIN, VENDOR, STOREFRONT)
|
||||
request: FastAPI Request object
|
||||
db: Database session
|
||||
**extra_context: Additional context variables to include
|
||||
|
||||
Returns:
|
||||
Dictionary with request, user, and extra context
|
||||
"""
|
||||
context = {
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
}
|
||||
Dict of context variables for template rendering
|
||||
|
||||
Example:
|
||||
context = get_context_for_frontend(
|
||||
FrontendType.PLATFORM,
|
||||
request,
|
||||
db,
|
||||
page_title="Home",
|
||||
)
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.service import module_service
|
||||
|
||||
# Get platform from middleware (may be None for unauthenticated contexts)
|
||||
platform = getattr(request.state, "platform", None)
|
||||
|
||||
# Get language from request state (set by middleware)
|
||||
language = getattr(request.state, "language", "en")
|
||||
|
||||
# Build base context (always present for all frontends)
|
||||
context = _build_base_context(request, platform, language)
|
||||
|
||||
# Determine which modules are enabled for this platform
|
||||
if platform:
|
||||
enabled_module_codes = module_service.get_enabled_module_codes(db, platform.id)
|
||||
else:
|
||||
# No platform context - only core modules
|
||||
enabled_module_codes = {
|
||||
code for code, module in MODULES.items() if module.is_core
|
||||
}
|
||||
|
||||
# Collect context from enabled modules that have providers for this frontend
|
||||
for code in enabled_module_codes:
|
||||
module = MODULES.get(code)
|
||||
if module is None:
|
||||
continue
|
||||
|
||||
if not module.has_context_provider(frontend_type):
|
||||
continue
|
||||
|
||||
try:
|
||||
contribution = module.get_context_contribution(
|
||||
frontend_type, request, db, platform
|
||||
)
|
||||
if contribution:
|
||||
context.update(contribution)
|
||||
logger.debug(
|
||||
f"[CONTEXT] Module '{code}' contributed {len(contribution)} keys "
|
||||
f"for {frontend_type.value}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[CONTEXT] Module '{code}' context provider failed for "
|
||||
f"{frontend_type.value}: {e}"
|
||||
)
|
||||
|
||||
# Add any extra context passed by the caller
|
||||
if extra_context:
|
||||
context.update(extra_context)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def _build_base_context(
|
||||
request: Request,
|
||||
platform: Any,
|
||||
language: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build the base context that's present for all frontends.
|
||||
|
||||
This includes:
|
||||
- request: FastAPI Request object
|
||||
- platform: Platform object (may be None)
|
||||
- i18n: Translation functions and language info
|
||||
- Basic platform settings from config
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
platform: Platform object or None
|
||||
language: Language code (e.g., "en", "fr")
|
||||
|
||||
Returns:
|
||||
Base context dict
|
||||
"""
|
||||
# Get i18n globals (_, t, current_language, etc.)
|
||||
i18n_globals = get_jinja2_globals(language)
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"platform": platform,
|
||||
"platform_name": settings.project_name,
|
||||
"platform_domain": settings.platform_domain,
|
||||
}
|
||||
|
||||
# Add i18n globals
|
||||
context.update(i18n_globals)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Convenience Functions (Wrappers for common frontend types)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_platform_context(
|
||||
request: Request,
|
||||
db: Session,
|
||||
**extra_context: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build context for platform/marketing pages.
|
||||
|
||||
This is a convenience wrapper for get_context_for_frontend(FrontendType.PLATFORM).
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
db: Database session
|
||||
**extra_context: Additional variables for template
|
||||
|
||||
Returns:
|
||||
Context dict for platform pages
|
||||
"""
|
||||
return get_context_for_frontend(
|
||||
FrontendType.PLATFORM,
|
||||
request,
|
||||
db,
|
||||
**extra_context,
|
||||
)
|
||||
|
||||
|
||||
def get_admin_context(
|
||||
request: Request,
|
||||
db: Session,
|
||||
current_user: Any,
|
||||
**extra_context: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build context for admin dashboard pages.
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
db: Database session
|
||||
current_user: Authenticated admin user
|
||||
**extra_context: Additional variables for template
|
||||
|
||||
Returns:
|
||||
Context dict for admin pages
|
||||
"""
|
||||
return get_context_for_frontend(
|
||||
FrontendType.ADMIN,
|
||||
request,
|
||||
db,
|
||||
user=current_user,
|
||||
**extra_context,
|
||||
)
|
||||
|
||||
|
||||
def get_vendor_context(
|
||||
request: Request,
|
||||
db: Session,
|
||||
current_user: User,
|
||||
current_user: Any,
|
||||
vendor_code: str,
|
||||
**extra_context,
|
||||
) -> dict:
|
||||
**extra_context: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build template context for vendor dashboard pages.
|
||||
|
||||
Resolves locale/currency using the platform settings service with
|
||||
vendor override support:
|
||||
1. Vendor's storefront_locale (if set)
|
||||
2. Platform's default from PlatformSettingsService
|
||||
3. Environment variable
|
||||
4. Hardcoded fallback
|
||||
Build context for vendor dashboard pages.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
request: FastAPI Request object
|
||||
db: Database session
|
||||
current_user: Authenticated vendor user
|
||||
vendor_code: Vendor subdomain/code
|
||||
**extra_context: Additional variables for template
|
||||
|
||||
Returns:
|
||||
Dictionary with request, user, vendor, resolved locale/currency, and extra context
|
||||
Context dict for vendor pages
|
||||
"""
|
||||
# Load vendor from database
|
||||
vendor = db.query(Vendor).filter(Vendor.subdomain == vendor_code).first()
|
||||
|
||||
# Get platform defaults
|
||||
platform_config = platform_settings_service.get_storefront_config(db)
|
||||
|
||||
# Resolve with vendor override
|
||||
storefront_locale = platform_config["locale"]
|
||||
storefront_currency = platform_config["currency"]
|
||||
|
||||
if vendor and vendor.storefront_locale:
|
||||
storefront_locale = vendor.storefront_locale
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
"vendor": vendor,
|
||||
"vendor_code": vendor_code,
|
||||
"storefront_locale": storefront_locale,
|
||||
"storefront_currency": storefront_currency,
|
||||
"dashboard_language": vendor.dashboard_language if vendor else "en",
|
||||
}
|
||||
|
||||
# Add any extra context
|
||||
if extra_context:
|
||||
context.update(extra_context)
|
||||
|
||||
logger.debug(
|
||||
"[VENDOR_CONTEXT] Context built",
|
||||
extra={
|
||||
"vendor_id": vendor.id if vendor else None,
|
||||
"vendor_code": vendor_code,
|
||||
"storefront_locale": storefront_locale,
|
||||
"storefront_currency": storefront_currency,
|
||||
"extra_keys": list(extra_context.keys()) if extra_context else [],
|
||||
},
|
||||
return get_context_for_frontend(
|
||||
FrontendType.VENDOR,
|
||||
request,
|
||||
db,
|
||||
user=current_user,
|
||||
vendor_code=vendor_code,
|
||||
**extra_context,
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def get_storefront_context(
|
||||
request: Request,
|
||||
db: Session | None = None,
|
||||
**extra_context,
|
||||
) -> dict:
|
||||
**extra_context: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build template context for storefront (customer shop) pages.
|
||||
|
||||
Automatically includes vendor and theme from middleware request.state.
|
||||
Additional context can be passed as keyword arguments.
|
||||
Build context for storefront (customer shop) pages.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object with vendor/theme in state
|
||||
db: Optional database session for loading navigation pages
|
||||
**extra_context: Additional variables for template (user, product_id, etc.)
|
||||
request: FastAPI Request object with vendor/theme in state
|
||||
db: Optional database session
|
||||
**extra_context: Additional variables for template
|
||||
|
||||
Returns:
|
||||
Dictionary with request, vendor, theme, navigation pages, and extra context
|
||||
Context dict for storefront pages
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from app.modules.cms.services import content_page_service
|
||||
|
||||
# Extract from middleware state
|
||||
# Extract vendor and theme from middleware state
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
platform = getattr(request.state, "platform", None)
|
||||
theme = getattr(request.state, "theme", None)
|
||||
clean_path = getattr(request.state, "clean_path", request.url.path)
|
||||
vendor_context = getattr(request.state, "vendor_context", None)
|
||||
|
||||
# Get platform_id (default to 1 for OMS if not set)
|
||||
platform_id = platform.id if platform else 1
|
||||
|
||||
# Get detection method from vendor_context
|
||||
access_method = (
|
||||
vendor_context.get("detection_method", "unknown")
|
||||
@@ -156,22 +286,9 @@ def get_storefront_context(
|
||||
else "unknown"
|
||||
)
|
||||
|
||||
if vendor is None:
|
||||
logger.warning(
|
||||
"[STOREFRONT_CONTEXT] Vendor not found in request.state",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"host": request.headers.get("host", ""),
|
||||
"has_vendor": False,
|
||||
},
|
||||
)
|
||||
|
||||
# Calculate base URL for links
|
||||
# - Domain/subdomain access: base_url = "/"
|
||||
# - Path-based access: base_url = "/vendor/{vendor_code}/" or "/vendors/{vendor_code}/"
|
||||
base_url = "/"
|
||||
if access_method == "path" and vendor:
|
||||
# Use the full_prefix from vendor_context to determine which pattern was used
|
||||
full_prefix = (
|
||||
vendor_context.get("full_prefix", "/vendor/")
|
||||
if vendor_context
|
||||
@@ -179,150 +296,31 @@ def get_storefront_context(
|
||||
)
|
||||
base_url = f"{full_prefix}{vendor.subdomain}/"
|
||||
|
||||
# Load footer and header navigation pages from CMS if db session provided
|
||||
footer_pages = []
|
||||
header_pages = []
|
||||
if db and vendor:
|
||||
try:
|
||||
vendor_id = vendor.id
|
||||
# Get pages configured to show in footer
|
||||
footer_pages = content_page_service.list_pages_for_vendor(
|
||||
db,
|
||||
platform_id=platform_id,
|
||||
vendor_id=vendor_id,
|
||||
footer_only=True,
|
||||
include_unpublished=False,
|
||||
)
|
||||
# Get pages configured to show in header
|
||||
header_pages = content_page_service.list_pages_for_vendor(
|
||||
db,
|
||||
platform_id=platform_id,
|
||||
vendor_id=vendor_id,
|
||||
header_only=True,
|
||||
include_unpublished=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[STOREFRONT_CONTEXT] Failed to load navigation pages",
|
||||
extra={"error": str(e), "vendor_id": vendor.id if vendor else None},
|
||||
)
|
||||
|
||||
# Resolve storefront locale and currency
|
||||
storefront_config = {"locale": "fr-LU", "currency": "EUR"} # defaults
|
||||
if db and vendor:
|
||||
platform_config = platform_settings_service.get_storefront_config(db)
|
||||
storefront_config["locale"] = platform_config["locale"]
|
||||
storefront_config["currency"] = platform_config["currency"]
|
||||
if vendor.storefront_locale:
|
||||
storefront_config["locale"] = vendor.storefront_locale
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
# Build storefront-specific base context
|
||||
storefront_base = {
|
||||
"vendor": vendor,
|
||||
"theme": theme,
|
||||
"clean_path": clean_path,
|
||||
"access_method": access_method,
|
||||
"base_url": base_url,
|
||||
"footer_pages": footer_pages,
|
||||
"header_pages": header_pages,
|
||||
"storefront_locale": storefront_config["locale"],
|
||||
"storefront_currency": storefront_config["currency"],
|
||||
}
|
||||
|
||||
# Add any extra context (user, product_id, category_slug, etc.)
|
||||
if extra_context:
|
||||
# If no db session, return just the base context
|
||||
if db is None:
|
||||
context = _build_base_context(
|
||||
request,
|
||||
getattr(request.state, "platform", None),
|
||||
getattr(request.state, "language", "en"),
|
||||
)
|
||||
context.update(storefront_base)
|
||||
context.update(extra_context)
|
||||
return context
|
||||
|
||||
logger.debug(
|
||||
"[STOREFRONT_CONTEXT] Context built",
|
||||
extra={
|
||||
"vendor_id": vendor.id if vendor else None,
|
||||
"vendor_name": vendor.name if vendor else None,
|
||||
"vendor_subdomain": vendor.subdomain if vendor else None,
|
||||
"has_theme": theme is not None,
|
||||
"access_method": access_method,
|
||||
"base_url": base_url,
|
||||
"storefront_locale": storefront_config["locale"],
|
||||
"storefront_currency": storefront_config["currency"],
|
||||
"footer_pages_count": len(footer_pages),
|
||||
"header_pages_count": len(header_pages),
|
||||
"extra_keys": list(extra_context.keys()) if extra_context else [],
|
||||
},
|
||||
# Full context with module contributions
|
||||
return get_context_for_frontend(
|
||||
FrontendType.STOREFRONT,
|
||||
request,
|
||||
db,
|
||||
**storefront_base,
|
||||
**extra_context,
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def get_platform_context(
|
||||
request: Request,
|
||||
db: Session,
|
||||
**extra_context,
|
||||
) -> dict:
|
||||
"""
|
||||
Build context for platform/marketing pages.
|
||||
|
||||
Includes platform info, i18n globals, and CMS navigation pages.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
db: Database session
|
||||
**extra_context: Additional variables for template
|
||||
|
||||
Returns:
|
||||
Dictionary with request, platform info, i18n globals, and extra context
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from app.modules.cms.services import content_page_service
|
||||
|
||||
# Get language from request state (set by middleware)
|
||||
language = getattr(request.state, "language", "fr")
|
||||
|
||||
# Get platform from middleware (default to OMS platform_id=1)
|
||||
platform = getattr(request.state, "platform", None)
|
||||
platform_id = platform.id if platform else 1
|
||||
|
||||
# Get translation function
|
||||
i18n_globals = get_jinja2_globals(language)
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"platform": platform,
|
||||
"platform_name": "Wizamart",
|
||||
"platform_domain": settings.platform_domain,
|
||||
"stripe_publishable_key": settings.stripe_publishable_key,
|
||||
"trial_days": settings.stripe_trial_days,
|
||||
}
|
||||
|
||||
# Add i18n globals (_, t, current_language, SUPPORTED_LANGUAGES, etc.)
|
||||
context.update(i18n_globals)
|
||||
|
||||
# Load CMS pages for header, footer, and legal navigation
|
||||
header_pages = []
|
||||
footer_pages = []
|
||||
legal_pages = []
|
||||
try:
|
||||
# Platform marketing pages (is_platform_page=True)
|
||||
header_pages = content_page_service.list_platform_pages(
|
||||
db, platform_id=platform_id, header_only=True, include_unpublished=False
|
||||
)
|
||||
footer_pages = content_page_service.list_platform_pages(
|
||||
db, platform_id=platform_id, footer_only=True, include_unpublished=False
|
||||
)
|
||||
# For legal pages, we need to add footer support or use a different approach
|
||||
# For now, legal pages come from footer pages with show_in_legal flag
|
||||
legal_pages = [] # Will be handled separately if needed
|
||||
logger.debug(
|
||||
f"Loaded CMS pages: {len(header_pages)} header, {len(footer_pages)} footer, {len(legal_pages)} legal"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load CMS navigation pages: {e}")
|
||||
|
||||
context["header_pages"] = header_pages
|
||||
context["footer_pages"] = footer_pages
|
||||
context["legal_pages"] = legal_pages
|
||||
|
||||
# Add any extra context
|
||||
if extra_context:
|
||||
context.update(extra_context)
|
||||
|
||||
return context
|
||||
|
||||
@@ -14,6 +14,14 @@ Usage:
|
||||
)
|
||||
"""
|
||||
|
||||
# Import models from other modules FIRST to resolve string-based relationship references.
|
||||
# These imports are NOT re-exported, just ensure models are registered with SQLAlchemy
|
||||
# before the marketplace models are loaded.
|
||||
#
|
||||
# Relationship being resolved:
|
||||
# - LetzshopFulfillmentQueue.order -> "Order" (in orders module)
|
||||
from app.modules.orders.models import Order # noqa: F401
|
||||
|
||||
from app.modules.marketplace.models.marketplace_product import (
|
||||
MarketplaceProduct,
|
||||
ProductType,
|
||||
|
||||
@@ -10,6 +10,17 @@ This is the canonical location for tenancy module models including:
|
||||
- Vendor domains
|
||||
"""
|
||||
|
||||
# Import models from other modules FIRST to resolve string-based relationship references.
|
||||
# These imports are NOT re-exported, just ensure models are registered with SQLAlchemy
|
||||
# before the tenancy models are loaded.
|
||||
#
|
||||
# Relationship chain being resolved:
|
||||
# - Platform.admin_menu_configs -> "AdminMenuConfig" (in core module)
|
||||
# - User.marketplace_import_jobs -> "MarketplaceImportJob" (in marketplace module)
|
||||
# - Vendor.marketplace_import_jobs -> "MarketplaceImportJob" (in marketplace module)
|
||||
from app.modules.core.models import AdminMenuConfig # noqa: F401
|
||||
from app.modules.marketplace.models.marketplace_import_job import MarketplaceImportJob # noqa: F401
|
||||
|
||||
from app.modules.tenancy.models.admin import (
|
||||
AdminAuditLog,
|
||||
AdminSession,
|
||||
|
||||
@@ -238,6 +238,7 @@ analytics_module = ModuleDefinition(
|
||||
| `features` | `list[str]` | Feature codes for tier gating |
|
||||
| `permissions` | `list[PermissionDefinition]` | RBAC permission definitions |
|
||||
| `menu_items` | `dict` | Menu items per frontend type |
|
||||
| `context_providers` | `dict[FrontendType, Callable]` | Functions that provide template context per frontend |
|
||||
| `is_core` | `bool` | Cannot be disabled if True |
|
||||
| `is_internal` | `bool` | Admin-only if True |
|
||||
| `is_self_contained` | `bool` | Uses self-contained structure |
|
||||
@@ -369,6 +370,283 @@ module_service.enable_module(db, platform_id, "billing", user_id=current_user.id
|
||||
module_service.disable_module(db, platform_id, "billing", user_id=current_user.id)
|
||||
```
|
||||
|
||||
## Context Providers (Module-Driven Page Context)
|
||||
|
||||
**Context providers** enable modules to dynamically contribute template context variables without hardcoding module imports. This is a core architectural pattern that ensures the platform remains modular and extensible.
|
||||
|
||||
### Problem Solved
|
||||
|
||||
Without context providers, the platform would need hardcoded imports like:
|
||||
|
||||
```python
|
||||
# BAD: Hardcoded module imports
|
||||
from app.modules.billing.models import TIER_LIMITS # What if billing is disabled?
|
||||
from app.modules.cms.services import content_page_service # What if cms is disabled?
|
||||
```
|
||||
|
||||
This breaks when modules are disabled and creates tight coupling.
|
||||
|
||||
### Solution: Module-Driven Context
|
||||
|
||||
Each module can register **context provider functions** in its `definition.py`. The framework automatically calls providers for enabled modules only.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Page Request (e.g., /pricing) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ get_context_for_frontend(FrontendType.PLATFORM) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────┼───────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||
│ CMS Module │ │Billing Module │ │ Other Module │
|
||||
│ (enabled) │ │ (enabled) │ │ (disabled) │
|
||||
└───────┬───────┘ └───────┬───────┘ └───────────────┘
|
||||
│ │ │
|
||||
▼ ▼ × (skipped)
|
||||
┌───────────────┐ ┌───────────────┐
|
||||
│ header_pages │ │ tiers │
|
||||
│ footer_pages │ │ trial_days │
|
||||
└───────────────┘ └───────────────┘
|
||||
│ │
|
||||
└───────────┬───────────┘
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ Merged Context Dict │
|
||||
│ {header_pages, tiers, ...} │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Frontend Types
|
||||
|
||||
Context providers are registered per frontend type:
|
||||
|
||||
| Frontend Type | Description | Use Case |
|
||||
|--------------|-------------|----------|
|
||||
| `PLATFORM` | Marketing/public pages | Homepage, pricing, signup |
|
||||
| `ADMIN` | Platform admin dashboard | Admin user management, platform settings |
|
||||
| `VENDOR` | Vendor/merchant dashboard | Store settings, product management |
|
||||
| `STOREFRONT` | Customer-facing shop | Product browsing, cart, checkout |
|
||||
|
||||
### Registering a Context Provider
|
||||
|
||||
In your module's `definition.py`:
|
||||
|
||||
```python
|
||||
# app/modules/billing/definition.py
|
||||
from typing import Any
|
||||
from app.modules.base import ModuleDefinition
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Provide billing context for platform/marketing pages.
|
||||
|
||||
This function is ONLY called when billing module is enabled.
|
||||
Imports are done inside to avoid loading when module is disabled.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.modules.billing.models import TIER_LIMITS, TierCode
|
||||
|
||||
tiers = []
|
||||
for tier_code, limits in TIER_LIMITS.items():
|
||||
tiers.append({
|
||||
"code": tier_code.value,
|
||||
"name": limits["name"],
|
||||
"price_monthly": limits["price_monthly_cents"] / 100,
|
||||
# ... more tier data
|
||||
})
|
||||
|
||||
return {
|
||||
"tiers": tiers,
|
||||
"trial_days": settings.stripe_trial_days,
|
||||
"stripe_publishable_key": settings.stripe_publishable_key,
|
||||
}
|
||||
|
||||
billing_module = ModuleDefinition(
|
||||
code="billing",
|
||||
name="Billing & Subscriptions",
|
||||
# ... other fields ...
|
||||
|
||||
# Register context providers
|
||||
context_providers={
|
||||
FrontendType.PLATFORM: _get_platform_context,
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### Context Provider Signature
|
||||
|
||||
All context providers must follow this signature:
|
||||
|
||||
```python
|
||||
def provider_function(
|
||||
request: Any, # FastAPI Request object
|
||||
db: Any, # SQLAlchemy Session
|
||||
platform: Any, # Platform model (may be None)
|
||||
) -> dict[str, Any]:
|
||||
"""Return a dict of context variables to merge into the template context."""
|
||||
return {"key": "value"}
|
||||
```
|
||||
|
||||
### Using Context in Routes
|
||||
|
||||
Route handlers use convenience functions to build context:
|
||||
|
||||
```python
|
||||
# app/modules/cms/routes/pages/platform.py
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from app.modules.core.utils import get_platform_context
|
||||
from app.api.deps import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/pricing")
|
||||
async def pricing_page(request: Request, db = Depends(get_db)):
|
||||
# Context automatically includes contributions from all enabled modules
|
||||
context = get_platform_context(request, db, page_title="Pricing")
|
||||
|
||||
# If billing module is enabled, context includes:
|
||||
# - tiers, trial_days, stripe_publishable_key
|
||||
# If CMS module is enabled, context includes:
|
||||
# - header_pages, footer_pages
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="platform/pricing.html",
|
||||
context=context,
|
||||
)
|
||||
```
|
||||
|
||||
### Available Context Functions
|
||||
|
||||
Import from `app.modules.core.utils`:
|
||||
|
||||
```python
|
||||
from app.modules.core.utils import (
|
||||
get_context_for_frontend, # Generic - specify FrontendType
|
||||
get_platform_context, # For PLATFORM pages
|
||||
get_admin_context, # For ADMIN pages
|
||||
get_vendor_context, # For VENDOR pages
|
||||
get_storefront_context, # For STOREFRONT pages
|
||||
)
|
||||
```
|
||||
|
||||
### Base Context (Always Available)
|
||||
|
||||
Every context includes these base variables regardless of modules:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `request` | FastAPI Request object |
|
||||
| `platform` | Platform model (may be None) |
|
||||
| `platform_name` | From settings.project_name |
|
||||
| `platform_domain` | From settings.platform_domain |
|
||||
| `_` | Translation function (gettext style) |
|
||||
| `t` | Translation function (key-value style) |
|
||||
| `current_language` | Current language code |
|
||||
| `SUPPORTED_LANGUAGES` | List of available languages |
|
||||
|
||||
### Example: CMS Module Context Provider
|
||||
|
||||
```python
|
||||
# app/modules/cms/definition.py
|
||||
def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any]:
|
||||
"""Provide CMS context for platform/marketing pages."""
|
||||
from app.modules.cms.services import content_page_service
|
||||
|
||||
platform_id = platform.id if platform else 1
|
||||
|
||||
header_pages = content_page_service.list_platform_pages(
|
||||
db, platform_id=platform_id, header_only=True, include_unpublished=False
|
||||
)
|
||||
footer_pages = content_page_service.list_platform_pages(
|
||||
db, platform_id=platform_id, footer_only=True, include_unpublished=False
|
||||
)
|
||||
|
||||
return {
|
||||
"header_pages": header_pages,
|
||||
"footer_pages": footer_pages,
|
||||
"legal_pages": [],
|
||||
}
|
||||
|
||||
def _get_storefront_context(request: Any, db: Any, platform: Any) -> dict[str, Any]:
|
||||
"""Provide CMS context for storefront (customer shop) pages."""
|
||||
from app.modules.cms.services import content_page_service
|
||||
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if not vendor:
|
||||
return {"header_pages": [], "footer_pages": []}
|
||||
|
||||
header_pages = content_page_service.list_pages_for_vendor(
|
||||
db, platform_id=platform.id, vendor_id=vendor.id, header_only=True
|
||||
)
|
||||
footer_pages = content_page_service.list_pages_for_vendor(
|
||||
db, platform_id=platform.id, vendor_id=vendor.id, footer_only=True
|
||||
)
|
||||
|
||||
return {"header_pages": header_pages, "footer_pages": footer_pages}
|
||||
|
||||
cms_module = ModuleDefinition(
|
||||
code="cms",
|
||||
# ... other fields ...
|
||||
context_providers={
|
||||
FrontendType.PLATFORM: _get_platform_context,
|
||||
FrontendType.STOREFRONT: _get_storefront_context,
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### How Modules Are Selected
|
||||
|
||||
The context builder determines which modules to query:
|
||||
|
||||
1. **With platform**: Queries `PlatformModule` table for enabled modules
|
||||
2. **Without platform**: Only includes core modules (`is_core=True`)
|
||||
|
||||
```python
|
||||
# Simplified logic from get_context_for_frontend()
|
||||
if platform:
|
||||
enabled_module_codes = module_service.get_enabled_module_codes(db, platform.id)
|
||||
else:
|
||||
# No platform context - only core modules
|
||||
enabled_module_codes = {
|
||||
code for code, module in MODULES.items() if module.is_core
|
||||
}
|
||||
|
||||
for code in enabled_module_codes:
|
||||
module = MODULES.get(code)
|
||||
if module and module.has_context_provider(frontend_type):
|
||||
contribution = module.get_context_contribution(frontend_type, request, db, platform)
|
||||
context.update(contribution)
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Context providers are wrapped in try/except to prevent one module from breaking the entire page:
|
||||
|
||||
```python
|
||||
try:
|
||||
contribution = module.get_context_contribution(...)
|
||||
if contribution:
|
||||
context.update(contribution)
|
||||
except Exception as e:
|
||||
logger.warning(f"[CONTEXT] Module '{code}' context provider failed: {e}")
|
||||
# Continue with other modules - page still renders
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Zero coupling**: Adding/removing modules requires no changes to route handlers
|
||||
2. **Lazy loading**: Module code only imported when that module is enabled
|
||||
3. **Per-platform customization**: Each platform loads only what it needs
|
||||
4. **Graceful degradation**: One failing module doesn't break the entire page
|
||||
5. **Testability**: Providers are pure functions that can be unit tested
|
||||
|
||||
## Module Static Files
|
||||
|
||||
Each module can have its own static assets (JavaScript, CSS, images) in the `static/` directory. These are automatically mounted at `/static/modules/{module_name}/`.
|
||||
|
||||
Reference in New Issue
Block a user