feat: implement three-tier module classification and framework layer

Module Classification:
- Core (4): core, tenancy, cms, customers - always enabled
- Optional (7): payments, billing, inventory, orders, marketplace, analytics, messaging
- Internal (2): dev-tools, monitoring - admin-only

Key Changes:
- Rename platform-admin module to tenancy
- Promote CMS and Customers to core modules
- Create new payments module (gateway abstractions)
- Add billing→payments and orders→payments dependencies
- Mark dev-tools and monitoring as internal modules

New Infrastructure:
- app/modules/events.py: Module event bus (ENABLED, DISABLED, STARTUP, SHUTDOWN)
- app/modules/migrations.py: Module-specific migration discovery
- app/core/observability.py: Health checks, Prometheus metrics, Sentry integration

Enhanced ModuleDefinition:
- version, is_internal, permissions
- config_schema, default_config
- migrations_path
- Lifecycle hooks: on_enable, on_disable, on_startup, health_check

New Registry Functions:
- get_optional_module_codes(), get_internal_module_codes()
- is_core_module(), is_internal_module()
- get_modules_by_tier(), get_module_tier()

Migrations:
- zc*: Rename platform-admin to tenancy
- zd*: Ensure CMS and Customers enabled for all platforms

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-27 22:02:39 +01:00
parent 9a828999fe
commit 1a52611438
26 changed files with 3084 additions and 67 deletions

View File

@@ -11,6 +11,12 @@ per platform. Each module contains:
- Models: Database models (optional, for self-contained modules)
- Schemas: Pydantic schemas (optional, for self-contained modules)
- Templates: Jinja2 templates (optional, for self-contained modules)
- Migrations: Database migrations (optional, for self-contained modules)
Module Classification:
- Core modules: Always enabled, cannot be disabled (core, tenancy, cms, customers)
- Optional modules: Can be enabled/disabled per platform
- Internal modules: Admin-only tools, not customer-facing (dev-tools, monitoring)
Self-Contained Module Structure:
app/modules/<code>/
@@ -22,15 +28,19 @@ Self-Contained Module Structure:
├── services/ # Business logic
├── models/ # SQLAlchemy models
├── schemas/ # Pydantic schemas
── templates/ # Jinja2 templates (namespaced)
── migrations/ # Alembic migrations for this module
│ └── versions/ # Migration scripts
├── templates/ # Jinja2 templates (namespaced)
└── locales/ # Translation files
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, Callable
if TYPE_CHECKING:
from fastapi import APIRouter
from pydantic import BaseModel
from models.database.admin_menu_config import FrontendType
@@ -43,26 +53,51 @@ class ModuleDefinition:
A module groups related functionality that can be enabled/disabled per platform.
Core modules cannot be disabled and are always available.
Self-contained modules include their own services, models, schemas, and templates.
The path attributes describe where these components are located.
Self-contained modules include their own services, models, schemas, templates,
and migrations. The path attributes describe where these components are located.
Attributes:
# Identity
code: Unique identifier (e.g., "billing", "marketplace")
name: Display name (e.g., "Billing & Subscriptions")
description: Description of what this module provides
version: Semantic version of the module (e.g., "1.0.0")
# Dependencies
requires: List of module codes this module depends on
# Components
features: List of feature codes this module provides
menu_items: Dict mapping FrontendType to list of menu item IDs
permissions: List of permission codes this module defines
# Classification
is_core: Core modules cannot be disabled
is_internal: Internal modules are admin-only (not customer-facing)
# Configuration
config_schema: Pydantic model class for module configuration
default_config: Default configuration values
# Routes
admin_router: FastAPI router for admin routes
vendor_router: FastAPI router for vendor routes
services_path: Path to services subpackage (e.g., "app.modules.billing.services")
models_path: Path to models subpackage (e.g., "app.modules.billing.models")
schemas_path: Path to schemas subpackage (e.g., "app.modules.billing.schemas")
templates_path: Path to templates directory (relative to module)
exceptions_path: Path to exceptions module (e.g., "app.modules.billing.exceptions")
locales_path: Path to locales directory (relative to module, e.g., "locales")
# Lifecycle hooks
on_enable: Called when module is enabled for a platform
on_disable: Called when module is disabled for a platform
on_startup: Called when application starts (for enabled modules)
health_check: Called to check module health status
# Self-contained module paths (optional)
is_self_contained: Whether module uses self-contained structure
services_path: Path to services subpackage
models_path: Path to models subpackage
schemas_path: Path to schemas subpackage
templates_path: Path to templates directory (relative to module)
exceptions_path: Path to exceptions module
locales_path: Path to locales directory (relative to module)
migrations_path: Path to migrations directory (relative to module)
Example (traditional thin wrapper):
billing_module = ModuleDefinition(
@@ -76,53 +111,89 @@ class ModuleDefinition:
},
)
Example (self-contained module):
Example (self-contained module with configuration):
from pydantic import BaseModel, Field
class CMSConfig(BaseModel):
max_pages: int = Field(default=100, ge=1)
enable_seo: bool = True
cms_module = ModuleDefinition(
code="cms",
name="Content Management",
description="Content pages, media library, and vendor themes.",
version="1.0.0",
features=["cms_basic", "cms_custom_pages"],
menu_items={
FrontendType.ADMIN: ["content-pages"],
FrontendType.VENDOR: ["content-pages", "media"],
},
config_schema=CMSConfig,
default_config={"max_pages": 100, "enable_seo": True},
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",
health_check=lambda: {"status": "healthy"},
)
"""
# =========================================================================
# Identity
# =========================================================================
code: str
name: str
description: str = ""
version: str = "1.0.0"
# =========================================================================
# Dependencies
# =========================================================================
requires: list[str] = field(default_factory=list)
# =========================================================================
# Components
# =========================================================================
features: list[str] = field(default_factory=list)
menu_items: dict[FrontendType, list[str]] = field(default_factory=dict)
permissions: list[str] = field(default_factory=list)
# Status
# =========================================================================
# Classification
# =========================================================================
is_core: bool = False
is_internal: bool = False
# =========================================================================
# Configuration
# =========================================================================
config_schema: "type[BaseModel] | None" = None
default_config: dict[str, Any] = field(default_factory=dict)
# =========================================================================
# Routes (registered dynamically)
# =========================================================================
admin_router: "APIRouter | None" = None
vendor_router: "APIRouter | None" = None
# Self-contained module paths (optional)
# =========================================================================
# Lifecycle Hooks
# =========================================================================
on_enable: Callable[[int], None] | None = None # Called with platform_id
on_disable: Callable[[int], None] | None = None # Called with platform_id
on_startup: Callable[[], None] | None = None # Called on app startup
health_check: Callable[[], dict[str, Any]] | None = None # Returns health status
# =========================================================================
# Self-Contained Module Paths (optional)
# =========================================================================
is_self_contained: bool = False
services_path: str | None = None
models_path: str | None = None
schemas_path: str | None = None
templates_path: str | None = None # Relative to module directory
exceptions_path: str | None = None
locales_path: str | None = None # Relative to module directory, e.g., "locales"
locales_path: str | None = None # Relative to module directory
migrations_path: str | None = None # Relative to module directory, e.g., "migrations"
# =========================================================================
# Menu Item Methods
# =========================================================================
def get_menu_items(self, frontend_type: FrontendType) -> list[str]:
"""Get menu item IDs for a specific frontend type."""
@@ -135,13 +206,25 @@ class ModuleDefinition:
all_items.update(items)
return all_items
def has_menu_item(self, menu_item_id: str) -> bool:
"""Check if this module provides a specific menu item."""
return menu_item_id in self.get_all_menu_items()
# =========================================================================
# Feature Methods
# =========================================================================
def has_feature(self, feature_code: str) -> bool:
"""Check if this module provides a specific feature."""
return feature_code in self.features
def has_menu_item(self, menu_item_id: str) -> bool:
"""Check if this module provides a specific menu item."""
return menu_item_id in self.get_all_menu_items()
def has_permission(self, permission_code: str) -> bool:
"""Check if this module defines a specific permission."""
return permission_code in self.permissions
# =========================================================================
# Dependency Methods
# =========================================================================
def check_dependencies(self, enabled_modules: set[str]) -> list[str]:
"""
@@ -155,6 +238,70 @@ class ModuleDefinition:
"""
return [req for req in self.requires if req not in enabled_modules]
# =========================================================================
# Configuration Methods
# =========================================================================
def validate_config(self, config: dict[str, Any]) -> dict[str, Any]:
"""
Validate configuration against the schema.
Args:
config: Configuration dict to validate
Returns:
Validated configuration dict
Raises:
ValidationError: If configuration is invalid
"""
if self.config_schema is None:
return config
# Merge with defaults
merged = {**self.default_config, **config}
# Validate using Pydantic model
validated = self.config_schema(**merged)
return validated.model_dump()
def get_default_config(self) -> dict[str, Any]:
"""Get the default configuration for this module."""
if self.config_schema is None:
return self.default_config.copy()
# Use Pydantic model defaults
return self.config_schema().model_dump()
# =========================================================================
# Lifecycle Methods
# =========================================================================
def run_on_enable(self, platform_id: int) -> None:
"""Run the on_enable hook if defined."""
if self.on_enable:
self.on_enable(platform_id)
def run_on_disable(self, platform_id: int) -> None:
"""Run the on_disable hook if defined."""
if self.on_disable:
self.on_disable(platform_id)
def run_on_startup(self) -> None:
"""Run the on_startup hook if defined."""
if self.on_startup:
self.on_startup()
def get_health_status(self) -> dict[str, Any]:
"""
Get the health status of this module.
Returns:
Dict with at least a 'status' key ('healthy', 'degraded', 'unhealthy')
"""
if self.health_check:
return self.health_check()
return {"status": "healthy", "message": "No health check defined"}
# =========================================================================
# Self-Contained Module Methods
# =========================================================================
@@ -166,7 +313,9 @@ class ModuleDefinition:
Returns:
Path to app/modules/<code>/
"""
return Path(__file__).parent / self.code
# Handle module codes with hyphens (e.g., dev-tools -> dev_tools)
dir_name = self.code.replace("-", "_")
return Path(__file__).parent / dir_name
def get_templates_dir(self) -> Path | None:
"""
@@ -190,6 +339,17 @@ class ModuleDefinition:
return None
return self.get_module_dir() / self.locales_path
def get_migrations_dir(self) -> Path | None:
"""
Get the filesystem path to this module's migrations directory.
Returns:
Path to migrations directory, or None if not configured
"""
if not self.migrations_path:
return None
return self.get_module_dir() / self.migrations_path
def get_import_path(self, component: str) -> str | None:
"""
Get the Python import path for a module component.
@@ -237,6 +397,8 @@ class ModuleDefinition:
expected_dirs.append(self.templates_path)
if self.locales_path:
expected_dirs.append(self.locales_path)
if self.migrations_path:
expected_dirs.append(self.migrations_path)
for dir_name in expected_dirs:
dir_path = module_dir / dir_name
@@ -245,6 +407,28 @@ class ModuleDefinition:
return issues
# =========================================================================
# Classification Methods
# =========================================================================
def get_tier(self) -> str:
"""
Get the tier classification of this module.
Returns:
'core', 'internal', or 'optional'
"""
if self.is_core:
return "core"
elif self.is_internal:
return "internal"
else:
return "optional"
# =========================================================================
# Magic Methods
# =========================================================================
def __hash__(self) -> int:
return hash(self.code)
@@ -254,5 +438,6 @@ class ModuleDefinition:
return False
def __repr__(self) -> str:
tier = self.get_tier()
sc = ", self_contained" if self.is_self_contained else ""
return f"<Module({self.code}, core={self.is_core}{sc})>"
return f"<Module({self.code}, tier={tier}{sc})>"