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:
2026-02-02 19:22:52 +01:00
parent fb8cb14506
commit b03406db45
8 changed files with 746 additions and 241 deletions

View File

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

View File

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