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:
@@ -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})>"
|
||||
|
||||
Reference in New Issue
Block a user