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:
@@ -238,6 +238,7 @@ analytics_module = ModuleDefinition(
|
||||
| `features` | `list[str]` | Feature codes for tier gating |
|
||||
| `permissions` | `list[PermissionDefinition]` | RBAC permission definitions |
|
||||
| `menu_items` | `dict` | Menu items per frontend type |
|
||||
| `context_providers` | `dict[FrontendType, Callable]` | Functions that provide template context per frontend |
|
||||
| `is_core` | `bool` | Cannot be disabled if True |
|
||||
| `is_internal` | `bool` | Admin-only if True |
|
||||
| `is_self_contained` | `bool` | Uses self-contained structure |
|
||||
@@ -369,6 +370,283 @@ module_service.enable_module(db, platform_id, "billing", user_id=current_user.id
|
||||
module_service.disable_module(db, platform_id, "billing", user_id=current_user.id)
|
||||
```
|
||||
|
||||
## Context Providers (Module-Driven Page Context)
|
||||
|
||||
**Context providers** enable modules to dynamically contribute template context variables without hardcoding module imports. This is a core architectural pattern that ensures the platform remains modular and extensible.
|
||||
|
||||
### Problem Solved
|
||||
|
||||
Without context providers, the platform would need hardcoded imports like:
|
||||
|
||||
```python
|
||||
# BAD: Hardcoded module imports
|
||||
from app.modules.billing.models import TIER_LIMITS # What if billing is disabled?
|
||||
from app.modules.cms.services import content_page_service # What if cms is disabled?
|
||||
```
|
||||
|
||||
This breaks when modules are disabled and creates tight coupling.
|
||||
|
||||
### Solution: Module-Driven Context
|
||||
|
||||
Each module can register **context provider functions** in its `definition.py`. The framework automatically calls providers for enabled modules only.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Page Request (e.g., /pricing) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ get_context_for_frontend(FrontendType.PLATFORM) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────┼───────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||
│ CMS Module │ │Billing Module │ │ Other Module │
|
||||
│ (enabled) │ │ (enabled) │ │ (disabled) │
|
||||
└───────┬───────┘ └───────┬───────┘ └───────────────┘
|
||||
│ │ │
|
||||
▼ ▼ × (skipped)
|
||||
┌───────────────┐ ┌───────────────┐
|
||||
│ header_pages │ │ tiers │
|
||||
│ footer_pages │ │ trial_days │
|
||||
└───────────────┘ └───────────────┘
|
||||
│ │
|
||||
└───────────┬───────────┘
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ Merged Context Dict │
|
||||
│ {header_pages, tiers, ...} │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Frontend Types
|
||||
|
||||
Context providers are registered per frontend type:
|
||||
|
||||
| Frontend Type | Description | Use Case |
|
||||
|--------------|-------------|----------|
|
||||
| `PLATFORM` | Marketing/public pages | Homepage, pricing, signup |
|
||||
| `ADMIN` | Platform admin dashboard | Admin user management, platform settings |
|
||||
| `VENDOR` | Vendor/merchant dashboard | Store settings, product management |
|
||||
| `STOREFRONT` | Customer-facing shop | Product browsing, cart, checkout |
|
||||
|
||||
### Registering a Context Provider
|
||||
|
||||
In your module's `definition.py`:
|
||||
|
||||
```python
|
||||
# app/modules/billing/definition.py
|
||||
from typing import Any
|
||||
from app.modules.base import ModuleDefinition
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Provide billing context for platform/marketing pages.
|
||||
|
||||
This function is ONLY called when billing module is enabled.
|
||||
Imports are done inside to avoid loading when module is disabled.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.modules.billing.models import TIER_LIMITS, TierCode
|
||||
|
||||
tiers = []
|
||||
for tier_code, limits in TIER_LIMITS.items():
|
||||
tiers.append({
|
||||
"code": tier_code.value,
|
||||
"name": limits["name"],
|
||||
"price_monthly": limits["price_monthly_cents"] / 100,
|
||||
# ... more tier data
|
||||
})
|
||||
|
||||
return {
|
||||
"tiers": tiers,
|
||||
"trial_days": settings.stripe_trial_days,
|
||||
"stripe_publishable_key": settings.stripe_publishable_key,
|
||||
}
|
||||
|
||||
billing_module = ModuleDefinition(
|
||||
code="billing",
|
||||
name="Billing & Subscriptions",
|
||||
# ... other fields ...
|
||||
|
||||
# Register context providers
|
||||
context_providers={
|
||||
FrontendType.PLATFORM: _get_platform_context,
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### Context Provider Signature
|
||||
|
||||
All context providers must follow this signature:
|
||||
|
||||
```python
|
||||
def provider_function(
|
||||
request: Any, # FastAPI Request object
|
||||
db: Any, # SQLAlchemy Session
|
||||
platform: Any, # Platform model (may be None)
|
||||
) -> dict[str, Any]:
|
||||
"""Return a dict of context variables to merge into the template context."""
|
||||
return {"key": "value"}
|
||||
```
|
||||
|
||||
### Using Context in Routes
|
||||
|
||||
Route handlers use convenience functions to build context:
|
||||
|
||||
```python
|
||||
# app/modules/cms/routes/pages/platform.py
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from app.modules.core.utils import get_platform_context
|
||||
from app.api.deps import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/pricing")
|
||||
async def pricing_page(request: Request, db = Depends(get_db)):
|
||||
# Context automatically includes contributions from all enabled modules
|
||||
context = get_platform_context(request, db, page_title="Pricing")
|
||||
|
||||
# If billing module is enabled, context includes:
|
||||
# - tiers, trial_days, stripe_publishable_key
|
||||
# If CMS module is enabled, context includes:
|
||||
# - header_pages, footer_pages
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="platform/pricing.html",
|
||||
context=context,
|
||||
)
|
||||
```
|
||||
|
||||
### Available Context Functions
|
||||
|
||||
Import from `app.modules.core.utils`:
|
||||
|
||||
```python
|
||||
from app.modules.core.utils import (
|
||||
get_context_for_frontend, # Generic - specify FrontendType
|
||||
get_platform_context, # For PLATFORM pages
|
||||
get_admin_context, # For ADMIN pages
|
||||
get_vendor_context, # For VENDOR pages
|
||||
get_storefront_context, # For STOREFRONT pages
|
||||
)
|
||||
```
|
||||
|
||||
### Base Context (Always Available)
|
||||
|
||||
Every context includes these base variables regardless of modules:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `request` | FastAPI Request object |
|
||||
| `platform` | Platform model (may be None) |
|
||||
| `platform_name` | From settings.project_name |
|
||||
| `platform_domain` | From settings.platform_domain |
|
||||
| `_` | Translation function (gettext style) |
|
||||
| `t` | Translation function (key-value style) |
|
||||
| `current_language` | Current language code |
|
||||
| `SUPPORTED_LANGUAGES` | List of available languages |
|
||||
|
||||
### Example: CMS Module Context Provider
|
||||
|
||||
```python
|
||||
# app/modules/cms/definition.py
|
||||
def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any]:
|
||||
"""Provide CMS context for platform/marketing pages."""
|
||||
from app.modules.cms.services import content_page_service
|
||||
|
||||
platform_id = platform.id if platform else 1
|
||||
|
||||
header_pages = content_page_service.list_platform_pages(
|
||||
db, platform_id=platform_id, header_only=True, include_unpublished=False
|
||||
)
|
||||
footer_pages = content_page_service.list_platform_pages(
|
||||
db, platform_id=platform_id, footer_only=True, include_unpublished=False
|
||||
)
|
||||
|
||||
return {
|
||||
"header_pages": header_pages,
|
||||
"footer_pages": footer_pages,
|
||||
"legal_pages": [],
|
||||
}
|
||||
|
||||
def _get_storefront_context(request: Any, db: Any, platform: Any) -> dict[str, Any]:
|
||||
"""Provide CMS context for storefront (customer shop) pages."""
|
||||
from app.modules.cms.services import content_page_service
|
||||
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if not vendor:
|
||||
return {"header_pages": [], "footer_pages": []}
|
||||
|
||||
header_pages = content_page_service.list_pages_for_vendor(
|
||||
db, platform_id=platform.id, vendor_id=vendor.id, header_only=True
|
||||
)
|
||||
footer_pages = content_page_service.list_pages_for_vendor(
|
||||
db, platform_id=platform.id, vendor_id=vendor.id, footer_only=True
|
||||
)
|
||||
|
||||
return {"header_pages": header_pages, "footer_pages": footer_pages}
|
||||
|
||||
cms_module = ModuleDefinition(
|
||||
code="cms",
|
||||
# ... other fields ...
|
||||
context_providers={
|
||||
FrontendType.PLATFORM: _get_platform_context,
|
||||
FrontendType.STOREFRONT: _get_storefront_context,
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### How Modules Are Selected
|
||||
|
||||
The context builder determines which modules to query:
|
||||
|
||||
1. **With platform**: Queries `PlatformModule` table for enabled modules
|
||||
2. **Without platform**: Only includes core modules (`is_core=True`)
|
||||
|
||||
```python
|
||||
# Simplified logic from get_context_for_frontend()
|
||||
if platform:
|
||||
enabled_module_codes = module_service.get_enabled_module_codes(db, platform.id)
|
||||
else:
|
||||
# No platform context - only core modules
|
||||
enabled_module_codes = {
|
||||
code for code, module in MODULES.items() if module.is_core
|
||||
}
|
||||
|
||||
for code in enabled_module_codes:
|
||||
module = MODULES.get(code)
|
||||
if module and module.has_context_provider(frontend_type):
|
||||
contribution = module.get_context_contribution(frontend_type, request, db, platform)
|
||||
context.update(contribution)
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Context providers are wrapped in try/except to prevent one module from breaking the entire page:
|
||||
|
||||
```python
|
||||
try:
|
||||
contribution = module.get_context_contribution(...)
|
||||
if contribution:
|
||||
context.update(contribution)
|
||||
except Exception as e:
|
||||
logger.warning(f"[CONTEXT] Module '{code}' context provider failed: {e}")
|
||||
# Continue with other modules - page still renders
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Zero coupling**: Adding/removing modules requires no changes to route handlers
|
||||
2. **Lazy loading**: Module code only imported when that module is enabled
|
||||
3. **Per-platform customization**: Each platform loads only what it needs
|
||||
4. **Graceful degradation**: One failing module doesn't break the entire page
|
||||
5. **Testability**: Providers are pure functions that can be unit tested
|
||||
|
||||
## Module Static Files
|
||||
|
||||
Each module can have its own static assets (JavaScript, CSS, images) in the `static/` directory. These are automatically mounted at `/static/modules/{module_name}/`.
|
||||
|
||||
Reference in New Issue
Block a user