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:
253
app/modules/migrations.py
Normal file
253
app/modules/migrations.py
Normal file
@@ -0,0 +1,253 @@
|
||||
# app/modules/migrations.py
|
||||
"""
|
||||
Module migration discovery utility.
|
||||
|
||||
Provides utilities for discovering and managing module-specific migrations.
|
||||
Each self-contained module can have its own migrations directory that will
|
||||
be included in Alembic's version locations.
|
||||
|
||||
Module Migration Structure:
|
||||
app/modules/<code>/
|
||||
└── migrations/
|
||||
└── versions/
|
||||
├── <module>_001_initial.py
|
||||
├── <module>_002_add_field.py
|
||||
└── ...
|
||||
|
||||
Migration Naming Convention:
|
||||
{module_code}_{sequence}_{description}.py
|
||||
Example: cms_001_create_content_pages.py
|
||||
|
||||
This ensures no collision between modules and makes it clear which
|
||||
module owns each migration.
|
||||
|
||||
Usage:
|
||||
# Get all migration paths for Alembic
|
||||
from app.modules.migrations import get_all_migration_paths
|
||||
|
||||
paths = get_all_migration_paths()
|
||||
# Returns: [Path("alembic/versions"), Path("app/modules/cms/migrations/versions"), ...]
|
||||
|
||||
# In alembic/env.py
|
||||
context.configure(
|
||||
version_locations=[str(p) for p in get_all_migration_paths()],
|
||||
...
|
||||
)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.modules.base import ModuleDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_core_migrations_path() -> Path:
|
||||
"""
|
||||
Get the path to the core (non-module) migrations directory.
|
||||
|
||||
Returns:
|
||||
Path to alembic/versions/
|
||||
"""
|
||||
# Navigate from app/modules/migrations.py to project root
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
return project_root / "alembic" / "versions"
|
||||
|
||||
|
||||
def get_module_migrations_path(module: "ModuleDefinition") -> Path | None:
|
||||
"""
|
||||
Get the migrations path for a specific module.
|
||||
|
||||
Args:
|
||||
module: Module definition to get migrations path for
|
||||
|
||||
Returns:
|
||||
Path to module's migrations/versions/ directory, or None if not configured
|
||||
"""
|
||||
migrations_dir = module.get_migrations_dir()
|
||||
if migrations_dir is None:
|
||||
return None
|
||||
|
||||
versions_dir = migrations_dir / "versions"
|
||||
return versions_dir
|
||||
|
||||
|
||||
def discover_module_migrations() -> list[Path]:
|
||||
"""
|
||||
Discover all module migration directories.
|
||||
|
||||
Scans all registered modules for those with migrations_path configured
|
||||
and returns paths to their versions directories.
|
||||
|
||||
Returns:
|
||||
List of paths to module migration version directories
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
paths: list[Path] = []
|
||||
|
||||
for module in MODULES.values():
|
||||
if not module.migrations_path:
|
||||
continue
|
||||
|
||||
versions_path = get_module_migrations_path(module)
|
||||
if versions_path is None:
|
||||
continue
|
||||
|
||||
if versions_path.exists():
|
||||
paths.append(versions_path)
|
||||
logger.debug(f"Found migrations for module {module.code}: {versions_path}")
|
||||
else:
|
||||
logger.debug(
|
||||
f"Module {module.code} has migrations_path but no versions directory"
|
||||
)
|
||||
|
||||
return sorted(paths) # Sort for deterministic ordering
|
||||
|
||||
|
||||
def get_all_migration_paths() -> list[Path]:
|
||||
"""
|
||||
Get all migration paths including core and module migrations.
|
||||
|
||||
Returns:
|
||||
List of paths starting with core migrations, followed by module migrations
|
||||
in alphabetical order by module code.
|
||||
|
||||
Example:
|
||||
[
|
||||
Path("alembic/versions"),
|
||||
Path("app/modules/billing/migrations/versions"),
|
||||
Path("app/modules/cms/migrations/versions"),
|
||||
]
|
||||
"""
|
||||
paths = [get_core_migrations_path()]
|
||||
paths.extend(discover_module_migrations())
|
||||
return paths
|
||||
|
||||
|
||||
def get_migration_order() -> list[str]:
|
||||
"""
|
||||
Get the order in which module migrations should be applied.
|
||||
|
||||
Returns migrations in dependency order - modules with no dependencies first,
|
||||
then modules that depend on them, etc.
|
||||
|
||||
Returns:
|
||||
List of module codes in migration order
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
# Build dependency graph
|
||||
modules_with_migrations = [
|
||||
m for m in MODULES.values() if m.migrations_path
|
||||
]
|
||||
|
||||
if not modules_with_migrations:
|
||||
return []
|
||||
|
||||
# Topological sort based on dependencies
|
||||
ordered: list[str] = []
|
||||
visited: set[str] = set()
|
||||
temp_visited: set[str] = set()
|
||||
|
||||
def visit(code: str) -> None:
|
||||
if code in visited:
|
||||
return
|
||||
if code in temp_visited:
|
||||
raise ValueError(f"Circular dependency detected involving {code}")
|
||||
|
||||
module = MODULES.get(code)
|
||||
if module is None:
|
||||
return
|
||||
|
||||
temp_visited.add(code)
|
||||
|
||||
# Visit dependencies first
|
||||
for dep in module.requires:
|
||||
if dep in {m.code for m in modules_with_migrations}:
|
||||
visit(dep)
|
||||
|
||||
temp_visited.remove(code)
|
||||
visited.add(code)
|
||||
|
||||
if module.migrations_path:
|
||||
ordered.append(code)
|
||||
|
||||
for module in modules_with_migrations:
|
||||
visit(module.code)
|
||||
|
||||
return ordered
|
||||
|
||||
|
||||
def validate_migration_names() -> list[str]:
|
||||
"""
|
||||
Validate that all module migrations follow the naming convention.
|
||||
|
||||
Returns:
|
||||
List of validation errors (empty if all valid)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
for path in discover_module_migrations():
|
||||
# Extract module code from path (e.g., app/modules/cms/migrations/versions -> cms)
|
||||
module_dir = path.parent.parent # migrations/versions -> migrations -> module
|
||||
module_code = module_dir.name.replace("_", "-") # cms or dev_tools -> dev-tools
|
||||
|
||||
for migration_file in path.glob("*.py"):
|
||||
name = migration_file.stem
|
||||
if name == "__pycache__":
|
||||
continue
|
||||
|
||||
# Check prefix matches module code
|
||||
expected_prefix = module_code.replace("-", "_")
|
||||
if not name.startswith(f"{expected_prefix}_"):
|
||||
errors.append(
|
||||
f"Migration {migration_file} should start with '{expected_prefix}_'"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def create_module_migrations_dir(module: "ModuleDefinition") -> Path:
|
||||
"""
|
||||
Create the migrations directory structure for a module.
|
||||
|
||||
Args:
|
||||
module: Module to create migrations for
|
||||
|
||||
Returns:
|
||||
Path to the created versions directory
|
||||
"""
|
||||
module_dir = module.get_module_dir()
|
||||
migrations_dir = module_dir / "migrations"
|
||||
versions_dir = migrations_dir / "versions"
|
||||
|
||||
versions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create __init__.py files
|
||||
init_files = [
|
||||
migrations_dir / "__init__.py",
|
||||
versions_dir / "__init__.py",
|
||||
]
|
||||
for init_file in init_files:
|
||||
if not init_file.exists():
|
||||
init_file.write_text('"""Module migrations."""\n')
|
||||
|
||||
logger.info(f"Created migrations directory for module {module.code}")
|
||||
return versions_dir
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_core_migrations_path",
|
||||
"get_module_migrations_path",
|
||||
"discover_module_migrations",
|
||||
"get_all_migration_paths",
|
||||
"get_migration_order",
|
||||
"validate_migration_names",
|
||||
"create_module_migrations_dir",
|
||||
]
|
||||
Reference in New Issue
Block a user