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>
This commit is contained in:
@@ -7,9 +7,26 @@ 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:
|
||||
@@ -26,6 +43,9 @@ class ModuleDefinition:
|
||||
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")
|
||||
@@ -34,10 +54,16 @@ class ModuleDefinition:
|
||||
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 (future)
|
||||
vendor_router: FastAPI router for vendor routes (future)
|
||||
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:
|
||||
Example (traditional thin wrapper):
|
||||
billing_module = ModuleDefinition(
|
||||
code="billing",
|
||||
name="Billing & Subscriptions",
|
||||
@@ -48,6 +74,24 @@ class ModuleDefinition:
|
||||
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
|
||||
@@ -65,10 +109,18 @@ class ModuleDefinition:
|
||||
# Status
|
||||
is_core: bool = False
|
||||
|
||||
# Routes (registered dynamically) - Future implementation
|
||||
# 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, [])
|
||||
@@ -100,6 +152,83 @@ class ModuleDefinition:
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -109,4 +238,5 @@ class ModuleDefinition:
|
||||
return False
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Module({self.code}, core={self.is_core})>"
|
||||
sc = ", self_contained" if self.is_self_contained else ""
|
||||
return f"<Module({self.code}, core={self.is_core}{sc})>"
|
||||
|
||||
Reference in New Issue
Block a user