Files
orion/app/modules/base.py
Samir Boulahtit 2ce19e66b1 feat: implement self-contained module architecture (Phase 1 & 2)
Phase 1 - Foundation:
- Add app/modules/contracts/ with Protocol definitions for cross-module
  communication (ServiceProtocol, ContentServiceProtocol, MediaServiceProtocol)
- Enhance app/modules/base.py ModuleDefinition with self-contained module
  support (is_self_contained, services_path, models_path, etc.)
- Update app/templates_config.py with multi-directory template loading
  using Jinja2 ChoiceLoader for module templates

Phase 2 - CMS Pilot Module:
- Migrate CMS service to app/modules/cms/services/content_page_service.py
- Create app/modules/cms/exceptions.py with CMS-specific exceptions
- Configure app/modules/cms/models/ to re-export ContentPage from canonical
  location (models.database) to avoid circular imports
- Update cms_module definition with is_self_contained=True and paths
- Add backwards compatibility shims with deprecation warnings:
  - app/services/content_page_service.py -> app.modules.cms.services
  - app/exceptions/content_page.py -> app.modules.cms.exceptions

Note: SQLAlchemy models remain in models/database/ as the canonical location
to avoid circular imports at startup time. Module model packages re-export
from the canonical location.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:35:36 +01:00

243 lines
8.6 KiB
Python

# app/modules/base.py
"""
Base module definition class.
A Module is a self-contained unit of functionality that can be enabled/disabled
per platform. Each module contains:
- Features: Granular capabilities for tier-based access control
- Menu items: Sidebar entries per frontend type
- Routes: API and page routes (future: dynamically registered)
- Services: Business logic (optional, for self-contained modules)
- Models: Database models (optional, for self-contained modules)
- Schemas: Pydantic schemas (optional, for self-contained modules)
- Templates: Jinja2 templates (optional, for self-contained modules)
Self-Contained Module Structure:
app/modules/<code>/
├── __init__.py
├── definition.py # ModuleDefinition instance
├── config.py # Module configuration schema (optional)
├── exceptions.py # Module-specific exceptions (optional)
├── routes/ # FastAPI routers
├── services/ # Business logic
├── models/ # SQLAlchemy models
├── schemas/ # Pydantic schemas
└── templates/ # Jinja2 templates (namespaced)
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fastapi import APIRouter
from models.database.admin_menu_config import FrontendType
@dataclass
class ModuleDefinition:
"""
Definition of a platform module.
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.
Attributes:
code: Unique identifier (e.g., "billing", "marketplace")
name: Display name (e.g., "Billing & Subscriptions")
description: Description of what this module provides
requires: List of module codes this module depends on
features: List of feature codes this module provides
menu_items: Dict mapping FrontendType to list of menu item IDs
is_core: Core modules cannot be disabled
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")
is_self_contained: Whether module uses self-contained structure
Example (traditional thin wrapper):
billing_module = ModuleDefinition(
code="billing",
name="Billing & Subscriptions",
description="Subscription tiers, billing history, and payment processing",
features=["subscription_management", "billing_history", "stripe_integration"],
menu_items={
FrontendType.ADMIN: ["subscription-tiers", "subscriptions", "billing-history"],
FrontendType.VENDOR: ["billing"],
},
)
Example (self-contained module):
cms_module = ModuleDefinition(
code="cms",
name="Content Management",
description="Content pages, media library, and vendor themes.",
features=["cms_basic", "cms_custom_pages"],
menu_items={
FrontendType.ADMIN: ["content-pages"],
FrontendType.VENDOR: ["content-pages", "media"],
},
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",
)
"""
# Identity
code: str
name: str
description: str = ""
# 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)
# Status
is_core: bool = False
# Routes (registered dynamically)
admin_router: "APIRouter | None" = None
vendor_router: "APIRouter | None" = None
# 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
def get_menu_items(self, frontend_type: FrontendType) -> list[str]:
"""Get menu item IDs for a specific frontend type."""
return self.menu_items.get(frontend_type, [])
def get_all_menu_items(self) -> set[str]:
"""Get all menu item IDs across all frontend types."""
all_items = set()
for items in self.menu_items.values():
all_items.update(items)
return all_items
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 check_dependencies(self, enabled_modules: set[str]) -> list[str]:
"""
Check if all required modules are enabled.
Args:
enabled_modules: Set of enabled module codes
Returns:
List of missing required module codes
"""
return [req for req in self.requires if req not in enabled_modules]
# =========================================================================
# Self-Contained Module Methods
# =========================================================================
def get_module_dir(self) -> Path:
"""
Get the filesystem path to this module's directory.
Returns:
Path to app/modules/<code>/
"""
return Path(__file__).parent / self.code
def get_templates_dir(self) -> Path | None:
"""
Get the filesystem path to this module's templates directory.
Returns:
Path to templates directory, or None if not self-contained
"""
if not self.is_self_contained or not self.templates_path:
return None
return self.get_module_dir() / self.templates_path
def get_import_path(self, component: str) -> str | None:
"""
Get the Python import path for a module component.
Args:
component: One of "services", "models", "schemas", "exceptions"
Returns:
Import path string, or None if not configured
"""
paths = {
"services": self.services_path,
"models": self.models_path,
"schemas": self.schemas_path,
"exceptions": self.exceptions_path,
}
return paths.get(component)
def validate_structure(self) -> list[str]:
"""
Validate that self-contained module has expected directory structure.
Returns:
List of missing or invalid paths (empty if valid)
"""
if not self.is_self_contained:
return []
issues = []
module_dir = self.get_module_dir()
if not module_dir.exists():
issues.append(f"Module directory not found: {module_dir}")
return issues
# Check optional directories
expected_dirs = []
if self.services_path:
expected_dirs.append("services")
if self.models_path:
expected_dirs.append("models")
if self.schemas_path:
expected_dirs.append("schemas")
if self.templates_path:
expected_dirs.append(self.templates_path)
for dir_name in expected_dirs:
dir_path = module_dir / dir_name
if not dir_path.exists():
issues.append(f"Missing directory: {dir_path}")
return issues
def __hash__(self) -> int:
return hash(self.code)
def __eq__(self, other: object) -> bool:
if isinstance(other, ModuleDefinition):
return self.code == other.code
return False
def __repr__(self) -> str:
sc = ", self_contained" if self.is_self_contained else ""
return f"<Module({self.code}, core={self.is_core}{sc})>"