feat: complete modular platform architecture (Phases 1-5)

Phase 1 - Vendor Router Integration:
- Wire up vendor module routers in app/api/v1/vendor/__init__.py
- Use lazy imports via __getattr__ to avoid circular dependencies

Phase 2 - Extract Remaining Modules:
- Create 6 new module directories: customers, cms, analytics, messaging,
  dev_tools, monitoring
- Each module has definition.py and route wrappers
- Update registry to import from extracted modules

Phase 3 - Database Table Migration:
- Add PlatformModule junction table for auditable module tracking
- Add migration zc2m3n4o5p6q7_add_platform_modules_table.py
- Add modules relationship to Platform model
- Update ModuleService with JSON-to-junction-table migration

Phase 4 - Module-Specific Configuration UI:
- Add /api/v1/admin/module-config/* endpoints
- Add module-config.html template and JS

Phase 5 - Integration Tests:
- Add tests/fixtures/module_fixtures.py
- Add tests/integration/api/v1/admin/test_modules.py
- Add tests/integration/api/v1/modules/test_module_access.py

Architecture fixes:
- Fix JS-003 errors: use ...data() directly in Alpine components
- Fix JS-005 warnings: add init() guards to prevent duplicate init
- Fix API-001 errors: add MenuActionResponse Pydantic model
- Add FE-008 noqa for dynamic number input in template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 18:19:00 +01:00
parent f29f1113cd
commit c419090531
55 changed files with 4059 additions and 206 deletions

View File

@@ -5,11 +5,15 @@ Module service for platform module operations.
Provides methods to check module enablement, get enabled modules,
and filter menu items based on module configuration.
Module configuration is stored in Platform.settings["enabled_modules"].
If not configured, all modules are enabled (backwards compatibility).
Module configuration can be stored in two places:
1. PlatformModule junction table (preferred, auditable)
2. Platform.settings["enabled_modules"] (fallback, legacy)
If neither is configured, all modules are enabled (backwards compatibility).
"""
import logging
from datetime import datetime, timezone
from functools import lru_cache
from sqlalchemy.orm import Session
@@ -23,6 +27,7 @@ from app.modules.registry import (
)
from models.database.admin_menu_config import FrontendType
from models.database.platform import Platform
from models.database.platform_module import PlatformModule
logger = logging.getLogger(__name__)
@@ -34,11 +39,20 @@ class ModuleService:
Handles module enablement checking, module listing, and menu item filtering
based on enabled modules.
Module configuration is stored in Platform.settings["enabled_modules"]:
- If key exists: Only listed modules (plus core) are enabled
- If key missing: All modules are enabled (backwards compatibility)
Module configuration is stored in two places (with fallback):
1. PlatformModule junction table (preferred, auditable)
2. Platform.settings["enabled_modules"] (legacy fallback)
Example Platform.settings:
The service checks the junction table first. If no records exist,
it falls back to the JSON settings for backwards compatibility.
If neither is configured, all modules are enabled (backwards compatibility).
Example PlatformModule records:
PlatformModule(platform_id=1, module_code="billing", is_enabled=True, config={"stripe_mode": "live"})
PlatformModule(platform_id=1, module_code="inventory", is_enabled=True, config={"low_stock_threshold": 10})
Legacy Platform.settings (fallback):
{
"enabled_modules": ["core", "billing", "inventory", "orders"],
"module_config": {
@@ -119,10 +133,13 @@ class ModuleService:
platform_id: int,
) -> set[str]:
"""
Get enabled module codes from platform settings.
Get enabled module codes for a platform.
Internal method that reads Platform.settings["enabled_modules"].
If not configured, returns all module codes (backwards compatibility).
Checks two sources with fallback:
1. PlatformModule junction table (preferred, auditable)
2. Platform.settings["enabled_modules"] (legacy fallback)
If neither is configured, returns all module codes (backwards compatibility).
Always includes core modules.
Args:
@@ -137,22 +154,102 @@ class ModuleService:
logger.warning(f"Platform {platform_id} not found, returning all modules")
return set(MODULES.keys())
settings = platform.settings or {}
enabled_modules = settings.get("enabled_modules")
# Try junction table first (preferred)
platform_modules = (
db.query(PlatformModule)
.filter(PlatformModule.platform_id == platform_id)
.all()
)
# If not configured, enable all modules (backwards compatibility)
if enabled_modules is None:
return set(MODULES.keys())
if platform_modules:
# Use junction table data
enabled_set = {pm.module_code for pm in platform_modules if pm.is_enabled}
else:
# Fallback to JSON settings (legacy)
settings = platform.settings or {}
enabled_modules = settings.get("enabled_modules")
# If not configured, enable all modules (backwards compatibility)
if enabled_modules is None:
return set(MODULES.keys())
enabled_set = set(enabled_modules)
# Always include core modules
core_codes = get_core_module_codes()
enabled_set = set(enabled_modules) | core_codes
enabled_set = enabled_set | core_codes
# Resolve dependencies - add required modules
enabled_set = self._resolve_dependencies(enabled_set)
return enabled_set
def _migrate_json_to_junction_table(
self,
db: Session,
platform_id: int,
user_id: int | None = None,
) -> None:
"""
Migrate JSON settings to junction table records.
Called when first creating a junction table record for a platform
that previously used JSON settings. This ensures consistency when
mixing junction table and JSON approaches.
Args:
db: Database session
platform_id: Platform ID
user_id: ID of user performing the migration (for audit)
"""
# Check if any junction table records exist
existing_count = (
db.query(PlatformModule)
.filter(PlatformModule.platform_id == platform_id)
.count()
)
if existing_count > 0:
# Already using junction table
return
platform = db.query(Platform).filter(Platform.id == platform_id).first()
if not platform:
return
settings = platform.settings or {}
enabled_modules = settings.get("enabled_modules")
if enabled_modules is None:
# No JSON settings, start fresh with all modules enabled
enabled_codes = set(MODULES.keys())
else:
enabled_codes = set(enabled_modules) | get_core_module_codes()
now = datetime.now(timezone.utc)
# Create junction table records for all known modules
for code in MODULES.keys():
is_enabled = code in enabled_codes
pm = PlatformModule(
platform_id=platform_id,
module_code=code,
is_enabled=is_enabled,
enabled_at=now if is_enabled else None,
enabled_by_user_id=user_id if is_enabled else None,
disabled_at=None if is_enabled else now,
disabled_by_user_id=None if is_enabled else user_id,
config={},
)
db.add(pm)
# Flush to ensure records are visible to subsequent queries
db.flush()
logger.info(
f"Migrated platform {platform_id} from JSON settings to junction table"
)
def _resolve_dependencies(self, enabled_codes: set[str]) -> set[str]:
"""
Resolve module dependencies by adding required modules.
@@ -283,6 +380,10 @@ class ModuleService:
"""
Get module-specific configuration for a platform.
Checks two sources with fallback:
1. PlatformModule.config (preferred, auditable)
2. Platform.settings["module_config"] (legacy fallback)
Args:
db: Database session
platform_id: Platform ID
@@ -291,6 +392,20 @@ class ModuleService:
Returns:
Module configuration dict (empty if not configured)
"""
# Try junction table first (preferred)
platform_module = (
db.query(PlatformModule)
.filter(
PlatformModule.platform_id == platform_id,
PlatformModule.module_code == module_code,
)
.first()
)
if platform_module:
return platform_module.config or {}
# Fallback to JSON settings (legacy)
platform = db.query(Platform).filter(Platform.id == platform_id).first()
if not platform:
return {}
@@ -299,22 +414,80 @@ class ModuleService:
module_configs = settings.get("module_config", {})
return module_configs.get(module_code, {})
def set_module_config(
self,
db: Session,
platform_id: int,
module_code: str,
config: dict,
) -> bool:
"""
Set module-specific configuration for a platform.
Uses junction table for persistence. Creates record if doesn't exist.
Args:
db: Database session
platform_id: Platform ID
module_code: Module code
config: Configuration dict to set
Returns:
True if successful
"""
if module_code not in MODULES:
logger.error(f"Unknown module: {module_code}")
return False
platform = db.query(Platform).filter(Platform.id == platform_id).first()
if not platform:
logger.error(f"Platform {platform_id} not found")
return False
# Get or create junction table record
platform_module = (
db.query(PlatformModule)
.filter(
PlatformModule.platform_id == platform_id,
PlatformModule.module_code == module_code,
)
.first()
)
if platform_module:
platform_module.config = config
else:
# Create new record with config
platform_module = PlatformModule(
platform_id=platform_id,
module_code=module_code,
is_enabled=True, # Default to enabled
config=config,
)
db.add(platform_module)
logger.info(f"Updated config for module '{module_code}' on platform {platform_id}")
return True
def set_enabled_modules(
self,
db: Session,
platform_id: int,
module_codes: list[str],
user_id: int | None = None,
) -> bool:
"""
Set the enabled modules for a platform.
Core modules are automatically included.
Dependencies are automatically resolved.
Uses junction table for auditability.
Args:
db: Database session
platform_id: Platform ID
module_codes: List of module codes to enable
user_id: ID of user making the change (for audit)
Returns:
True if successful, False if platform not found
@@ -338,10 +511,44 @@ class ModuleService:
# Resolve dependencies
enabled_set = self._resolve_dependencies(enabled_set)
# Update platform settings
settings = platform.settings or {}
settings["enabled_modules"] = list(enabled_set)
platform.settings = settings
now = datetime.now(timezone.utc)
# Update junction table for all modules
for code in MODULES.keys():
platform_module = (
db.query(PlatformModule)
.filter(
PlatformModule.platform_id == platform_id,
PlatformModule.module_code == code,
)
.first()
)
should_enable = code in enabled_set
if platform_module:
# Update existing record
if should_enable and not platform_module.is_enabled:
platform_module.is_enabled = True
platform_module.enabled_at = now
platform_module.enabled_by_user_id = user_id
elif not should_enable and platform_module.is_enabled:
platform_module.is_enabled = False
platform_module.disabled_at = now
platform_module.disabled_by_user_id = user_id
else:
# Create new record
platform_module = PlatformModule(
platform_id=platform_id,
module_code=code,
is_enabled=should_enable,
enabled_at=now if should_enable else None,
enabled_by_user_id=user_id if should_enable else None,
disabled_at=None if should_enable else now,
disabled_by_user_id=None if should_enable else user_id,
config={},
)
db.add(platform_module)
logger.info(
f"Updated enabled modules for platform {platform_id}: {sorted(enabled_set)}"
@@ -353,16 +560,19 @@ class ModuleService:
db: Session,
platform_id: int,
module_code: str,
user_id: int | None = None,
) -> bool:
"""
Enable a single module for a platform.
Also enables required dependencies.
Uses junction table for auditability when available.
Args:
db: Database session
platform_id: Platform ID
module_code: Module code to enable
user_id: ID of user enabling the module (for audit)
Returns:
True if successful
@@ -376,15 +586,45 @@ class ModuleService:
logger.error(f"Platform {platform_id} not found")
return False
settings = platform.settings or {}
enabled = set(settings.get("enabled_modules", list(MODULES.keys())))
enabled.add(module_code)
# Migrate JSON settings to junction table if needed
self._migrate_json_to_junction_table(db, platform_id, user_id)
# Resolve dependencies
enabled = self._resolve_dependencies(enabled)
now = datetime.now(timezone.utc)
settings["enabled_modules"] = list(enabled)
platform.settings = settings
# Enable this module and its dependencies
modules_to_enable = {module_code}
module = get_module(module_code)
if module:
for required in module.requires:
modules_to_enable.add(required)
for code in modules_to_enable:
# Check if junction table record exists
platform_module = (
db.query(PlatformModule)
.filter(
PlatformModule.platform_id == platform_id,
PlatformModule.module_code == code,
)
.first()
)
if platform_module:
# Update existing record
platform_module.is_enabled = True
platform_module.enabled_at = now
platform_module.enabled_by_user_id = user_id
else:
# Create new record
platform_module = PlatformModule(
platform_id=platform_id,
module_code=code,
is_enabled=True,
enabled_at=now,
enabled_by_user_id=user_id,
config={},
)
db.add(platform_module)
logger.info(f"Enabled module '{module_code}' for platform {platform_id}")
return True
@@ -394,17 +634,20 @@ class ModuleService:
db: Session,
platform_id: int,
module_code: str,
user_id: int | None = None,
) -> bool:
"""
Disable a single module for a platform.
Core modules cannot be disabled.
Also disables modules that depend on this one.
Uses junction table for auditability when available.
Args:
db: Database session
platform_id: Platform ID
module_code: Module code to disable
user_id: ID of user disabling the module (for audit)
Returns:
True if successful, False if core or not found
@@ -423,23 +666,48 @@ class ModuleService:
logger.error(f"Platform {platform_id} not found")
return False
settings = platform.settings or {}
enabled = set(settings.get("enabled_modules", list(MODULES.keys())))
# Migrate JSON settings to junction table if needed
self._migrate_json_to_junction_table(db, platform_id, user_id)
# Remove this module
enabled.discard(module_code)
now = datetime.now(timezone.utc)
# Remove modules that depend on this one
# Get modules to disable (this one + dependents)
modules_to_disable = {module_code}
dependents = self._get_dependent_modules(module_code)
for dependent in dependents:
if dependent in enabled:
enabled.discard(dependent)
logger.info(
f"Also disabled '{dependent}' (depends on '{module_code}')"
)
modules_to_disable.update(dependents)
settings["enabled_modules"] = list(enabled)
platform.settings = settings
for code in modules_to_disable:
# Check if junction table record exists
platform_module = (
db.query(PlatformModule)
.filter(
PlatformModule.platform_id == platform_id,
PlatformModule.module_code == code,
)
.first()
)
if platform_module:
# Update existing record
platform_module.is_enabled = False
platform_module.disabled_at = now
platform_module.disabled_by_user_id = user_id
else:
# Create disabled record for tracking
platform_module = PlatformModule(
platform_id=platform_id,
module_code=code,
is_enabled=False,
disabled_at=now,
disabled_by_user_id=user_id,
config={},
)
db.add(platform_module)
if code != module_code:
logger.info(
f"Also disabled '{code}' (depends on '{module_code}')"
)
logger.info(f"Disabled module '{module_code}' for platform {platform_id}")
return True