feat: add module config and migrations auto-discovery infrastructure

Add self-contained configuration and migrations support for modules:

Config auto-discovery (app/modules/config.py):
- Modules can have config.py with Pydantic Settings
- Environment variables prefixed with MODULE_NAME_
- Auto-discovered via get_module_config()

Migrations auto-discovery:
- Each module has migrations/versions/ directory
- Alembic discovers module migrations automatically
- Naming convention: {module}_{seq}_{description}.py

New architecture rules (MOD-013 to MOD-015):
- MOD-013: config.py should export config/config_class
- MOD-014: Migrations must follow naming convention
- MOD-015: Migrations directory must have __init__.py

Created for all 11 self-contained modules:
- config.py placeholder files
- migrations/ directories with __init__.py files

Added core and tenancy module definitions for completeness.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 22:19:41 +01:00
parent eb47daec8b
commit 2466dfd7ed
43 changed files with 2338 additions and 581 deletions

View File

@@ -1,6 +1,13 @@
# Module System Architecture
The Wizamart platform uses a modular architecture that allows features to be enabled or disabled per platform. This document explains the module system, its classification tiers, and how modules interact with the rest of the application.
The Wizamart platform uses a **plug-and-play modular architecture** where modules are fully self-contained and automatically discovered. Simply create a module directory with the required structure, and the framework handles registration, routing, and resource loading automatically.
## Key Features
- **Auto-Discovery**: Modules are automatically discovered from `app/modules/*/definition.py`
- **Zero Configuration**: No changes to `main.py`, `registry.py`, or other framework files needed
- **Self-Contained**: Each module owns its routes, services, models, templates, and translations
- **Hot-Pluggable**: Add or remove modules by simply adding/removing directories
## Overview
@@ -14,7 +21,7 @@ The Wizamart platform uses a modular architecture that allows features to be ena
┌─────────────────────────────────────────────────────────────────────┐
MODULE LAYER
AUTO-DISCOVERED MODULE LAYER │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ CORE MODULES (Always Enabled) │ │
@@ -33,6 +40,36 @@ The Wizamart platform uses a modular architecture that allows features to be ena
└─────────────────────────────────────────────────────────────────────┘
```
## Auto-Discovery System
All module components are automatically discovered by the framework:
| Component | Discovery Location | Auto-Loaded By |
|-----------|-------------------|----------------|
| **Registry** | `*/definition.py` | `app/modules/discovery.py` |
| **Configuration** | `*/config.py` | `app/modules/config.py` |
| **API Routes** | `*/routes/api/*.py` | `app/modules/routes.py` |
| **Page Routes** | `*/routes/pages/*.py` | `app/modules/routes.py` |
| **Tasks** | `*/tasks/__init__.py` | `app/modules/tasks.py` |
| **Templates** | `*/templates/` | `app/templates_config.py` |
| **Static Files** | `*/static/` | `main.py` |
| **Locales** | `*/locales/*.json` | `app/utils/i18n.py` |
| **Migrations** | `*/migrations/versions/` | `app/modules/migrations.py` |
### Creating a New Module (Zero Framework Changes)
```bash
# 1. Create module directory
mkdir -p app/modules/mymodule/{routes/{api,pages},services,models,schemas,templates/mymodule/vendor,static/vendor/js,locales,tasks}
# 2. Create required files
touch app/modules/mymodule/__init__.py
touch app/modules/mymodule/definition.py
touch app/modules/mymodule/exceptions.py
# 3. That's it! The framework auto-discovers and registers everything.
```
## Three-Tier Classification
### Core Modules (4)
@@ -69,6 +106,160 @@ Internal modules are **admin-only tools** not exposed to customers or vendors.
| `dev-tools` | Component library, icon browser |
| `monitoring` | Logs, background tasks, Flower, Grafana integration |
## Self-Contained Module Structure
Every module follows this standardized structure:
```
app/modules/analytics/
├── __init__.py # Module package marker
├── definition.py # ModuleDefinition (REQUIRED for auto-discovery)
├── config.py # Environment config (auto-discovered)
├── exceptions.py # Module-specific exceptions
├── routes/
│ ├── __init__.py
│ ├── api/ # API endpoints (auto-discovered)
│ │ ├── __init__.py
│ │ ├── admin.py # Must export: router = APIRouter()
│ │ └── vendor.py # Must export: router = APIRouter()
│ └── pages/ # HTML page routes (auto-discovered)
│ ├── __init__.py
│ └── vendor.py # Must export: router = APIRouter()
├── services/
│ ├── __init__.py
│ └── stats_service.py
├── models/
│ ├── __init__.py
│ └── report.py
├── schemas/
│ ├── __init__.py
│ └── stats.py
├── templates/ # Auto-discovered by Jinja2
│ └── analytics/
│ └── vendor/
│ └── analytics.html
├── static/ # Auto-mounted at /static/modules/analytics/
│ └── vendor/
│ └── js/
│ └── analytics.js
├── locales/ # Auto-loaded translations
│ ├── en.json
│ ├── de.json
│ ├── fr.json
│ └── lu.json
├── tasks/ # Auto-discovered by Celery
│ ├── __init__.py # REQUIRED for Celery discovery
│ └── reports.py
└── migrations/ # Auto-discovered by Alembic
├── __init__.py # REQUIRED for discovery
└── versions/
├── __init__.py # REQUIRED for discovery
└── analytics_001_create_reports.py
```
## Module Definition
Each module must have a `definition.py` with a `ModuleDefinition` instance:
```python
# app/modules/analytics/definition.py
from app.modules.base import ModuleDefinition
from models.database.admin_menu_config import FrontendType
analytics_module = ModuleDefinition(
# Identity
code="analytics",
name="Analytics & Reporting",
description="Dashboard analytics, custom reports, and data exports.",
version="1.0.0",
# Classification (determines tier)
is_core=False, # Set True for core modules
is_internal=False, # Set True for admin-only modules
# Dependencies
requires=[], # List other module codes this depends on
# Features (for tier-based gating)
features=[
"basic_reports",
"analytics_dashboard",
"custom_reports",
],
# Menu items per frontend
menu_items={
FrontendType.ADMIN: [], # Analytics uses dashboard
FrontendType.VENDOR: ["analytics"],
},
# Self-contained module configuration
is_self_contained=True,
services_path="app.modules.analytics.services",
models_path="app.modules.analytics.models",
schemas_path="app.modules.analytics.schemas",
exceptions_path="app.modules.analytics.exceptions",
templates_path="templates",
locales_path="locales",
)
```
### ModuleDefinition Fields
| Field | Type | Description |
|-------|------|-------------|
| `code` | `str` | Unique identifier (e.g., "billing") |
| `name` | `str` | Display name |
| `description` | `str` | What the module provides |
| `version` | `str` | Semantic version (default: "1.0.0") |
| `requires` | `list[str]` | Module codes this depends on |
| `features` | `list[str]` | Feature codes for tier gating |
| `menu_items` | `dict` | Menu items per frontend type |
| `is_core` | `bool` | Cannot be disabled if True |
| `is_internal` | `bool` | Admin-only if True |
| `is_self_contained` | `bool` | Uses self-contained structure |
## Route Auto-Discovery
Routes in `routes/api/` and `routes/pages/` are automatically discovered and registered.
### API Routes (`routes/api/vendor.py`)
```python
# app/modules/analytics/routes/api/vendor.py
from fastapi import APIRouter, Depends
from app.api.deps import get_current_vendor_api, get_db
router = APIRouter() # MUST be named 'router' for auto-discovery
@router.get("")
def get_analytics(
current_user = Depends(get_current_vendor_api),
db = Depends(get_db),
):
"""Get vendor analytics."""
pass
```
**Auto-registered at:** `/api/v1/vendor/analytics`
### Page Routes (`routes/pages/vendor.py`)
```python
# app/modules/analytics/routes/pages/vendor.py
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
router = APIRouter() # MUST be named 'router' for auto-discovery
@router.get("/{vendor_code}/analytics", response_class=HTMLResponse)
async def analytics_page(request: Request, vendor_code: str):
"""Render analytics page."""
pass
```
**Auto-registered at:** `/vendor/{vendor_code}/analytics`
## Framework Layer
The Framework Layer provides infrastructure that modules depend on. These are **not modules** - they're always available and cannot be disabled.
@@ -87,74 +278,6 @@ The Framework Layer provides infrastructure that modules depend on. These are **
| Dependencies | `app/api/deps.py` | FastAPI DI |
| Base Exceptions | `app/exceptions/base.py` | Exception hierarchy |
## Module Definition
Each module is defined using the `ModuleDefinition` dataclass:
```python
from app.modules.base import ModuleDefinition
from models.database.admin_menu_config import FrontendType
billing_module = ModuleDefinition(
# Identity
code="billing",
name="Billing & Subscriptions",
description="Platform subscriptions and vendor invoices",
version="1.0.0",
# Dependencies
requires=["payments"], # Must have payments enabled
# Features (for tier-based gating)
features=[
"subscription_management",
"billing_history",
"invoice_generation",
],
# Menu items per frontend
menu_items={
FrontendType.ADMIN: ["subscription-tiers", "subscriptions"],
FrontendType.VENDOR: ["billing", "invoices"],
},
# Classification
is_core=False,
is_internal=False,
# Configuration schema (optional)
config_schema=BillingConfig,
default_config={"trial_days": 14},
# Lifecycle hooks (optional)
on_enable=lambda platform_id: setup_billing(platform_id),
on_disable=lambda platform_id: cleanup_billing(platform_id),
health_check=lambda: {"status": "healthy"},
)
```
### ModuleDefinition Fields
| Field | Type | Description |
|-------|------|-------------|
| `code` | `str` | Unique identifier (e.g., "billing") |
| `name` | `str` | Display name |
| `description` | `str` | What the module provides |
| `version` | `str` | Semantic version (default: "1.0.0") |
| `requires` | `list[str]` | Module codes this depends on |
| `features` | `list[str]` | Feature codes for tier gating |
| `menu_items` | `dict` | Menu items per frontend type |
| `permissions` | `list[str]` | Permission codes defined by module |
| `is_core` | `bool` | Cannot be disabled if True |
| `is_internal` | `bool` | Admin-only if True |
| `config_schema` | `type[BaseModel]` | Pydantic model for configuration |
| `default_config` | `dict` | Default configuration values |
| `on_enable` | `Callable` | Called when module is enabled |
| `on_disable` | `Callable` | Called when module is disabled |
| `on_startup` | `Callable` | Called on application startup |
| `health_check` | `Callable` | Returns health status dict |
| `migrations_path` | `str` | Path to module migrations (relative) |
## Module Dependencies
Modules can depend on other modules. When enabling a module, its dependencies are automatically enabled.
@@ -178,18 +301,17 @@ Modules can depend on other modules. When enabling a module, its dependencies ar
## Module Registry
All modules are registered in `app/modules/registry.py`:
The registry auto-discovers all modules:
```python
from app.modules.registry import (
MODULES, # All modules
MODULES, # All modules (auto-discovered)
CORE_MODULES, # Core only
OPTIONAL_MODULES, # Optional only
INTERNAL_MODULES, # Internal only
get_module,
get_core_module_codes,
get_module_tier,
is_core_module,
)
# Get a specific module
@@ -211,7 +333,6 @@ from app.modules.service import module_service
# Check if module is enabled
if module_service.is_module_enabled(db, platform_id, "billing"):
# Module is enabled for this platform
pass
# Get all enabled modules for a platform
@@ -222,178 +343,144 @@ module_service.enable_module(db, platform_id, "billing", user_id=current_user.id
# Disable a module (auto-disables dependents)
module_service.disable_module(db, platform_id, "billing", user_id=current_user.id)
# Get module configuration
config = module_service.get_module_config(db, platform_id, "billing")
# Set module configuration
module_service.set_module_config(db, platform_id, "billing", {"trial_days": 30})
```
## Module Events
## Module Configuration
The event system allows components to react to module lifecycle changes:
Modules can have environment-based configuration using Pydantic Settings. The `config.py` file is auto-discovered by `app/modules/config.py`.
```python
from app.modules.events import module_event_bus, ModuleEvent, ModuleEventData
# app/modules/marketplace/config.py
from pydantic import Field
from pydantic_settings import BaseSettings
# Subscribe to events
@module_event_bus.subscribe(ModuleEvent.ENABLED)
def on_module_enabled(data: ModuleEventData):
print(f"Module {data.module_code} enabled for platform {data.platform_id}")
class MarketplaceConfig(BaseSettings):
"""Configuration for marketplace module."""
@module_event_bus.subscribe(ModuleEvent.DISABLED)
def on_module_disabled(data: ModuleEventData):
clear_module_cache(data.platform_id, data.module_code)
# Settings loaded from env vars with MARKETPLACE_ prefix
api_timeout: int = Field(default=30, description="API timeout in seconds")
batch_size: int = Field(default=100, description="Import batch size")
max_retries: int = Field(default=3, description="Max retry attempts")
@module_event_bus.subscribe(ModuleEvent.CONFIG_CHANGED)
def on_config_changed(data: ModuleEventData):
print(f"Config changed: {data.config}")
model_config = {"env_prefix": "MARKETPLACE_"}
# Events are emitted by ModuleService automatically
# You can also emit manually:
module_event_bus.emit_enabled("billing", platform_id=1, user_id=42)
# Export for auto-discovery
config_class = MarketplaceConfig
config = MarketplaceConfig()
```
### Event Types
**Usage:**
| Event | When Fired | Data Available |
|-------|------------|----------------|
| `ENABLED` | Module enabled for platform | `module_code`, `platform_id`, `user_id` |
| `DISABLED` | Module disabled for platform | `module_code`, `platform_id`, `user_id` |
| `STARTUP` | Application starting | `module_code` |
| `SHUTDOWN` | Application shutting down | `module_code` |
| `CONFIG_CHANGED` | Module config updated | `module_code`, `platform_id`, `config` |
```python
# Direct import
from app.modules.marketplace.config import config
timeout = config.api_timeout
## Module-Specific Migrations
Self-contained modules can have their own database migrations:
```
app/modules/cms/
├── migrations/
│ └── versions/
│ ├── cms_001_create_content_pages.py
│ └── cms_002_add_seo_fields.py
├── models/
├── services/
└── ...
# Via discovery
from app.modules.config import get_module_config
config = get_module_config("marketplace")
```
**Migration Naming Convention:**
**Environment variables:**
```bash
MARKETPLACE_API_TIMEOUT=60
MARKETPLACE_BATCH_SIZE=500
```
## Module Migrations
Each module owns its database migrations in the `migrations/versions/` directory. Alembic auto-discovers these via `app/modules/migrations.py`.
### Migration Structure
```
app/modules/cms/migrations/
├── __init__.py # REQUIRED for discovery
└── versions/
├── __init__.py # REQUIRED for discovery
├── cms_001_create_content_pages.py
├── cms_002_add_sections.py
└── cms_003_add_media_library.py
```
### Naming Convention
```
{module_code}_{sequence}_{description}.py
```
Alembic automatically discovers module migrations:
### Migration Template
```python
# In alembic/env.py
from app.modules.migrations import get_all_migration_paths
# app/modules/cms/migrations/versions/cms_001_create_content_pages.py
"""Create content_pages table.
version_locations = [str(p) for p in get_all_migration_paths()]
Revision ID: cms_001
Create Date: 2026-01-28
"""
from alembic import op
import sqlalchemy as sa
revision = "cms_001"
down_revision = None
branch_labels = ("cms",) # Module-specific branch
def upgrade() -> None:
op.create_table(
"content_pages",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("vendor_id", sa.Integer(), sa.ForeignKey("vendors.id")),
sa.Column("slug", sa.String(100), nullable=False),
sa.Column("title", sa.String(200), nullable=False),
)
def downgrade() -> None:
op.drop_table("content_pages")
```
## Self-Contained Module Structure
### Running Migrations
Modules can be self-contained with their own services, models, and templates:
Module migrations are automatically discovered:
```
app/modules/cms/
├── __init__.py
├── definition.py # ModuleDefinition
├── config.py # Configuration schema (optional)
├── exceptions.py # Module-specific exceptions
├── routes/
│ ├── admin.py # Admin API routes
│ └── vendor.py # Vendor API routes
├── services/
│ └── content_service.py
├── models/
│ └── content_page.py
├── schemas/
│ └── content.py # Pydantic schemas
├── templates/
│ ├── admin/
│ └── vendor/
├── migrations/
│ └── versions/
└── locales/
├── en.json
└── fr.json
```bash
# Run all migrations (core + modules)
alembic upgrade head
# View migration history
alembic history
```
Configure paths in the definition:
### Current State
```python
cms_module = ModuleDefinition(
code="cms",
name="Content Management",
is_self_contained=True,
services_path="app.modules.cms.services",
models_path="app.modules.cms.models",
schemas_path="app.modules.cms.schemas",
templates_path="templates",
exceptions_path="app.modules.cms.exceptions",
locales_path="locales",
migrations_path="migrations",
)
```
Currently, all migrations reside in central `alembic/versions/`. The module-specific directories are in place for:
- **New modules**: Should create migrations in their own `migrations/versions/`
- **Future reorganization**: Existing migrations will be moved to modules pre-production
## Database Storage
## Architecture Validation Rules
Module enablement is stored in the `platform_modules` table:
The architecture validator (`scripts/validate_architecture.py`) enforces module structure:
| Column | Type | Description |
|--------|------|-------------|
| `platform_id` | FK | Platform reference |
| `module_code` | string | Module identifier |
| `is_enabled` | boolean | Whether enabled |
| `enabled_at` | timestamp | When enabled |
| `enabled_by_user_id` | FK | Who enabled it |
| `disabled_at` | timestamp | When disabled |
| `disabled_by_user_id` | FK | Who disabled it |
| `config` | JSON | Module-specific configuration |
| Rule | Severity | Description |
|------|----------|-------------|
| MOD-001 | ERROR | Self-contained modules must have required directories |
| MOD-002 | WARNING | Services must contain actual code, not re-exports |
| MOD-003 | WARNING | Schemas must contain actual code, not re-exports |
| MOD-004 | WARNING | Routes must import from module, not legacy locations |
| MOD-005 | WARNING | Modules with UI must have templates and static |
| MOD-006 | INFO | Modules should have locales for i18n |
| MOD-007 | ERROR | Definition paths must match directory structure |
| MOD-008 | WARNING | Self-contained modules must have exceptions.py |
| MOD-009 | ERROR | Modules must have definition.py for auto-discovery |
| MOD-010 | WARNING | Route files must export `router` variable |
| MOD-011 | WARNING | Tasks directory must have `__init__.py` |
| MOD-012 | INFO | Locales should have all language files |
| MOD-013 | INFO | config.py should export `config` or `config_class` |
| MOD-014 | WARNING | Migrations must follow naming convention |
| MOD-015 | WARNING | Migrations directory must have `__init__.py` files |
## Menu Item Filtering
Menu items are filtered based on enabled modules:
```python
from app.modules.service import module_service
from models.database.admin_menu_config import FrontendType
# Get available menu items for platform
menu_items = module_service.get_module_menu_items(
db, platform_id, FrontendType.ADMIN
)
# Check if specific menu item's module is enabled
is_available = module_service.is_menu_item_module_enabled(
db, platform_id, "subscription-tiers", FrontendType.ADMIN
)
```
## Health Checks
Modules can provide health checks that are aggregated:
```python
from app.core.observability import health_registry, register_module_health_checks
# Register all module health checks on startup
register_module_health_checks()
# Health endpoint aggregates results
# GET /health returns:
{
"status": "healthy",
"checks": [
{"name": "module:billing", "status": "healthy"},
{"name": "module:payments", "status": "healthy"},
...
]
}
Run validation:
```bash
python scripts/validate_architecture.py
```
## Best Practices
@@ -404,7 +491,9 @@ register_module_health_checks()
- Use `requires` for hard dependencies
- Provide `health_check` for critical modules
- Use events for cross-module communication
- Document module features and menu items
- Follow the standard directory structure
- Export `router` variable in route files
- Include all supported languages in locales
### Don't
@@ -412,12 +501,12 @@ register_module_health_checks()
- Make core modules depend on optional modules
- Put framework-level code in modules
- Skip migration naming conventions
- Forget to register menu items
- Forget `__init__.py` in tasks directory
- Manually register modules in registry.py (use auto-discovery)
## Related Documentation
- [Menu Management](menu-management.md) - Sidebar and menu configuration
- [Creating Modules](../development/creating-modules.md) - Developer guide for building modules
- [Observability](observability.md) - Health checks and module health integration
- [Multi-Tenant System](multi-tenant.md) - Platform isolation
- [Creating Modules](../development/creating-modules.md) - Step-by-step guide
- [Menu Management](menu-management.md) - Sidebar configuration
- [Observability](observability.md) - Health checks integration
- [Feature Gating](../implementation/feature-gating-system.md) - Tier-based access

View File

@@ -1,305 +1,437 @@
# Creating Modules
This guide explains how to create new modules for the Wizamart platform, including both simple wrapper modules and self-contained modules with their own services, models, and migrations.
This guide explains how to create new **plug-and-play modules** for the Wizamart platform. Modules are fully self-contained and automatically discovered - no changes to framework files required.
## Module Types
## Quick Start (5 Minutes)
| Type | Complexity | Use Case |
|------|------------|----------|
| **Wrapper Module** | Simple | Groups existing routes and features under a toggleable module |
| **Self-Contained Module** | Complex | Full isolation with own services, models, templates, migrations |
Creating a new module requires **zero changes** to `main.py`, `registry.py`, or any other framework file.
## Quick Start: Wrapper Module
### Step 1: Create Directory Structure
For a simple module that wraps existing functionality:
```bash
# Create module with all directories
MODULE_NAME=mymodule
mkdir -p app/modules/$MODULE_NAME/{routes/{api,pages},services,models,schemas,templates/$MODULE_NAME/vendor,static/vendor/js,locales,tasks}
# Create required files
touch app/modules/$MODULE_NAME/__init__.py
touch app/modules/$MODULE_NAME/definition.py
touch app/modules/$MODULE_NAME/exceptions.py
touch app/modules/$MODULE_NAME/routes/__init__.py
touch app/modules/$MODULE_NAME/routes/api/__init__.py
touch app/modules/$MODULE_NAME/routes/pages/__init__.py
touch app/modules/$MODULE_NAME/services/__init__.py
touch app/modules/$MODULE_NAME/models/__init__.py
touch app/modules/$MODULE_NAME/schemas/__init__.py
touch app/modules/$MODULE_NAME/tasks/__init__.py
```
### Step 2: Create Module Definition
```python
# app/modules/analytics/definition.py
# app/modules/mymodule/definition.py
from app.modules.base import ModuleDefinition
from models.database.admin_menu_config import FrontendType
analytics_module = ModuleDefinition(
code="analytics",
name="Analytics & Reports",
description="Business analytics, reports, and dashboards",
mymodule_module = ModuleDefinition(
code="mymodule",
name="My Module",
description="Description of what this module does",
version="1.0.0",
features=[
"analytics_dashboard",
"sales_reports",
"customer_insights",
],
# Classification
is_core=False, # True = always enabled
is_internal=False, # True = admin-only
is_self_contained=True,
# Features for tier-based gating
features=["mymodule_feature"],
# Menu items
menu_items={
FrontendType.ADMIN: ["analytics-dashboard", "reports"],
FrontendType.VENDOR: ["analytics", "sales-reports"],
FrontendType.VENDOR: ["mymodule"],
},
is_core=False,
is_internal=False,
# Paths (for self-contained modules)
services_path="app.modules.mymodule.services",
models_path="app.modules.mymodule.models",
schemas_path="app.modules.mymodule.schemas",
exceptions_path="app.modules.mymodule.exceptions",
templates_path="templates",
locales_path="locales",
)
```
## Module Definition Fields
### Step 3: Create Routes (Auto-Discovered)
### Required Fields
```python
# app/modules/mymodule/routes/api/vendor.py
from fastapi import APIRouter, Depends
from app.api.deps import get_current_vendor_api, get_db
| Field | Type | Description |
|-------|------|-------------|
| `code` | str | Unique identifier (e.g., "billing", "analytics") |
| `name` | str | Display name (e.g., "Billing & Subscriptions") |
router = APIRouter() # MUST be named 'router'
### Optional Fields
@router.get("")
def get_mymodule_data(current_user=Depends(get_current_vendor_api)):
return {"message": "Hello from mymodule"}
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `description` | str | "" | What the module provides |
| `version` | str | "1.0.0" | Semantic version |
| `requires` | list[str] | [] | Module codes this depends on |
| `features` | list[str] | [] | Feature codes for tier gating |
| `menu_items` | dict | {} | Menu items per frontend type |
| `permissions` | list[str] | [] | Permission codes defined |
| `is_core` | bool | False | Cannot be disabled if True |
| `is_internal` | bool | False | Admin-only if True |
```python
# app/modules/mymodule/routes/pages/vendor.py
from fastapi import APIRouter, Request, Path
from fastapi.responses import HTMLResponse
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db, Depends
from app.templates_config import templates
### Configuration Fields
router = APIRouter() # MUST be named 'router'
| Field | Type | Description |
|-------|------|-------------|
| `config_schema` | type[BaseModel] | Pydantic model for validation |
| `default_config` | dict | Default configuration values |
@router.get("/{vendor_code}/mymodule", response_class=HTMLResponse)
async def mymodule_page(
request: Request,
vendor_code: str = Path(...),
current_user=Depends(get_current_vendor_from_cookie_or_header),
db=Depends(get_db),
):
return templates.TemplateResponse(
"mymodule/vendor/index.html",
{"request": request, "vendor_code": vendor_code},
)
```
### Lifecycle Hooks
### Step 4: Done!
| Field | Signature | Description |
|-------|-----------|-------------|
| `on_enable` | (platform_id: int) -> None | Called when enabled |
| `on_disable` | (platform_id: int) -> None | Called when disabled |
| `on_startup` | () -> None | Called on app startup |
| `health_check` | () -> dict | Returns health status |
That's it! The framework automatically:
- Discovers and registers the module
- Mounts API routes at `/api/v1/vendor/mymodule`
- Mounts page routes at `/vendor/{code}/mymodule`
- Loads templates from `templates/`
- Mounts static files at `/static/modules/mymodule/`
- Loads translations from `locales/`
## Complete Module Structure
```
app/modules/mymodule/
├── __init__.py # Package marker
├── definition.py # ModuleDefinition (REQUIRED)
├── config.py # Environment config (optional, auto-discovered)
├── exceptions.py # Module exceptions
├── routes/ # Auto-discovered routes
│ ├── __init__.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── admin.py # /api/v1/admin/mymodule
│ │ └── vendor.py # /api/v1/vendor/mymodule
│ └── pages/
│ ├── __init__.py
│ ├── admin.py # /admin/mymodule
│ └── vendor.py # /vendor/{code}/mymodule
├── services/ # Business logic
│ ├── __init__.py
│ └── mymodule_service.py
├── models/ # SQLAlchemy models
│ ├── __init__.py
│ └── mymodel.py
├── schemas/ # Pydantic schemas
│ ├── __init__.py
│ └── myschema.py
├── templates/ # Jinja2 templates (auto-loaded)
│ └── mymodule/
│ ├── admin/
│ │ └── index.html
│ └── vendor/
│ └── index.html
├── static/ # Static files (auto-mounted)
│ ├── admin/
│ │ └── js/
│ │ └── mymodule.js
│ └── vendor/
│ └── js/
│ └── mymodule.js
├── locales/ # Translations (auto-loaded)
│ ├── en.json
│ ├── de.json
│ ├── fr.json
│ └── lu.json
├── tasks/ # Celery tasks (auto-discovered)
│ ├── __init__.py # REQUIRED for discovery
│ └── background_tasks.py
└── migrations/ # Alembic migrations (auto-discovered)
├── __init__.py # REQUIRED for discovery
└── versions/
├── __init__.py # REQUIRED for discovery
└── mymodule_001_initial.py
```
## Auto-Discovery Details
### What Gets Auto-Discovered
| Component | Location | Discovered By | Notes |
|-----------|----------|---------------|-------|
| Module Definition | `definition.py` | `app/modules/discovery.py` | Must export ModuleDefinition |
| Configuration | `config.py` | `app/modules/config.py` | Must export `config` or `config_class` |
| API Routes | `routes/api/*.py` | `app/modules/routes.py` | Must export `router` |
| Page Routes | `routes/pages/*.py` | `app/modules/routes.py` | Must export `router` |
| Templates | `templates/` | `app/templates_config.py` | Use namespace prefix |
| Static Files | `static/` | `main.py` | Mounted at `/static/modules/{code}/` |
| Locales | `locales/*.json` | `app/utils/i18n.py` | Keyed by module code |
| Tasks | `tasks/` | `app/modules/tasks.py` | Needs `__init__.py` |
| Migrations | `migrations/versions/` | `app/modules/migrations.py` | Use naming convention |
### Route File Requirements
Route files MUST export a `router` variable:
```python
# CORRECT - will be discovered
router = APIRouter()
@router.get("/")
def my_endpoint():
pass
# WRONG - won't be discovered
my_router = APIRouter() # Variable not named 'router'
```
### Template Namespacing
Templates must be namespaced under the module code:
```
templates/
└── mymodule/ # Module code as namespace
└── vendor/
└── index.html
```
Reference in code:
```python
templates.TemplateResponse("mymodule/vendor/index.html", {...})
```
### Static File URLs
Static files are mounted at `/static/modules/{module_code}/`:
```html
<!-- In template -->
<script src="{{ url_for('mymodule_static', path='vendor/js/mymodule.js') }}"></script>
```
### Translation Keys
Translations are namespaced under the module code:
```json
// locales/en.json
{
"mymodule": {
"title": "My Module",
"description": "Module description"
}
}
```
Use in templates:
```html
{{ _('mymodule.title') }}
```
## Module Classification
### Core Modules
Cannot be disabled. Use for essential platform functionality.
Always enabled, cannot be disabled.
```python
core_module = ModuleDefinition(
code="tenancy",
name="Platform Tenancy",
is_core=True, # Cannot be disabled
ModuleDefinition(
code="mymodule",
is_core=True,
# ...
)
```
### Optional Modules
### Optional Modules (Default)
Can be enabled/disabled per platform.
```python
billing_module = ModuleDefinition(
code="billing",
name="Billing",
is_core=False, # Can be toggled
ModuleDefinition(
code="mymodule",
is_core=False,
is_internal=False,
# ...
)
```
### Internal Modules
Admin-only tools not visible to customers/vendors.
Admin-only tools, not visible to vendors.
```python
devtools_module = ModuleDefinition(
code="dev-tools",
name="Developer Tools",
is_internal=True, # Admin-only
ModuleDefinition(
code="mymodule",
is_internal=True,
# ...
)
```
## Module Dependencies
Declare dependencies using the `requires` field:
Declare dependencies in the definition:
```python
# orders module requires payments
orders_module = ModuleDefinition(
ModuleDefinition(
code="orders",
name="Orders",
requires=["payments"], # Must have payments enabled
# ...
)
```
**Dependency Rules:**
1. Core modules cannot depend on optional modules
2. Enabling a module auto-enables its dependencies
3. Disabling a module auto-disables modules that depend on it
4. Circular dependencies are not allowed
**Rules:**
- Enabling a module auto-enables its dependencies
- Disabling a module auto-disables modules that depend on it
- Core modules cannot depend on optional modules
## Self-Contained Module Structure
For modules with their own services, models, and templates:
```
app/modules/cms/
├── __init__.py
├── definition.py # ModuleDefinition
├── config.py # Configuration schema (optional)
├── exceptions.py # Module-specific exceptions
├── routes/
│ ├── __init__.py
│ ├── admin.py # Admin API routes
│ └── vendor.py # Vendor API routes
├── services/
│ ├── __init__.py
│ └── content_service.py
├── models/
│ ├── __init__.py
│ └── content_page.py
├── schemas/
│ ├── __init__.py
│ └── content.py
├── templates/
│ ├── admin/
│ └── vendor/
├── migrations/
│ └── versions/
│ ├── cms_001_create_content_pages.py
│ └── cms_002_add_seo_fields.py
└── locales/
├── en.json
└── fr.json
```
### Self-Contained Definition
## Services Pattern
```python
# app/modules/cms/definition.py
from pydantic import BaseModel, Field
from app.modules.base import ModuleDefinition
from models.database.admin_menu_config import FrontendType
class CMSConfig(BaseModel):
"""CMS module configuration."""
max_pages_per_vendor: int = Field(default=100, ge=1, le=1000)
enable_seo: bool = True
default_language: str = "en"
cms_module = ModuleDefinition(
# Identity
code="cms",
name="Content Management",
description="Content pages, media library, and themes",
version="1.0.0",
# Classification
is_core=True,
is_self_contained=True,
# Features
features=[
"content_pages",
"media_library",
"vendor_themes",
],
# Menu items
menu_items={
FrontendType.ADMIN: ["content-pages", "vendor-themes"],
FrontendType.VENDOR: ["content-pages", "media"],
},
# Configuration
config_schema=CMSConfig,
default_config={
"max_pages_per_vendor": 100,
"enable_seo": True,
},
# Self-contained paths
services_path="app.modules.cms.services",
models_path="app.modules.cms.models",
schemas_path="app.modules.cms.schemas",
templates_path="templates",
exceptions_path="app.modules.cms.exceptions",
locales_path="locales",
migrations_path="migrations",
# Lifecycle
health_check=lambda: {"status": "healthy"},
)
```
## Creating Module Routes
### Admin Routes
```python
# app/modules/payments/routes/admin.py
from fastapi import APIRouter, Depends
# app/modules/mymodule/services/mymodule_service.py
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_admin_user
admin_router = APIRouter(prefix="/api/admin/payments", tags=["Admin - Payments"])
class MyModuleService:
def get_data(self, db: Session, vendor_id: int):
# Business logic here
pass
@admin_router.get("/gateways")
async def list_gateways(
db: Session = Depends(get_db),
current_user = Depends(get_current_admin_user),
):
"""List configured payment gateways."""
# Implementation
pass
# Singleton instance
mymodule_service = MyModuleService()
```
### Vendor Routes
```python
# app/modules/payments/routes/vendor.py
from fastapi import APIRouter, Depends
from app.api.deps import get_db, get_current_vendor_user
vendor_router = APIRouter(prefix="/api/vendor/payments", tags=["Vendor - Payments"])
@vendor_router.get("/methods")
async def list_payment_methods(
db: Session = Depends(get_db),
current_user = Depends(get_current_vendor_user),
):
"""List vendor's stored payment methods."""
pass
```
### Lazy Router Loading
To avoid circular imports, use lazy loading:
```python
# app/modules/payments/definition.py
def _get_admin_router():
"""Lazy import to avoid circular imports."""
from app.modules.payments.routes.admin import admin_router
return admin_router
def _get_vendor_router():
from app.modules.payments.routes.vendor import vendor_router
return vendor_router
payments_module = ModuleDefinition(
code="payments",
# ...
# app/modules/mymodule/services/__init__.py
from app.modules.mymodule.services.mymodule_service import (
mymodule_service,
MyModuleService,
)
def get_payments_module_with_routers():
"""Get module with routers attached."""
payments_module.admin_router = _get_admin_router()
payments_module.vendor_router = _get_vendor_router()
return payments_module
__all__ = ["mymodule_service", "MyModuleService"]
```
## Module Migrations
## Exceptions Pattern
```python
# app/modules/mymodule/exceptions.py
from app.exceptions import WizamartException
class MyModuleException(WizamartException):
"""Base exception for mymodule."""
pass
class MyModuleNotFoundError(MyModuleException):
"""Resource not found."""
def __init__(self, resource_id: int):
super().__init__(f"Resource {resource_id} not found")
```
## Configuration Pattern
Modules can have their own environment-based configuration using Pydantic Settings.
```python
# app/modules/mymodule/config.py
from pydantic import Field
from pydantic_settings import BaseSettings
class MyModuleConfig(BaseSettings):
"""Configuration for mymodule."""
# Settings are loaded from environment with MYMODULE_ prefix
api_timeout: int = Field(default=30, description="API timeout in seconds")
max_retries: int = Field(default=3, description="Maximum retry attempts")
batch_size: int = Field(default=100, description="Batch processing size")
enable_feature_x: bool = Field(default=False, description="Enable feature X")
model_config = {"env_prefix": "MYMODULE_"}
# Export for auto-discovery
config_class = MyModuleConfig
config = MyModuleConfig()
```
Access configuration in your code:
```python
# In services or routes
from app.modules.config import get_module_config
mymodule_config = get_module_config("mymodule")
timeout = mymodule_config.api_timeout
```
Or import directly:
```python
from app.modules.mymodule.config import config
timeout = config.api_timeout
```
Environment variables:
```bash
# .env
MYMODULE_API_TIMEOUT=60
MYMODULE_MAX_RETRIES=5
MYMODULE_ENABLE_FEATURE_X=true
```
## Background Tasks
```python
# app/modules/mymodule/tasks/__init__.py
from app.modules.mymodule.tasks.background import process_data
__all__ = ["process_data"]
```
```python
# app/modules/mymodule/tasks/background.py
from app.modules.task_base import DatabaseTask
from celery_config import celery_app
@celery_app.task(base=DatabaseTask, bind=True)
def process_data(self, data_id: int):
"""Process data in background."""
db = self.get_db()
# Use db session
pass
```
## Migrations
Modules can have their own migrations that are auto-discovered by Alembic.
### Directory Structure
```
app/modules/mymodule/migrations/
├── __init__.py # REQUIRED for discovery
└── versions/
├── __init__.py # REQUIRED for discovery
├── mymodule_001_initial.py
└── mymodule_002_add_feature.py
```
### Naming Convention
@@ -307,177 +439,121 @@ def get_payments_module_with_routers():
{module_code}_{sequence}_{description}.py
```
Examples:
- `cms_001_create_content_pages.py`
- `cms_002_add_seo_fields.py`
- `billing_001_create_subscriptions.py`
Example: `mymodule_001_create_tables.py`
### Creating the Migrations Directory
```bash
# Create migrations directory structure
mkdir -p app/modules/mymodule/migrations/versions
# Create required __init__.py files
echo '"""Module migrations."""' > app/modules/mymodule/migrations/__init__.py
echo '"""Migration versions."""' > app/modules/mymodule/migrations/versions/__init__.py
```
### Migration Template
```python
# app/modules/cms/migrations/versions/cms_001_create_content_pages.py
"""Create content pages table.
Revision ID: cms_001
Revises:
Create Date: 2026-01-27
# app/modules/mymodule/migrations/versions/mymodule_001_create_tables.py
"""Create mymodule tables.
Revision ID: mymodule_001
Create Date: 2026-01-28
"""
from alembic import op
import sqlalchemy as sa
revision = "cms_001"
down_revision = None # Or previous module migration
branch_labels = ("cms",)
depends_on = None
revision = "mymodule_001"
down_revision = None
branch_labels = ("mymodule",)
def upgrade() -> None:
op.create_table(
"cms_content_pages",
"mymodule_items",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("vendor_id", sa.Integer(), sa.ForeignKey("vendors.id")),
sa.Column("title", sa.String(200), nullable=False),
sa.Column("slug", sa.String(200), nullable=False),
sa.Column("content", sa.Text()),
sa.Column("created_at", sa.DateTime(timezone=True)),
sa.Column("updated_at", sa.DateTime(timezone=True)),
sa.Column("name", sa.String(200), nullable=False),
)
def downgrade() -> None:
op.drop_table("cms_content_pages")
op.drop_table("mymodule_items")
```
## Module Configuration
### Running Module Migrations
### Defining Configuration Schema
Module migrations are automatically discovered. Run all migrations:
```python
from pydantic import BaseModel, Field
class BillingConfig(BaseModel):
"""Billing module configuration."""
stripe_mode: Literal["test", "live"] = "test"
trial_days: int = Field(default=14, ge=0, le=365)
enable_invoices: bool = True
invoice_prefix: str = Field(default="INV-", max_length=10)
```bash
alembic upgrade head
```
### Using Configuration
The `alembic/env.py` automatically discovers module migrations via `app/modules/migrations.py`.
```python
from app.modules.service import module_service
### Note on Existing Modules
# Get module config for platform
config = module_service.get_module_config(db, platform_id, "billing")
# Returns: {"stripe_mode": "test", "trial_days": 14, ...}
Existing modules (CMS, billing, marketplace, etc.) currently have their migrations in the central `alembic/versions/` directory. Moving these to module-specific directories is an optional migration task that can be done incrementally.
# Set module config
module_service.set_module_config(
db, platform_id, "billing",
{"trial_days": 30}
)
**New modules should create their own `migrations/versions/` directory.**
## Validation
Run the architecture validator to check your module:
```bash
python scripts/validate_architecture.py
```
## Health Checks
### Validation Rules
### Defining Health Check
```python
def check_billing_health() -> dict:
"""Check billing service dependencies."""
issues = []
# Check Stripe
try:
stripe.Account.retrieve()
except stripe.AuthenticationError:
issues.append("Stripe authentication failed")
if issues:
return {
"status": "unhealthy",
"message": "; ".join(issues),
}
return {
"status": "healthy",
"details": {"stripe": "connected"},
}
billing_module = ModuleDefinition(
code="billing",
health_check=check_billing_health,
# ...
)
```
## Registering the Module
### Add to Registry
```python
# app/modules/registry.py
from app.modules.analytics.definition import analytics_module
OPTIONAL_MODULES: dict[str, ModuleDefinition] = {
# ... existing modules
"analytics": analytics_module,
}
MODULES = {**CORE_MODULES, **OPTIONAL_MODULES, **INTERNAL_MODULES}
```
## Module Events
Subscribe to module lifecycle events:
```python
from app.modules.events import module_event_bus, ModuleEvent, ModuleEventData
@module_event_bus.subscribe(ModuleEvent.ENABLED)
def on_analytics_enabled(data: ModuleEventData):
if data.module_code == "analytics":
# Initialize analytics for this platform
setup_analytics_dashboard(data.platform_id)
@module_event_bus.subscribe(ModuleEvent.DISABLED)
def on_analytics_disabled(data: ModuleEventData):
if data.module_code == "analytics":
# Cleanup
cleanup_analytics_data(data.platform_id)
```
| Rule | What It Checks |
|------|----------------|
| MOD-001 | Required directories exist |
| MOD-002 | Services contain actual code |
| MOD-003 | Schemas contain actual code |
| MOD-004 | Routes import from module |
| MOD-005 | Templates and static exist |
| MOD-006 | Locales directory exists |
| MOD-007 | Definition paths are valid |
| MOD-008 | exceptions.py exists |
| MOD-009 | definition.py exists |
| MOD-010 | Routes export `router` |
| MOD-011 | Tasks has `__init__.py` |
| MOD-012 | All locale files exist |
| MOD-013 | config.py exports config |
| MOD-014 | Migrations follow naming convention |
| MOD-015 | Migrations have `__init__.py` |
## Checklist
### New Module Checklist
- [ ] Create module directory: `app/modules/{code}/`
- [ ] Create module directory with structure
- [ ] Create `definition.py` with ModuleDefinition
- [ ] Add module to appropriate dict in `registry.py`
- [ ] Create routes if needed (admin.py, vendor.py)
- [ ] Register menu items in menu registry
- [ ] Create migrations if adding database tables
- [ ] Add health check if module has dependencies
- [ ] Document features and configuration options
- [ ] Write tests for module functionality
- [ ] Set `is_self_contained=True`
- [ ] Create `__init__.py` in all directories
- [ ] Create `exceptions.py`
- [ ] Create routes with `router` variable
- [ ] Create templates with namespace prefix
- [ ] Create locales (en, de, fr, lu)
- [ ] Create `config.py` if module needs environment settings (optional)
- [ ] Create `migrations/versions/` with `__init__.py` files if module has database tables
- [ ] Run `python scripts/validate_architecture.py`
- [ ] Test routes are accessible
### Self-Contained Module Checklist
### No Framework Changes Needed
- [ ] All items from basic checklist
- [ ] Create `services/` directory with business logic
- [ ] Create `models/` directory with SQLAlchemy models
- [ ] Create `schemas/` directory with Pydantic schemas
- [ ] Create `exceptions.py` for module-specific errors
- [ ] Create `config.py` with Pydantic config model
- [ ] Set up `migrations/versions/` directory
- [ ] Create `templates/` if module has UI
- [ ] Create `locales/` if module needs translations
- [ ] Set `is_self_contained=True` and path attributes
You do NOT need to:
- Edit `main.py`
- Edit `registry.py`
- Edit any configuration files
- Register routes manually
- Import the module anywhere
The framework discovers everything automatically!
## Related Documentation
- [Module System](../architecture/module-system.md) - Architecture overview
- [Menu Management](../architecture/menu-management.md) - Menu item integration
- [Database Migrations](migration/database-migrations.md) - Migration guide
- [Menu Management](../architecture/menu-management.md) - Sidebar integration
- [Architecture Rules](architecture-rules.md) - Validation rules