feat: implement module-driven context providers for dynamic page context

Introduces a module-driven context provider system that allows modules to
dynamically contribute template context variables without hardcoding imports.

Key changes:
- Add context_providers field to ModuleDefinition in app/modules/base.py
- Create unified get_context_for_frontend() that queries enabled modules only
- Add context providers to CMS module (PLATFORM, STOREFRONT)
- Add context providers to billing module (PLATFORM)
- Fix SQLAlchemy cross-module relationship resolution (Order, AdminMenuConfig,
  MarketplaceImportJob) by ensuring models are imported before referencing
- Document the entire system in docs/architecture/module-system.md

Benefits:
- Zero coupling: adding/removing modules requires no route handler changes
- Lazy loading: module code only imported when that module is enabled
- Per-platform customization: each platform loads only what it needs
- Graceful degradation: one failing module doesn't break entire page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 19:22:52 +01:00
parent fb8cb14506
commit b03406db45
8 changed files with 746 additions and 241 deletions

View File

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