Files
orion/app/modules/migrations.py
Samir Boulahtit 1a52611438 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>
2026-01-27 22:02:39 +01:00

254 lines
7.0 KiB
Python

# 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",
]