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"
|
tasks_path: str | None = None # Python import path, e.g., "app.modules.billing.tasks"
|
||||||
scheduled_tasks: list[ScheduledTask] = field(default_factory=list)
|
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)
|
# Menu Item Methods (Legacy - uses menu_items dict of IDs)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -695,6 +715,46 @@ class ModuleDefinition:
|
|||||||
else:
|
else:
|
||||||
return "optional"
|
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
|
# Magic Methods
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -6,9 +6,57 @@ Defines the billing module including its features, menu items,
|
|||||||
route configurations, and scheduled tasks.
|
route configurations, and scheduled tasks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition, PermissionDefinition, ScheduledTask
|
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition, PermissionDefinition, ScheduledTask
|
||||||
from app.modules.enums import FrontendType
|
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():
|
def _get_admin_router():
|
||||||
"""Lazy import of admin router to avoid circular imports."""
|
"""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)
|
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
|
# Self-Contained Module Configuration
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -12,9 +12,100 @@ This is a self-contained module with:
|
|||||||
- Templates: app.modules.cms.templates (namespaced as cms/)
|
- 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.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition, PermissionDefinition
|
||||||
from app.modules.enums import FrontendType
|
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():
|
def _get_admin_router():
|
||||||
"""Lazy import of admin router to avoid circular imports."""
|
"""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
|
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
|
# Self-contained module configuration
|
||||||
is_self_contained=True,
|
is_self_contained=True,
|
||||||
services_path="app.modules.cms.services",
|
services_path="app.modules.cms.services",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"""Core module utilities."""
|
"""Core module utilities."""
|
||||||
|
|
||||||
from .page_context import (
|
from .page_context import (
|
||||||
|
get_context_for_frontend,
|
||||||
get_admin_context,
|
get_admin_context,
|
||||||
get_vendor_context,
|
get_vendor_context,
|
||||||
get_storefront_context,
|
get_storefront_context,
|
||||||
@@ -9,6 +10,7 @@ from .page_context import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"get_context_for_frontend",
|
||||||
"get_admin_context",
|
"get_admin_context",
|
||||||
"get_vendor_context",
|
"get_vendor_context",
|
||||||
"get_storefront_context",
|
"get_storefront_context",
|
||||||
|
|||||||
@@ -1,154 +1,284 @@
|
|||||||
# app/modules/core/utils/page_context.py
|
# 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
|
This module provides a unified, dynamic context building system for all frontend
|
||||||
needed across different frontends (admin, vendor, storefront, public).
|
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
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
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.utils.i18n import get_jinja2_globals
|
||||||
from app.modules.tenancy.models import User
|
|
||||||
from app.modules.tenancy.models import Vendor
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_admin_context(
|
def get_context_for_frontend(
|
||||||
|
frontend_type: FrontendType,
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: User,
|
db: Session,
|
||||||
db: Session | None = None,
|
**extra_context: Any,
|
||||||
**extra_context,
|
) -> dict[str, Any]:
|
||||||
) -> dict:
|
|
||||||
"""
|
"""
|
||||||
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:
|
Args:
|
||||||
request: FastAPI request object
|
frontend_type: The frontend type (PLATFORM, ADMIN, VENDOR, STOREFRONT)
|
||||||
current_user: Authenticated admin user
|
request: FastAPI Request object
|
||||||
db: Optional database session
|
db: Database session
|
||||||
**extra_context: Additional variables for template
|
**extra_context: Additional context variables to include
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with request, user, and extra context
|
Dict of context variables for template rendering
|
||||||
"""
|
|
||||||
context = {
|
|
||||||
"request": request,
|
|
||||||
"user": current_user,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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:
|
if extra_context:
|
||||||
context.update(extra_context)
|
context.update(extra_context)
|
||||||
|
|
||||||
return 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(
|
def get_vendor_context(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session,
|
db: Session,
|
||||||
current_user: User,
|
current_user: Any,
|
||||||
vendor_code: str,
|
vendor_code: str,
|
||||||
**extra_context,
|
**extra_context: Any,
|
||||||
) -> dict:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Build template context for vendor dashboard pages.
|
Build 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
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: FastAPI request object
|
request: FastAPI Request object
|
||||||
db: Database session
|
db: Database session
|
||||||
current_user: Authenticated vendor user
|
current_user: Authenticated vendor user
|
||||||
vendor_code: Vendor subdomain/code
|
vendor_code: Vendor subdomain/code
|
||||||
**extra_context: Additional variables for template
|
**extra_context: Additional variables for template
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with request, user, vendor, resolved locale/currency, and extra context
|
Context dict for vendor pages
|
||||||
"""
|
"""
|
||||||
# Load vendor from database
|
return get_context_for_frontend(
|
||||||
vendor = db.query(Vendor).filter(Vendor.subdomain == vendor_code).first()
|
FrontendType.VENDOR,
|
||||||
|
request,
|
||||||
# Get platform defaults
|
db,
|
||||||
platform_config = platform_settings_service.get_storefront_config(db)
|
user=current_user,
|
||||||
|
vendor_code=vendor_code,
|
||||||
# Resolve with vendor override
|
**extra_context,
|
||||||
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 context
|
|
||||||
|
|
||||||
|
|
||||||
def get_storefront_context(
|
def get_storefront_context(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session | None = None,
|
db: Session | None = None,
|
||||||
**extra_context,
|
**extra_context: Any,
|
||||||
) -> dict:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Build template context for storefront (customer shop) pages.
|
Build context for storefront (customer shop) pages.
|
||||||
|
|
||||||
Automatically includes vendor and theme from middleware request.state.
|
|
||||||
Additional context can be passed as keyword arguments.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: FastAPI request object with vendor/theme in state
|
request: FastAPI Request object with vendor/theme in state
|
||||||
db: Optional database session for loading navigation pages
|
db: Optional database session
|
||||||
**extra_context: Additional variables for template (user, product_id, etc.)
|
**extra_context: Additional variables for template
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with request, vendor, theme, navigation pages, and extra context
|
Context dict for storefront pages
|
||||||
"""
|
"""
|
||||||
# Import here to avoid circular imports
|
# Extract vendor and theme from middleware state
|
||||||
from app.modules.cms.services import content_page_service
|
|
||||||
|
|
||||||
# Extract from middleware state
|
|
||||||
vendor = getattr(request.state, "vendor", None)
|
vendor = getattr(request.state, "vendor", None)
|
||||||
platform = getattr(request.state, "platform", None)
|
|
||||||
theme = getattr(request.state, "theme", None)
|
theme = getattr(request.state, "theme", None)
|
||||||
clean_path = getattr(request.state, "clean_path", request.url.path)
|
clean_path = getattr(request.state, "clean_path", request.url.path)
|
||||||
vendor_context = getattr(request.state, "vendor_context", None)
|
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
|
# Get detection method from vendor_context
|
||||||
access_method = (
|
access_method = (
|
||||||
vendor_context.get("detection_method", "unknown")
|
vendor_context.get("detection_method", "unknown")
|
||||||
@@ -156,22 +286,9 @@ def get_storefront_context(
|
|||||||
else "unknown"
|
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
|
# Calculate base URL for links
|
||||||
# - Domain/subdomain access: base_url = "/"
|
|
||||||
# - Path-based access: base_url = "/vendor/{vendor_code}/" or "/vendors/{vendor_code}/"
|
|
||||||
base_url = "/"
|
base_url = "/"
|
||||||
if access_method == "path" and vendor:
|
if access_method == "path" and vendor:
|
||||||
# Use the full_prefix from vendor_context to determine which pattern was used
|
|
||||||
full_prefix = (
|
full_prefix = (
|
||||||
vendor_context.get("full_prefix", "/vendor/")
|
vendor_context.get("full_prefix", "/vendor/")
|
||||||
if vendor_context
|
if vendor_context
|
||||||
@@ -179,150 +296,31 @@ def get_storefront_context(
|
|||||||
)
|
)
|
||||||
base_url = f"{full_prefix}{vendor.subdomain}/"
|
base_url = f"{full_prefix}{vendor.subdomain}/"
|
||||||
|
|
||||||
# Load footer and header navigation pages from CMS if db session provided
|
# Build storefront-specific base context
|
||||||
footer_pages = []
|
storefront_base = {
|
||||||
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,
|
|
||||||
"vendor": vendor,
|
"vendor": vendor,
|
||||||
"theme": theme,
|
"theme": theme,
|
||||||
"clean_path": clean_path,
|
"clean_path": clean_path,
|
||||||
"access_method": access_method,
|
"access_method": access_method,
|
||||||
"base_url": base_url,
|
"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 no db session, return just the base context
|
||||||
if extra_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)
|
context.update(extra_context)
|
||||||
|
return context
|
||||||
|
|
||||||
logger.debug(
|
# Full context with module contributions
|
||||||
"[STOREFRONT_CONTEXT] Context built",
|
return get_context_for_frontend(
|
||||||
extra={
|
FrontendType.STOREFRONT,
|
||||||
"vendor_id": vendor.id if vendor else None,
|
request,
|
||||||
"vendor_name": vendor.name if vendor else None,
|
db,
|
||||||
"vendor_subdomain": vendor.subdomain if vendor else None,
|
**storefront_base,
|
||||||
"has_theme": theme is not None,
|
**extra_context,
|
||||||
"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 [],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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 (
|
from app.modules.marketplace.models.marketplace_product import (
|
||||||
MarketplaceProduct,
|
MarketplaceProduct,
|
||||||
ProductType,
|
ProductType,
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ This is the canonical location for tenancy module models including:
|
|||||||
- Vendor domains
|
- 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 (
|
from app.modules.tenancy.models.admin import (
|
||||||
AdminAuditLog,
|
AdminAuditLog,
|
||||||
AdminSession,
|
AdminSession,
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ analytics_module = ModuleDefinition(
|
|||||||
| `features` | `list[str]` | Feature codes for tier gating |
|
| `features` | `list[str]` | Feature codes for tier gating |
|
||||||
| `permissions` | `list[PermissionDefinition]` | RBAC permission definitions |
|
| `permissions` | `list[PermissionDefinition]` | RBAC permission definitions |
|
||||||
| `menu_items` | `dict` | Menu items per frontend type |
|
| `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_core` | `bool` | Cannot be disabled if True |
|
||||||
| `is_internal` | `bool` | Admin-only if True |
|
| `is_internal` | `bool` | Admin-only if True |
|
||||||
| `is_self_contained` | `bool` | Uses self-contained structure |
|
| `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)
|
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
|
## 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}/`.
|
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