diff --git a/app/modules/base.py b/app/modules/base.py index 93cb2b1a..0585ebd0 100644 --- a/app/modules/base.py +++ b/app/modules/base.py @@ -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 # ========================================================================= diff --git a/app/modules/billing/definition.py b/app/modules/billing/definition.py index 48df80f9..8f515deb 100644 --- a/app/modules/billing/definition.py +++ b/app/modules/billing/definition.py @@ -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 # ========================================================================= diff --git a/app/modules/cms/definition.py b/app/modules/cms/definition.py index 50e94e84..9277bd1d 100644 --- a/app/modules/cms/definition.py +++ b/app/modules/cms/definition.py @@ -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", diff --git a/app/modules/core/utils/__init__.py b/app/modules/core/utils/__init__.py index 89860b0b..851f06ca 100644 --- a/app/modules/core/utils/__init__.py +++ b/app/modules/core/utils/__init__.py @@ -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", diff --git a/app/modules/core/utils/page_context.py b/app/modules/core/utils/page_context.py index 7d67f06d..9f109271 100644 --- a/app/modules/core/utils/page_context.py +++ b/app/modules/core/utils/page_context.py @@ -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 diff --git a/app/modules/marketplace/models/__init__.py b/app/modules/marketplace/models/__init__.py index 7162b1a7..af05a6d4 100644 --- a/app/modules/marketplace/models/__init__.py +++ b/app/modules/marketplace/models/__init__.py @@ -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, diff --git a/app/modules/tenancy/models/__init__.py b/app/modules/tenancy/models/__init__.py index 7d2675dd..233cd9cf 100644 --- a/app/modules/tenancy/models/__init__.py +++ b/app/modules/tenancy/models/__init__.py @@ -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, diff --git a/docs/architecture/module-system.md b/docs/architecture/module-system.md index dcc8aba8..822d21af 100644 --- a/docs/architecture/module-system.md +++ b/docs/architecture/module-system.md @@ -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}/`.