feat: add module config and migrations auto-discovery infrastructure
Add self-contained configuration and migrations support for modules:
Config auto-discovery (app/modules/config.py):
- Modules can have config.py with Pydantic Settings
- Environment variables prefixed with MODULE_NAME_
- Auto-discovered via get_module_config()
Migrations auto-discovery:
- Each module has migrations/versions/ directory
- Alembic discovers module migrations automatically
- Naming convention: {module}_{seq}_{description}.py
New architecture rules (MOD-013 to MOD-015):
- MOD-013: config.py should export config/config_class
- MOD-014: Migrations must follow naming convention
- MOD-015: Migrations directory must have __init__.py
Created for all 11 self-contained modules:
- config.py placeholder files
- migrations/ directories with __init__.py files
Added core and tenancy module definitions for completeness.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
31
app/modules/analytics/config.py
Normal file
31
app/modules/analytics/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# app/modules/analytics/config.py
|
||||
"""
|
||||
Module configuration.
|
||||
|
||||
Environment-based configuration using Pydantic Settings.
|
||||
Settings are loaded from environment variables with ANALYTICS_ prefix.
|
||||
|
||||
Example:
|
||||
ANALYTICS_SETTING_NAME=value
|
||||
|
||||
Usage:
|
||||
from app.modules.analytics.config import config
|
||||
value = config.setting_name
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ModuleConfig(BaseSettings):
|
||||
"""Configuration for analytics module."""
|
||||
|
||||
# Add module-specific settings here
|
||||
# Example:
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "ANALYTICS_"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
config_class = ModuleConfig
|
||||
config = ModuleConfig()
|
||||
1
app/modules/analytics/migrations/__init__.py
Normal file
1
app/modules/analytics/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Module migrations."""
|
||||
1
app/modules/analytics/migrations/versions/__init__.py
Normal file
1
app/modules/analytics/migrations/versions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Migration versions."""
|
||||
31
app/modules/billing/config.py
Normal file
31
app/modules/billing/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# app/modules/billing/config.py
|
||||
"""
|
||||
Module configuration.
|
||||
|
||||
Environment-based configuration using Pydantic Settings.
|
||||
Settings are loaded from environment variables with BILLING_ prefix.
|
||||
|
||||
Example:
|
||||
BILLING_SETTING_NAME=value
|
||||
|
||||
Usage:
|
||||
from app.modules.billing.config import config
|
||||
value = config.setting_name
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ModuleConfig(BaseSettings):
|
||||
"""Configuration for billing module."""
|
||||
|
||||
# Add module-specific settings here
|
||||
# Example:
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "BILLING_"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
config_class = ModuleConfig
|
||||
config = ModuleConfig()
|
||||
1
app/modules/billing/migrations/__init__.py
Normal file
1
app/modules/billing/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Module migrations."""
|
||||
1
app/modules/billing/migrations/versions/__init__.py
Normal file
1
app/modules/billing/migrations/versions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Migration versions."""
|
||||
31
app/modules/cms/config.py
Normal file
31
app/modules/cms/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# app/modules/cms/config.py
|
||||
"""
|
||||
Module configuration.
|
||||
|
||||
Environment-based configuration using Pydantic Settings.
|
||||
Settings are loaded from environment variables with CMS_ prefix.
|
||||
|
||||
Example:
|
||||
CMS_SETTING_NAME=value
|
||||
|
||||
Usage:
|
||||
from app.modules.cms.config import config
|
||||
value = config.setting_name
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ModuleConfig(BaseSettings):
|
||||
"""Configuration for cms module."""
|
||||
|
||||
# Add module-specific settings here
|
||||
# Example:
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "CMS_"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
config_class = ModuleConfig
|
||||
config = ModuleConfig()
|
||||
1
app/modules/cms/migrations/__init__.py
Normal file
1
app/modules/cms/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Module migrations."""
|
||||
1
app/modules/cms/migrations/versions/__init__.py
Normal file
1
app/modules/cms/migrations/versions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Migration versions."""
|
||||
272
app/modules/config.py
Normal file
272
app/modules/config.py
Normal file
@@ -0,0 +1,272 @@
|
||||
# app/modules/config.py
|
||||
"""
|
||||
Module configuration auto-discovery utility.
|
||||
|
||||
Provides utilities for discovering and loading module-specific configurations.
|
||||
Each self-contained module can have its own config.py file that defines:
|
||||
- A Pydantic Settings class for environment-based configuration
|
||||
- Default values for module settings
|
||||
- Validation rules for configuration
|
||||
|
||||
Module Config Structure:
|
||||
app/modules/<code>/
|
||||
└── config.py # ModuleConfig class
|
||||
|
||||
Config File Pattern:
|
||||
# app/modules/mymodule/config.py
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class MyModuleConfig(BaseSettings):
|
||||
'''Configuration for mymodule.'''
|
||||
|
||||
# Module-specific settings (auto-prefixed with module code)
|
||||
api_timeout: int = Field(default=30, description="API timeout in seconds")
|
||||
max_retries: int = Field(default=3, description="Max retry attempts")
|
||||
|
||||
model_config = {"env_prefix": "MYMODULE_"}
|
||||
|
||||
# Export the config class and instance
|
||||
config_class = MyModuleConfig
|
||||
config = MyModuleConfig()
|
||||
|
||||
Usage:
|
||||
# Get all module configurations
|
||||
from app.modules.config import get_all_module_configs, get_module_config
|
||||
|
||||
configs = get_all_module_configs()
|
||||
# Returns: {"billing": <BillingConfig>, "marketplace": <MarketplaceConfig>, ...}
|
||||
|
||||
billing_config = get_module_config("billing")
|
||||
# Returns: <BillingConfig instance>
|
||||
|
||||
Integration with ModuleDefinition:
|
||||
The config auto-discovery works alongside ModuleDefinition.config_schema.
|
||||
- If config.py exists, it's auto-discovered and loaded
|
||||
- If config_schema is defined in definition.py, it takes precedence
|
||||
- Both can be used together for runtime vs static configuration
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Path to the modules directory
|
||||
MODULES_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def discover_module_config(module_code: str) -> "BaseSettings | None":
|
||||
"""
|
||||
Discover and load configuration for a specific module.
|
||||
|
||||
Looks for a config.py file in the module directory with either:
|
||||
- A 'config' variable (instantiated config)
|
||||
- A 'config_class' variable (config class to instantiate)
|
||||
|
||||
Args:
|
||||
module_code: Module code (e.g., "billing", "marketplace")
|
||||
|
||||
Returns:
|
||||
Instantiated config object, or None if no config found
|
||||
"""
|
||||
# Convert module code to directory name (e.g., dev-tools -> dev_tools)
|
||||
dir_name = module_code.replace("-", "_")
|
||||
config_file = MODULES_DIR / dir_name / "config.py"
|
||||
|
||||
if not config_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
import_path = f"app.modules.{dir_name}.config"
|
||||
config_module = importlib.import_module(import_path)
|
||||
|
||||
# First, try to get an instantiated config
|
||||
if hasattr(config_module, "config"):
|
||||
config = getattr(config_module, "config")
|
||||
logger.debug(f"Loaded config instance for module {module_code}")
|
||||
return config
|
||||
|
||||
# Otherwise, try to instantiate from config_class
|
||||
if hasattr(config_module, "config_class"):
|
||||
config_class = getattr(config_module, "config_class")
|
||||
config = config_class()
|
||||
logger.debug(f"Instantiated config class for module {module_code}")
|
||||
return config
|
||||
|
||||
logger.warning(
|
||||
f"Module {module_code} has config.py but no 'config' or 'config_class' export"
|
||||
)
|
||||
return None
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import config for module {module_code}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config for module {module_code}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def discover_all_module_configs() -> dict[str, "BaseSettings"]:
|
||||
"""
|
||||
Discover and load configurations for all modules.
|
||||
|
||||
Returns:
|
||||
Dict mapping module code to config instance
|
||||
"""
|
||||
configs: dict[str, "BaseSettings"] = {}
|
||||
|
||||
for module_dir in sorted(MODULES_DIR.iterdir()):
|
||||
if not module_dir.is_dir():
|
||||
continue
|
||||
|
||||
# Skip special directories
|
||||
if module_dir.name.startswith("_"):
|
||||
continue
|
||||
|
||||
config_file = module_dir / "config.py"
|
||||
if not config_file.exists():
|
||||
continue
|
||||
|
||||
# Convert directory name to module code (e.g., dev_tools -> dev-tools)
|
||||
module_code = module_dir.name.replace("_", "-")
|
||||
config = discover_module_config(module_code)
|
||||
|
||||
if config is not None:
|
||||
configs[module_code] = config
|
||||
|
||||
logger.info(f"Discovered configs for {len(configs)} modules")
|
||||
return configs
|
||||
|
||||
|
||||
def get_all_module_configs() -> dict[str, "BaseSettings"]:
|
||||
"""
|
||||
Get all module configurations.
|
||||
|
||||
Returns:
|
||||
Dict mapping module code to config instance
|
||||
"""
|
||||
return discover_all_module_configs()
|
||||
|
||||
|
||||
def get_module_config(module_code: str) -> "BaseSettings | None":
|
||||
"""
|
||||
Get configuration for a specific module.
|
||||
|
||||
Args:
|
||||
module_code: Module code (e.g., "billing")
|
||||
|
||||
Returns:
|
||||
Config instance, or None if no config exists
|
||||
"""
|
||||
configs = get_all_module_configs()
|
||||
return configs.get(module_code)
|
||||
|
||||
|
||||
def reload_module_configs() -> dict[str, "BaseSettings"]:
|
||||
"""
|
||||
Reload all module configurations (clears cache).
|
||||
|
||||
Useful for testing or when configuration files change.
|
||||
|
||||
Returns:
|
||||
Fresh dict of module configurations
|
||||
"""
|
||||
discover_all_module_configs.cache_clear()
|
||||
return discover_all_module_configs()
|
||||
|
||||
|
||||
def get_merged_config(module_code: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get merged configuration for a module.
|
||||
|
||||
Merges configuration from multiple sources:
|
||||
1. config.py environment settings (lowest priority)
|
||||
2. ModuleDefinition.default_config
|
||||
3. ModuleDefinition.config_schema validation
|
||||
|
||||
Args:
|
||||
module_code: Module code
|
||||
|
||||
Returns:
|
||||
Merged configuration dict
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from app.modules.registry import get_module
|
||||
|
||||
config_dict: dict[str, Any] = {}
|
||||
|
||||
# 1. Load from config.py if exists
|
||||
config_instance = get_module_config(module_code)
|
||||
if config_instance is not None:
|
||||
config_dict.update(config_instance.model_dump())
|
||||
|
||||
# 2. Merge with ModuleDefinition defaults
|
||||
module = get_module(module_code)
|
||||
if module:
|
||||
# default_config is merged, overriding config.py values
|
||||
if module.default_config:
|
||||
for key, value in module.default_config.items():
|
||||
if key not in config_dict:
|
||||
config_dict[key] = value
|
||||
|
||||
# 3. Validate against config_schema if defined
|
||||
if module.config_schema:
|
||||
try:
|
||||
validated = module.config_schema(**config_dict)
|
||||
config_dict = validated.model_dump()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Config validation failed for module {module_code}: {e}"
|
||||
)
|
||||
|
||||
return config_dict
|
||||
|
||||
|
||||
def validate_module_configs() -> list[str]:
|
||||
"""
|
||||
Validate all module configurations.
|
||||
|
||||
Returns:
|
||||
List of validation errors (empty if all valid)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
for module_dir in MODULES_DIR.iterdir():
|
||||
if not module_dir.is_dir() or module_dir.name.startswith("_"):
|
||||
continue
|
||||
|
||||
config_file = module_dir / "config.py"
|
||||
if not config_file.exists():
|
||||
continue
|
||||
|
||||
module_code = module_dir.name.replace("_", "-")
|
||||
|
||||
try:
|
||||
config = discover_module_config(module_code)
|
||||
if config is None:
|
||||
errors.append(
|
||||
f"Module {module_code}: config.py exists but no config exported"
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(f"Module {module_code}: config.py failed to load: {e}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
__all__ = [
|
||||
"discover_module_config",
|
||||
"discover_all_module_configs",
|
||||
"get_all_module_configs",
|
||||
"get_module_config",
|
||||
"reload_module_configs",
|
||||
"get_merged_config",
|
||||
"validate_module_configs",
|
||||
]
|
||||
11
app/modules/core/__init__.py
Normal file
11
app/modules/core/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# app/modules/core/__init__.py
|
||||
"""
|
||||
Core Platform module.
|
||||
|
||||
Provides dashboard, settings, and profile management.
|
||||
This is a core module that cannot be disabled.
|
||||
"""
|
||||
|
||||
from app.modules.core.definition import core_module
|
||||
|
||||
__all__ = ["core_module"]
|
||||
39
app/modules/core/definition.py
Normal file
39
app/modules/core/definition.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# app/modules/core/definition.py
|
||||
"""
|
||||
Core Platform module definition.
|
||||
|
||||
Dashboard, settings, and profile management.
|
||||
Required for basic operation - cannot be disabled.
|
||||
"""
|
||||
|
||||
from app.modules.base import ModuleDefinition
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
|
||||
core_module = ModuleDefinition(
|
||||
code="core",
|
||||
name="Core Platform",
|
||||
description="Dashboard, settings, and profile management. Required for basic operation.",
|
||||
version="1.0.0",
|
||||
is_core=True,
|
||||
features=[
|
||||
"dashboard",
|
||||
"settings",
|
||||
"profile",
|
||||
],
|
||||
menu_items={
|
||||
FrontendType.ADMIN: [
|
||||
"dashboard",
|
||||
"settings",
|
||||
"email-templates",
|
||||
"my-menu",
|
||||
],
|
||||
FrontendType.VENDOR: [
|
||||
"dashboard",
|
||||
"profile",
|
||||
"settings",
|
||||
"email-templates",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
__all__ = ["core_module"]
|
||||
31
app/modules/customers/config.py
Normal file
31
app/modules/customers/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# app/modules/customers/config.py
|
||||
"""
|
||||
Module configuration.
|
||||
|
||||
Environment-based configuration using Pydantic Settings.
|
||||
Settings are loaded from environment variables with CUSTOMERS_ prefix.
|
||||
|
||||
Example:
|
||||
CUSTOMERS_SETTING_NAME=value
|
||||
|
||||
Usage:
|
||||
from app.modules.customers.config import config
|
||||
value = config.setting_name
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ModuleConfig(BaseSettings):
|
||||
"""Configuration for customers module."""
|
||||
|
||||
# Add module-specific settings here
|
||||
# Example:
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "CUSTOMERS_"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
config_class = ModuleConfig
|
||||
config = ModuleConfig()
|
||||
1
app/modules/customers/migrations/__init__.py
Normal file
1
app/modules/customers/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Module migrations."""
|
||||
1
app/modules/customers/migrations/versions/__init__.py
Normal file
1
app/modules/customers/migrations/versions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Migration versions."""
|
||||
31
app/modules/dev_tools/config.py
Normal file
31
app/modules/dev_tools/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# app/modules/dev_tools/config.py
|
||||
"""
|
||||
Module configuration.
|
||||
|
||||
Environment-based configuration using Pydantic Settings.
|
||||
Settings are loaded from environment variables with DEV_TOOLS_ prefix.
|
||||
|
||||
Example:
|
||||
DEV_TOOLS_SETTING_NAME=value
|
||||
|
||||
Usage:
|
||||
from app.modules.dev_tools.config import config
|
||||
value = config.setting_name
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ModuleConfig(BaseSettings):
|
||||
"""Configuration for dev_tools module."""
|
||||
|
||||
# Add module-specific settings here
|
||||
# Example:
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "DEV_TOOLS_"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
config_class = ModuleConfig
|
||||
config = ModuleConfig()
|
||||
1
app/modules/dev_tools/migrations/__init__.py
Normal file
1
app/modules/dev_tools/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Module migrations."""
|
||||
1
app/modules/dev_tools/migrations/versions/__init__.py
Normal file
1
app/modules/dev_tools/migrations/versions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Migration versions."""
|
||||
190
app/modules/discovery.py
Normal file
190
app/modules/discovery.py
Normal file
@@ -0,0 +1,190 @@
|
||||
# app/modules/discovery.py
|
||||
"""
|
||||
Module auto-discovery system.
|
||||
|
||||
Provides automatic discovery of modules from app/modules/*/definition.py.
|
||||
This enables true plug-and-play modules - just create a module directory
|
||||
with a definition.py file and it will be automatically registered.
|
||||
|
||||
Module Discovery Rules:
|
||||
1. Module must have app/modules/<code>/definition.py
|
||||
2. definition.py must define a ModuleDefinition instance
|
||||
3. The instance variable name should be <code>_module (e.g., cms_module)
|
||||
4. Module tier is determined by is_core and is_internal flags
|
||||
|
||||
Usage:
|
||||
# Auto-discover all modules (replaces manual imports in registry.py)
|
||||
from app.modules.discovery import discover_modules
|
||||
|
||||
MODULES = discover_modules()
|
||||
|
||||
Directory Structure for Auto-Discovery:
|
||||
app/modules/
|
||||
├── cms/
|
||||
│ ├── __init__.py
|
||||
│ ├── definition.py <- Must export ModuleDefinition
|
||||
│ ├── routes/
|
||||
│ │ ├── api/
|
||||
│ │ └── pages/
|
||||
│ ├── services/
|
||||
│ ├── models/
|
||||
│ ├── schemas/
|
||||
│ ├── templates/
|
||||
│ ├── static/
|
||||
│ ├── locales/
|
||||
│ └── tasks/
|
||||
└── analytics/
|
||||
└── ...
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.modules.base import ModuleDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Modules directory
|
||||
MODULES_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def discover_modules() -> dict[str, "ModuleDefinition"]:
|
||||
"""
|
||||
Auto-discover all modules from app/modules/*/definition.py.
|
||||
|
||||
Scans the modules directory for subdirectories containing definition.py,
|
||||
imports them, and extracts ModuleDefinition instances.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping module code to ModuleDefinition
|
||||
|
||||
Example:
|
||||
>>> modules = discover_modules()
|
||||
>>> modules.keys()
|
||||
dict_keys(['cms', 'analytics', 'billing', ...])
|
||||
"""
|
||||
from app.modules.base import ModuleDefinition
|
||||
|
||||
modules: dict[str, ModuleDefinition] = {}
|
||||
|
||||
if not MODULES_DIR.exists():
|
||||
logger.warning(f"Modules directory not found: {MODULES_DIR}")
|
||||
return modules
|
||||
|
||||
for module_dir in sorted(MODULES_DIR.iterdir()):
|
||||
# Skip non-directories and special files
|
||||
if not module_dir.is_dir():
|
||||
continue
|
||||
if module_dir.name.startswith("_") or module_dir.name.startswith("."):
|
||||
continue
|
||||
|
||||
definition_file = module_dir / "definition.py"
|
||||
if not definition_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
# Import the definition module
|
||||
dir_name = module_dir.name
|
||||
import_path = f"app.modules.{dir_name}.definition"
|
||||
definition_module = importlib.import_module(import_path)
|
||||
|
||||
# Find ModuleDefinition instances in the module
|
||||
for attr_name in dir(definition_module):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
|
||||
attr = getattr(definition_module, attr_name)
|
||||
if isinstance(attr, ModuleDefinition):
|
||||
modules[attr.code] = attr
|
||||
logger.debug(f"Discovered module: {attr.code} from {import_path}")
|
||||
break
|
||||
else:
|
||||
logger.warning(f"No ModuleDefinition found in {import_path}")
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import {import_path}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error discovering module in {module_dir}: {e}")
|
||||
|
||||
logger.info(f"Auto-discovered {len(modules)} modules")
|
||||
return modules
|
||||
|
||||
|
||||
def discover_modules_by_tier() -> dict[str, dict[str, "ModuleDefinition"]]:
|
||||
"""
|
||||
Discover modules and organize by tier.
|
||||
|
||||
Returns:
|
||||
Dict with keys 'core', 'optional', 'internal' mapping to module dicts
|
||||
|
||||
Example:
|
||||
>>> by_tier = discover_modules_by_tier()
|
||||
>>> by_tier['core'].keys()
|
||||
dict_keys(['core', 'tenancy', 'cms', 'customers'])
|
||||
"""
|
||||
modules = discover_modules()
|
||||
|
||||
result = {
|
||||
"core": {},
|
||||
"optional": {},
|
||||
"internal": {},
|
||||
}
|
||||
|
||||
for code, module in modules.items():
|
||||
tier = module.get_tier()
|
||||
result[tier][code] = module
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_module_codes() -> set[str]:
|
||||
"""Get all discovered module codes."""
|
||||
return set(discover_modules().keys())
|
||||
|
||||
|
||||
def validate_module_discovery() -> list[str]:
|
||||
"""
|
||||
Validate that all module directories have proper structure.
|
||||
|
||||
Returns:
|
||||
List of validation errors (empty if all valid)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not MODULES_DIR.exists():
|
||||
return ["Modules directory not found"]
|
||||
|
||||
for module_dir in sorted(MODULES_DIR.iterdir()):
|
||||
if not module_dir.is_dir():
|
||||
continue
|
||||
if module_dir.name.startswith("_") or module_dir.name.startswith("."):
|
||||
continue
|
||||
|
||||
# Check for definition.py
|
||||
definition_file = module_dir / "definition.py"
|
||||
if not definition_file.exists():
|
||||
# Only warn if it looks like a module (has __init__.py)
|
||||
if (module_dir / "__init__.py").exists():
|
||||
errors.append(
|
||||
f"Module directory '{module_dir.name}' has __init__.py but no definition.py"
|
||||
)
|
||||
continue
|
||||
|
||||
# Check for __init__.py
|
||||
init_file = module_dir / "__init__.py"
|
||||
if not init_file.exists():
|
||||
errors.append(f"Module '{module_dir.name}' missing __init__.py")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
__all__ = [
|
||||
"discover_modules",
|
||||
"discover_modules_by_tier",
|
||||
"get_module_codes",
|
||||
"validate_module_discovery",
|
||||
"MODULES_DIR",
|
||||
]
|
||||
31
app/modules/inventory/config.py
Normal file
31
app/modules/inventory/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# app/modules/inventory/config.py
|
||||
"""
|
||||
Module configuration.
|
||||
|
||||
Environment-based configuration using Pydantic Settings.
|
||||
Settings are loaded from environment variables with INVENTORY_ prefix.
|
||||
|
||||
Example:
|
||||
INVENTORY_SETTING_NAME=value
|
||||
|
||||
Usage:
|
||||
from app.modules.inventory.config import config
|
||||
value = config.setting_name
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ModuleConfig(BaseSettings):
|
||||
"""Configuration for inventory module."""
|
||||
|
||||
# Add module-specific settings here
|
||||
# Example:
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "INVENTORY_"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
config_class = ModuleConfig
|
||||
config = ModuleConfig()
|
||||
1
app/modules/inventory/migrations/__init__.py
Normal file
1
app/modules/inventory/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Module migrations."""
|
||||
1
app/modules/inventory/migrations/versions/__init__.py
Normal file
1
app/modules/inventory/migrations/versions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Migration versions."""
|
||||
31
app/modules/marketplace/config.py
Normal file
31
app/modules/marketplace/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# app/modules/marketplace/config.py
|
||||
"""
|
||||
Module configuration.
|
||||
|
||||
Environment-based configuration using Pydantic Settings.
|
||||
Settings are loaded from environment variables with MARKETPLACE_ prefix.
|
||||
|
||||
Example:
|
||||
MARKETPLACE_SETTING_NAME=value
|
||||
|
||||
Usage:
|
||||
from app.modules.marketplace.config import config
|
||||
value = config.setting_name
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ModuleConfig(BaseSettings):
|
||||
"""Configuration for marketplace module."""
|
||||
|
||||
# Add module-specific settings here
|
||||
# Example:
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "MARKETPLACE_"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
config_class = ModuleConfig
|
||||
config = ModuleConfig()
|
||||
1
app/modules/marketplace/migrations/__init__.py
Normal file
1
app/modules/marketplace/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Module migrations."""
|
||||
1
app/modules/marketplace/migrations/versions/__init__.py
Normal file
1
app/modules/marketplace/migrations/versions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Migration versions."""
|
||||
31
app/modules/messaging/config.py
Normal file
31
app/modules/messaging/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# app/modules/messaging/config.py
|
||||
"""
|
||||
Module configuration.
|
||||
|
||||
Environment-based configuration using Pydantic Settings.
|
||||
Settings are loaded from environment variables with MESSAGING_ prefix.
|
||||
|
||||
Example:
|
||||
MESSAGING_SETTING_NAME=value
|
||||
|
||||
Usage:
|
||||
from app.modules.messaging.config import config
|
||||
value = config.setting_name
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ModuleConfig(BaseSettings):
|
||||
"""Configuration for messaging module."""
|
||||
|
||||
# Add module-specific settings here
|
||||
# Example:
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "MESSAGING_"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
config_class = ModuleConfig
|
||||
config = ModuleConfig()
|
||||
1
app/modules/messaging/migrations/__init__.py
Normal file
1
app/modules/messaging/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Module migrations."""
|
||||
1
app/modules/messaging/migrations/versions/__init__.py
Normal file
1
app/modules/messaging/migrations/versions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Migration versions."""
|
||||
31
app/modules/monitoring/config.py
Normal file
31
app/modules/monitoring/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# app/modules/monitoring/config.py
|
||||
"""
|
||||
Module configuration.
|
||||
|
||||
Environment-based configuration using Pydantic Settings.
|
||||
Settings are loaded from environment variables with MONITORING_ prefix.
|
||||
|
||||
Example:
|
||||
MONITORING_SETTING_NAME=value
|
||||
|
||||
Usage:
|
||||
from app.modules.monitoring.config import config
|
||||
value = config.setting_name
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ModuleConfig(BaseSettings):
|
||||
"""Configuration for monitoring module."""
|
||||
|
||||
# Add module-specific settings here
|
||||
# Example:
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "MONITORING_"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
config_class = ModuleConfig
|
||||
config = ModuleConfig()
|
||||
1
app/modules/monitoring/migrations/__init__.py
Normal file
1
app/modules/monitoring/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Module migrations."""
|
||||
1
app/modules/monitoring/migrations/versions/__init__.py
Normal file
1
app/modules/monitoring/migrations/versions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Migration versions."""
|
||||
31
app/modules/orders/config.py
Normal file
31
app/modules/orders/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# app/modules/orders/config.py
|
||||
"""
|
||||
Module configuration.
|
||||
|
||||
Environment-based configuration using Pydantic Settings.
|
||||
Settings are loaded from environment variables with ORDERS_ prefix.
|
||||
|
||||
Example:
|
||||
ORDERS_SETTING_NAME=value
|
||||
|
||||
Usage:
|
||||
from app.modules.orders.config import config
|
||||
value = config.setting_name
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ModuleConfig(BaseSettings):
|
||||
"""Configuration for orders module."""
|
||||
|
||||
# Add module-specific settings here
|
||||
# Example:
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "ORDERS_"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
config_class = ModuleConfig
|
||||
config = ModuleConfig()
|
||||
1
app/modules/orders/migrations/__init__.py
Normal file
1
app/modules/orders/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Module migrations."""
|
||||
1
app/modules/orders/migrations/versions/__init__.py
Normal file
1
app/modules/orders/migrations/versions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Migration versions."""
|
||||
31
app/modules/payments/config.py
Normal file
31
app/modules/payments/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# app/modules/payments/config.py
|
||||
"""
|
||||
Module configuration.
|
||||
|
||||
Environment-based configuration using Pydantic Settings.
|
||||
Settings are loaded from environment variables with PAYMENTS_ prefix.
|
||||
|
||||
Example:
|
||||
PAYMENTS_SETTING_NAME=value
|
||||
|
||||
Usage:
|
||||
from app.modules.payments.config import config
|
||||
value = config.setting_name
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ModuleConfig(BaseSettings):
|
||||
"""Configuration for payments module."""
|
||||
|
||||
# Add module-specific settings here
|
||||
# Example:
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "PAYMENTS_"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
config_class = ModuleConfig
|
||||
config = ModuleConfig()
|
||||
1
app/modules/payments/migrations/__init__.py
Normal file
1
app/modules/payments/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Module migrations."""
|
||||
1
app/modules/payments/migrations/versions/__init__.py
Normal file
1
app/modules/payments/migrations/versions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Migration versions."""
|
||||
248
app/modules/routes.py
Normal file
248
app/modules/routes.py
Normal file
@@ -0,0 +1,248 @@
|
||||
# app/modules/routes.py
|
||||
"""
|
||||
Module route discovery for FastAPI.
|
||||
|
||||
Provides utilities to:
|
||||
- Discover route modules from all registered modules
|
||||
- Auto-register API and page routes from self-contained modules
|
||||
|
||||
This module bridges the gap between the module system and FastAPI,
|
||||
allowing routes to be defined within modules and automatically
|
||||
discovered and registered.
|
||||
|
||||
Usage:
|
||||
# In main.py
|
||||
from app.modules.routes import discover_module_routes
|
||||
|
||||
# Auto-discover and register routes
|
||||
for route_info in discover_module_routes():
|
||||
app.include_router(
|
||||
route_info["router"],
|
||||
prefix=route_info["prefix"],
|
||||
tags=route_info["tags"],
|
||||
include_in_schema=route_info.get("include_in_schema", True),
|
||||
)
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import APIRouter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteInfo:
|
||||
"""Information about a discovered route."""
|
||||
|
||||
router: "APIRouter"
|
||||
prefix: str
|
||||
tags: list[str]
|
||||
include_in_schema: bool = True
|
||||
module_code: str = ""
|
||||
route_type: str = "" # "api" or "pages"
|
||||
frontend: str = "" # "admin", "vendor", "shop"
|
||||
|
||||
|
||||
def discover_module_routes() -> list[RouteInfo]:
|
||||
"""
|
||||
Discover routes from all registered self-contained modules.
|
||||
|
||||
Scans all modules in the registry and returns RouteInfo objects for
|
||||
modules that have routes directories.
|
||||
|
||||
Route discovery looks for:
|
||||
- routes/api/admin.py -> admin API routes
|
||||
- routes/api/vendor.py -> vendor API routes
|
||||
- routes/api/shop.py -> shop API routes
|
||||
- routes/pages/admin.py -> admin page routes
|
||||
- routes/pages/vendor.py -> vendor page routes
|
||||
|
||||
Returns:
|
||||
List of RouteInfo objects with router and registration info
|
||||
|
||||
Example:
|
||||
>>> routes = discover_module_routes()
|
||||
>>> for route in routes:
|
||||
... print(f"{route.module_code}: {route.route_type}/{route.frontend}")
|
||||
analytics: pages/vendor
|
||||
cms: api/admin
|
||||
cms: api/vendor
|
||||
cms: pages/admin
|
||||
cms: pages/vendor
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
routes: list[RouteInfo] = []
|
||||
|
||||
for module in MODULES.values():
|
||||
if not module.is_self_contained:
|
||||
continue
|
||||
|
||||
module_routes = _discover_module_routes(module.code)
|
||||
routes.extend(module_routes)
|
||||
|
||||
logger.info(f"Discovered {len(routes)} routes from self-contained modules")
|
||||
return routes
|
||||
|
||||
|
||||
def _discover_module_routes(module_code: str) -> list[RouteInfo]:
|
||||
"""
|
||||
Discover routes for a specific module.
|
||||
|
||||
Args:
|
||||
module_code: Module code (e.g., "analytics", "cms")
|
||||
|
||||
Returns:
|
||||
List of RouteInfo for this module
|
||||
"""
|
||||
routes: list[RouteInfo] = []
|
||||
dir_name = module_code.replace("-", "_")
|
||||
module_path = Path(__file__).parent / dir_name
|
||||
|
||||
if not module_path.exists():
|
||||
return routes
|
||||
|
||||
routes_path = module_path / "routes"
|
||||
if not routes_path.exists():
|
||||
return routes
|
||||
|
||||
# Discover API routes
|
||||
api_routes = _discover_routes_in_dir(
|
||||
module_code, dir_name, routes_path / "api", "api"
|
||||
)
|
||||
routes.extend(api_routes)
|
||||
|
||||
# Discover page routes
|
||||
page_routes = _discover_routes_in_dir(
|
||||
module_code, dir_name, routes_path / "pages", "pages"
|
||||
)
|
||||
routes.extend(page_routes)
|
||||
|
||||
return routes
|
||||
|
||||
|
||||
def _discover_routes_in_dir(
|
||||
module_code: str, dir_name: str, routes_dir: Path, route_type: str
|
||||
) -> list[RouteInfo]:
|
||||
"""
|
||||
Discover routes in a specific directory (api/ or pages/).
|
||||
|
||||
Args:
|
||||
module_code: Module code for tags
|
||||
dir_name: Directory name (module_code with _ instead of -)
|
||||
routes_dir: Path to routes/api/ or routes/pages/
|
||||
route_type: "api" or "pages"
|
||||
|
||||
Returns:
|
||||
List of RouteInfo for discovered routes
|
||||
"""
|
||||
routes: list[RouteInfo] = []
|
||||
|
||||
if not routes_dir.exists():
|
||||
return routes
|
||||
|
||||
# Look for admin.py, vendor.py, shop.py
|
||||
frontends = {
|
||||
"admin": {
|
||||
"api_prefix": "/api/v1/admin",
|
||||
"pages_prefix": "/admin",
|
||||
"include_in_schema": True,
|
||||
},
|
||||
"vendor": {
|
||||
"api_prefix": "/api/v1/vendor",
|
||||
"pages_prefix": "/vendor",
|
||||
"include_in_schema": True if route_type == "api" else False,
|
||||
},
|
||||
"shop": {
|
||||
"api_prefix": "/api/v1/shop",
|
||||
"pages_prefix": "/shop",
|
||||
"include_in_schema": True if route_type == "api" else False,
|
||||
},
|
||||
}
|
||||
|
||||
for frontend, config in frontends.items():
|
||||
route_file = routes_dir / f"{frontend}.py"
|
||||
if not route_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
# Import the module
|
||||
import_path = f"app.modules.{dir_name}.routes.{route_type}.{frontend}"
|
||||
route_module = importlib.import_module(import_path)
|
||||
|
||||
# Get the router (try common names)
|
||||
router = None
|
||||
for attr_name in ["router", f"{frontend}_router", "api_router", "page_router"]:
|
||||
if hasattr(route_module, attr_name):
|
||||
router = getattr(route_module, attr_name)
|
||||
break
|
||||
|
||||
if router is None:
|
||||
logger.warning(f"No router found in {import_path}")
|
||||
continue
|
||||
|
||||
# Determine prefix based on route type
|
||||
if route_type == "api":
|
||||
prefix = config["api_prefix"]
|
||||
else:
|
||||
prefix = config["pages_prefix"]
|
||||
|
||||
# Build tags
|
||||
tags = [f"{module_code}-{frontend}-{route_type}"]
|
||||
|
||||
route_info = RouteInfo(
|
||||
router=router,
|
||||
prefix=prefix,
|
||||
tags=tags,
|
||||
include_in_schema=config["include_in_schema"],
|
||||
module_code=module_code,
|
||||
route_type=route_type,
|
||||
frontend=frontend,
|
||||
)
|
||||
routes.append(route_info)
|
||||
|
||||
logger.debug(f"Discovered route: {module_code} {route_type}/{frontend}")
|
||||
|
||||
except ImportError as e:
|
||||
logger.warning(f"Failed to import {import_path}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error discovering routes in {route_file}: {e}")
|
||||
|
||||
return routes
|
||||
|
||||
|
||||
def get_api_routes() -> list[RouteInfo]:
|
||||
"""Get only API routes from modules."""
|
||||
return [r for r in discover_module_routes() if r.route_type == "api"]
|
||||
|
||||
|
||||
def get_page_routes() -> list[RouteInfo]:
|
||||
"""Get only page routes from modules."""
|
||||
return [r for r in discover_module_routes() if r.route_type == "pages"]
|
||||
|
||||
|
||||
def get_vendor_page_routes() -> list[RouteInfo]:
|
||||
"""Get vendor page routes from modules."""
|
||||
return [r for r in discover_module_routes() if r.route_type == "pages" and r.frontend == "vendor"]
|
||||
|
||||
|
||||
def get_admin_page_routes() -> list[RouteInfo]:
|
||||
"""Get admin page routes from modules."""
|
||||
return [r for r in discover_module_routes() if r.route_type == "pages" and r.frontend == "admin"]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RouteInfo",
|
||||
"discover_module_routes",
|
||||
"get_api_routes",
|
||||
"get_page_routes",
|
||||
"get_vendor_page_routes",
|
||||
"get_admin_page_routes",
|
||||
]
|
||||
11
app/modules/tenancy/__init__.py
Normal file
11
app/modules/tenancy/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# app/modules/tenancy/__init__.py
|
||||
"""
|
||||
Tenancy Management module.
|
||||
|
||||
Provides platform, company, vendor, and admin user management.
|
||||
This is a core module that cannot be disabled.
|
||||
"""
|
||||
|
||||
from app.modules.tenancy.definition import tenancy_module
|
||||
|
||||
__all__ = ["tenancy_module"]
|
||||
37
app/modules/tenancy/definition.py
Normal file
37
app/modules/tenancy/definition.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# app/modules/tenancy/definition.py
|
||||
"""
|
||||
Tenancy Management module definition.
|
||||
|
||||
Platform, company, vendor, and admin user management.
|
||||
Required for multi-tenant operation - cannot be disabled.
|
||||
"""
|
||||
|
||||
from app.modules.base import ModuleDefinition
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
|
||||
tenancy_module = ModuleDefinition(
|
||||
code="tenancy",
|
||||
name="Tenancy Management",
|
||||
description="Platform, company, vendor, and admin user management. Required for multi-tenant operation.",
|
||||
version="1.0.0",
|
||||
is_core=True,
|
||||
features=[
|
||||
"platform_management",
|
||||
"company_management",
|
||||
"vendor_management",
|
||||
"admin_user_management",
|
||||
],
|
||||
menu_items={
|
||||
FrontendType.ADMIN: [
|
||||
"platforms",
|
||||
"companies",
|
||||
"vendors",
|
||||
"admin-users",
|
||||
],
|
||||
FrontendType.VENDOR: [
|
||||
"team",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
__all__ = ["tenancy_module"]
|
||||
Reference in New Issue
Block a user