Enhance the self-contained module architecture with locale/translation support:
ModuleDefinition changes:
- Add locales_path attribute for module-specific translations
- Add get_locales_dir() helper method
- Include locales in validate_structure() check
i18n module changes (app/utils/i18n.py):
- Add get_module_locale_dirs() to discover module locales
- Update load_translations() to merge module translations with core
- Module translations namespaced under module code (e.g., cms.title)
- Add _deep_merge() helper for nested dictionary merging
- Add _load_json_file() helper for cleaner JSON loading
CMS module locales:
- Add app/modules/cms/locales/ with translations for all 4 languages
- en.json, fr.json, de.json, lb.json with CMS-specific strings
- Covers: pages, page editing, SEO, navigation, publishing, homepage
sections, media library, themes, actions, and messages
Usage in templates:
{{ _("cms.title") }} -> "Content Management" (en)
{{ _("cms.pages.create") }} -> "Créer une page" (fr)
{{ _("cms.publishing.draft") }} -> "Entwurf" (de)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
259 lines
9.3 KiB
Python
259 lines
9.3 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")
|
|
locales_path: Path to locales directory (relative to module, e.g., "locales")
|
|
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",
|
|
locales_path="locales",
|
|
)
|
|
"""
|
|
|
|
# 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
|
|
locales_path: str | None = None # Relative to module directory, e.g., "locales"
|
|
|
|
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_locales_dir(self) -> Path | None:
|
|
"""
|
|
Get the filesystem path to this module's locales directory.
|
|
|
|
Returns:
|
|
Path to locales directory, or None if not configured
|
|
"""
|
|
if not self.is_self_contained or not self.locales_path:
|
|
return None
|
|
return self.get_module_dir() / self.locales_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)
|
|
if self.locales_path:
|
|
expected_dirs.append(self.locales_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})>"
|