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

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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}/`.