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:
@@ -1,88 +1,43 @@
|
|||||||
# app/exceptions/content_page.py
|
# app/exceptions/content_page.py
|
||||||
"""
|
"""
|
||||||
Content Page Domain Exceptions
|
DEPRECATED: This module has moved to app.modules.cms.exceptions
|
||||||
|
|
||||||
These exceptions are raised by the content page service layer
|
Please update your imports:
|
||||||
and converted to HTTP responses by the global exception handler.
|
# Old (deprecated):
|
||||||
|
from app.exceptions.content_page import ContentPageNotFoundException
|
||||||
|
|
||||||
|
# New (preferred):
|
||||||
|
from app.modules.cms.exceptions import ContentPageNotFoundException
|
||||||
|
|
||||||
|
This shim re-exports from the new location for backwards compatibility.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.exceptions.base import (
|
import warnings
|
||||||
AuthorizationException,
|
|
||||||
BusinessLogicException,
|
warnings.warn(
|
||||||
ConflictException,
|
"Import from app.modules.cms.exceptions instead of "
|
||||||
ResourceNotFoundException,
|
"app.exceptions.content_page. This shim will be removed in a future version.",
|
||||||
ValidationException,
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Re-export everything from the new location
|
||||||
|
from app.modules.cms.exceptions import ( # noqa: E402, F401
|
||||||
|
ContentPageAlreadyExistsException,
|
||||||
|
ContentPageNotFoundException,
|
||||||
|
ContentPageNotPublishedException,
|
||||||
|
ContentPageSlugReservedException,
|
||||||
|
ContentPageValidationException,
|
||||||
|
UnauthorizedContentPageAccessException,
|
||||||
|
VendorNotAssociatedException,
|
||||||
|
)
|
||||||
|
|
||||||
class ContentPageNotFoundException(ResourceNotFoundException):
|
__all__ = [
|
||||||
"""Raised when a content page is not found."""
|
"ContentPageNotFoundException",
|
||||||
|
"ContentPageAlreadyExistsException",
|
||||||
def __init__(self, identifier: str | int | None = None):
|
"ContentPageSlugReservedException",
|
||||||
if identifier:
|
"ContentPageNotPublishedException",
|
||||||
message = f"Content page not found: {identifier}"
|
"UnauthorizedContentPageAccessException",
|
||||||
else:
|
"VendorNotAssociatedException",
|
||||||
message = "Content page not found"
|
"ContentPageValidationException",
|
||||||
super().__init__(
|
]
|
||||||
message=message,
|
|
||||||
resource_type="content_page",
|
|
||||||
identifier=str(identifier) if identifier else "unknown",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ContentPageAlreadyExistsException(ConflictException):
|
|
||||||
"""Raised when a content page with the same slug already exists."""
|
|
||||||
|
|
||||||
def __init__(self, slug: str, vendor_id: int | None = None):
|
|
||||||
if vendor_id:
|
|
||||||
message = f"Content page with slug '{slug}' already exists for this vendor"
|
|
||||||
else:
|
|
||||||
message = f"Platform content page with slug '{slug}' already exists"
|
|
||||||
super().__init__(message=message)
|
|
||||||
|
|
||||||
|
|
||||||
class ContentPageSlugReservedException(ValidationException):
|
|
||||||
"""Raised when trying to use a reserved slug."""
|
|
||||||
|
|
||||||
def __init__(self, slug: str):
|
|
||||||
super().__init__(
|
|
||||||
message=f"Content page slug '{slug}' is reserved",
|
|
||||||
field="slug",
|
|
||||||
value=slug,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ContentPageNotPublishedException(BusinessLogicException):
|
|
||||||
"""Raised when trying to access an unpublished content page."""
|
|
||||||
|
|
||||||
def __init__(self, slug: str):
|
|
||||||
super().__init__(message=f"Content page '{slug}' is not published")
|
|
||||||
|
|
||||||
|
|
||||||
class UnauthorizedContentPageAccessException(AuthorizationException):
|
|
||||||
"""Raised when a user tries to access/modify a content page they don't own."""
|
|
||||||
|
|
||||||
def __init__(self, action: str = "access"):
|
|
||||||
super().__init__(
|
|
||||||
message=f"Cannot {action} content pages from other vendors",
|
|
||||||
error_code="CONTENT_PAGE_ACCESS_DENIED",
|
|
||||||
details={"required_permission": f"content_page:{action}"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VendorNotAssociatedException(AuthorizationException):
|
|
||||||
"""Raised when a user is not associated with a vendor."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
message="User is not associated with a vendor",
|
|
||||||
error_code="VENDOR_NOT_ASSOCIATED",
|
|
||||||
details={"required_permission": "vendor:member"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ContentPageValidationException(ValidationException):
|
|
||||||
"""Raised when content page data validation fails."""
|
|
||||||
|
|
||||||
def __init__(self, field: str, message: str, value: str | None = None):
|
|
||||||
super().__init__(message=message, field=field, value=value)
|
|
||||||
|
|||||||
@@ -7,9 +7,26 @@ per platform. Each module contains:
|
|||||||
- Features: Granular capabilities for tier-based access control
|
- Features: Granular capabilities for tier-based access control
|
||||||
- Menu items: Sidebar entries per frontend type
|
- Menu items: Sidebar entries per frontend type
|
||||||
- Routes: API and page routes (future: dynamically registered)
|
- 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 dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -26,6 +43,9 @@ class ModuleDefinition:
|
|||||||
A module groups related functionality that can be enabled/disabled per platform.
|
A module groups related functionality that can be enabled/disabled per platform.
|
||||||
Core modules cannot be disabled and are always available.
|
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:
|
Attributes:
|
||||||
code: Unique identifier (e.g., "billing", "marketplace")
|
code: Unique identifier (e.g., "billing", "marketplace")
|
||||||
name: Display name (e.g., "Billing & Subscriptions")
|
name: Display name (e.g., "Billing & Subscriptions")
|
||||||
@@ -34,10 +54,16 @@ class ModuleDefinition:
|
|||||||
features: List of feature codes this module provides
|
features: List of feature codes this module provides
|
||||||
menu_items: Dict mapping FrontendType to list of menu item IDs
|
menu_items: Dict mapping FrontendType to list of menu item IDs
|
||||||
is_core: Core modules cannot be disabled
|
is_core: Core modules cannot be disabled
|
||||||
admin_router: FastAPI router for admin routes (future)
|
admin_router: FastAPI router for admin routes
|
||||||
vendor_router: FastAPI router for vendor routes (future)
|
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(
|
billing_module = ModuleDefinition(
|
||||||
code="billing",
|
code="billing",
|
||||||
name="Billing & Subscriptions",
|
name="Billing & Subscriptions",
|
||||||
@@ -48,6 +74,24 @@ class ModuleDefinition:
|
|||||||
FrontendType.VENDOR: ["billing"],
|
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
|
# Identity
|
||||||
@@ -65,10 +109,18 @@ class ModuleDefinition:
|
|||||||
# Status
|
# Status
|
||||||
is_core: bool = False
|
is_core: bool = False
|
||||||
|
|
||||||
# Routes (registered dynamically) - Future implementation
|
# Routes (registered dynamically)
|
||||||
admin_router: "APIRouter | None" = None
|
admin_router: "APIRouter | None" = None
|
||||||
vendor_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]:
|
def get_menu_items(self, frontend_type: FrontendType) -> list[str]:
|
||||||
"""Get menu item IDs for a specific frontend type."""
|
"""Get menu item IDs for a specific frontend type."""
|
||||||
return self.menu_items.get(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]
|
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:
|
def __hash__(self) -> int:
|
||||||
return hash(self.code)
|
return hash(self.code)
|
||||||
|
|
||||||
@@ -109,4 +238,5 @@ class ModuleDefinition:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
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})>"
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
"""
|
"""
|
||||||
CMS Module - Content Management System.
|
CMS Module - Content Management System.
|
||||||
|
|
||||||
|
This is a SELF-CONTAINED module that includes:
|
||||||
|
- Services: content_page_service (business logic)
|
||||||
|
- Models: ContentPage (database model)
|
||||||
|
- Exceptions: ContentPageNotFoundException, etc.
|
||||||
|
|
||||||
This module provides:
|
This module provides:
|
||||||
- Content pages management
|
- Content pages management (three-tier: platform, vendor default, vendor override)
|
||||||
- Media library
|
- Media library
|
||||||
- Vendor themes
|
- Vendor themes
|
||||||
- SEO tools
|
- SEO tools
|
||||||
@@ -15,6 +20,16 @@ Routes:
|
|||||||
Menu Items:
|
Menu Items:
|
||||||
- Admin: content-pages, vendor-themes
|
- Admin: content-pages, vendor-themes
|
||||||
- Vendor: content-pages, media
|
- Vendor: content-pages, media
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Preferred: Import from module directly
|
||||||
|
from app.modules.cms.services import content_page_service
|
||||||
|
from app.modules.cms.models import ContentPage
|
||||||
|
from app.modules.cms.exceptions import ContentPageNotFoundException
|
||||||
|
|
||||||
|
# Legacy: Still works via re-export shims (deprecated)
|
||||||
|
from app.services.content_page_service import content_page_service
|
||||||
|
from models.database.content_page import ContentPage
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.modules.cms.definition import cms_module
|
from app.modules.cms.definition import cms_module
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
CMS module definition.
|
CMS module definition.
|
||||||
|
|
||||||
Defines the CMS module including its features, menu items,
|
Defines the CMS module including its features, menu items,
|
||||||
and route configurations.
|
route configurations, and self-contained component paths.
|
||||||
|
|
||||||
|
This is a self-contained module with:
|
||||||
|
- Services: app.modules.cms.services
|
||||||
|
- Models: app.modules.cms.models
|
||||||
|
- Exceptions: app.modules.cms.exceptions
|
||||||
|
|
||||||
|
Templates remain in core (app/templates/admin/) for now due to
|
||||||
|
admin/base.html inheritance dependency.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.modules.base import ModuleDefinition
|
from app.modules.base import ModuleDefinition
|
||||||
@@ -24,7 +32,7 @@ def _get_vendor_router():
|
|||||||
return vendor_router
|
return vendor_router
|
||||||
|
|
||||||
|
|
||||||
# CMS module definition
|
# CMS module definition - Self-contained module (pilot)
|
||||||
cms_module = ModuleDefinition(
|
cms_module = ModuleDefinition(
|
||||||
code="cms",
|
code="cms",
|
||||||
name="Content Management",
|
name="Content Management",
|
||||||
@@ -48,6 +56,13 @@ cms_module = ModuleDefinition(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
is_core=False,
|
is_core=False,
|
||||||
|
# Self-contained module configuration
|
||||||
|
is_self_contained=True,
|
||||||
|
services_path="app.modules.cms.services",
|
||||||
|
models_path="app.modules.cms.models",
|
||||||
|
exceptions_path="app.modules.cms.exceptions",
|
||||||
|
# Templates remain in core for now (admin/content-pages*.html)
|
||||||
|
templates_path=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
110
app/modules/cms/exceptions.py
Normal file
110
app/modules/cms/exceptions.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# app/modules/cms/exceptions.py
|
||||||
|
"""
|
||||||
|
CMS Module Exceptions
|
||||||
|
|
||||||
|
These exceptions are raised by the CMS module service layer
|
||||||
|
and converted to HTTP responses by the global exception handler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.exceptions.base import (
|
||||||
|
AuthorizationException,
|
||||||
|
BusinessLogicException,
|
||||||
|
ConflictException,
|
||||||
|
ResourceNotFoundException,
|
||||||
|
ValidationException,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentPageNotFoundException(ResourceNotFoundException):
|
||||||
|
"""Raised when a content page is not found."""
|
||||||
|
|
||||||
|
def __init__(self, identifier: str | int | None = None):
|
||||||
|
if identifier:
|
||||||
|
message = f"Content page not found: {identifier}"
|
||||||
|
else:
|
||||||
|
message = "Content page not found"
|
||||||
|
super().__init__(
|
||||||
|
message=message,
|
||||||
|
resource_type="content_page",
|
||||||
|
identifier=str(identifier) if identifier else "unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentPageAlreadyExistsException(ConflictException):
|
||||||
|
"""Raised when a content page with the same slug already exists."""
|
||||||
|
|
||||||
|
def __init__(self, slug: str, vendor_id: int | None = None):
|
||||||
|
if vendor_id:
|
||||||
|
message = f"Content page with slug '{slug}' already exists for this vendor"
|
||||||
|
else:
|
||||||
|
message = f"Platform content page with slug '{slug}' already exists"
|
||||||
|
super().__init__(message=message)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentPageSlugReservedException(ValidationException):
|
||||||
|
"""Raised when trying to use a reserved slug."""
|
||||||
|
|
||||||
|
def __init__(self, slug: str):
|
||||||
|
super().__init__(
|
||||||
|
message=f"Content page slug '{slug}' is reserved",
|
||||||
|
field="slug",
|
||||||
|
value=slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentPageNotPublishedException(BusinessLogicException):
|
||||||
|
"""Raised when trying to access an unpublished content page."""
|
||||||
|
|
||||||
|
def __init__(self, slug: str):
|
||||||
|
super().__init__(message=f"Content page '{slug}' is not published")
|
||||||
|
|
||||||
|
|
||||||
|
class UnauthorizedContentPageAccessException(AuthorizationException):
|
||||||
|
"""Raised when a user tries to access/modify a content page they don't own."""
|
||||||
|
|
||||||
|
def __init__(self, action: str = "access"):
|
||||||
|
super().__init__(
|
||||||
|
message=f"Cannot {action} content pages from other vendors",
|
||||||
|
error_code="CONTENT_PAGE_ACCESS_DENIED",
|
||||||
|
details={"required_permission": f"content_page:{action}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VendorNotAssociatedException(AuthorizationException):
|
||||||
|
"""Raised when a user is not associated with a vendor."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="User is not associated with a vendor",
|
||||||
|
error_code="VENDOR_NOT_ASSOCIATED",
|
||||||
|
details={"required_permission": "vendor:member"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentPageValidationException(ValidationException):
|
||||||
|
"""Raised when content page data validation fails."""
|
||||||
|
|
||||||
|
def __init__(self, field: str, message: str, value: str | None = None):
|
||||||
|
super().__init__(message=message, field=field, value=value)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaNotFoundException(ResourceNotFoundException):
|
||||||
|
"""Raised when a media item is not found."""
|
||||||
|
|
||||||
|
def __init__(self, identifier: str | int | None = None):
|
||||||
|
if identifier:
|
||||||
|
message = f"Media item not found: {identifier}"
|
||||||
|
else:
|
||||||
|
message = "Media item not found"
|
||||||
|
super().__init__(
|
||||||
|
message=message,
|
||||||
|
resource_type="media",
|
||||||
|
identifier=str(identifier) if identifier else "unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaUploadException(BusinessLogicException):
|
||||||
|
"""Raised when a media upload fails."""
|
||||||
|
|
||||||
|
def __init__(self, reason: str):
|
||||||
|
super().__init__(message=f"Media upload failed: {reason}")
|
||||||
20
app/modules/cms/models/__init__.py
Normal file
20
app/modules/cms/models/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# app/modules/cms/models/__init__.py
|
||||||
|
"""
|
||||||
|
CMS module database models.
|
||||||
|
|
||||||
|
This package re-exports the ContentPage model from its canonical location
|
||||||
|
in models.database. SQLAlchemy models must remain in a single location to
|
||||||
|
avoid circular imports at startup time.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from app.modules.cms.models import ContentPage
|
||||||
|
|
||||||
|
The canonical model is at: models.database.content_page.ContentPage
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Import from canonical location to avoid circular imports
|
||||||
|
from models.database.content_page import ContentPage
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ContentPage",
|
||||||
|
]
|
||||||
16
app/modules/cms/services/__init__.py
Normal file
16
app/modules/cms/services/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# app/modules/cms/services/__init__.py
|
||||||
|
"""
|
||||||
|
CMS module services.
|
||||||
|
|
||||||
|
This package contains all business logic for the CMS module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.modules.cms.services.content_page_service import (
|
||||||
|
ContentPageService,
|
||||||
|
content_page_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ContentPageService",
|
||||||
|
"content_page_service",
|
||||||
|
]
|
||||||
1002
app/modules/cms/services/content_page_service.py
Normal file
1002
app/modules/cms/services/content_page_service.py
Normal file
File diff suppressed because it is too large
Load Diff
32
app/modules/contracts/__init__.py
Normal file
32
app/modules/contracts/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# app/modules/contracts/__init__.py
|
||||||
|
"""
|
||||||
|
Cross-module contracts using Protocol pattern.
|
||||||
|
|
||||||
|
This module defines type-safe interfaces for cross-module communication.
|
||||||
|
Modules depend on protocols rather than concrete implementations, enabling:
|
||||||
|
- Loose coupling between modules
|
||||||
|
- Testability through mock implementations
|
||||||
|
- Clear dependency boundaries
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from app.modules.contracts.cms import ContentServiceProtocol
|
||||||
|
|
||||||
|
class OrderService:
|
||||||
|
def __init__(self, content: ContentServiceProtocol | None = None):
|
||||||
|
self._content = content
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content(self) -> ContentServiceProtocol:
|
||||||
|
if self._content is None:
|
||||||
|
from app.modules.cms.services import content_page_service
|
||||||
|
self._content = content_page_service
|
||||||
|
return self._content
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.modules.contracts.base import ServiceProtocol
|
||||||
|
from app.modules.contracts.cms import ContentServiceProtocol
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ServiceProtocol",
|
||||||
|
"ContentServiceProtocol",
|
||||||
|
]
|
||||||
47
app/modules/contracts/base.py
Normal file
47
app/modules/contracts/base.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# app/modules/contracts/base.py
|
||||||
|
"""
|
||||||
|
Base protocol definitions for cross-module communication.
|
||||||
|
|
||||||
|
Protocols define the interface that services must implement without
|
||||||
|
requiring inheritance. This allows for:
|
||||||
|
- Duck typing with static type checking
|
||||||
|
- Easy mocking in tests
|
||||||
|
- Module independence
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ServiceProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Base protocol for all module services.
|
||||||
|
|
||||||
|
Services should be stateless and operate on database sessions
|
||||||
|
passed as arguments. This ensures:
|
||||||
|
- Thread safety
|
||||||
|
- Transaction boundaries are clear
|
||||||
|
- Easy testing with mock sessions
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CRUDServiceProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Protocol for services that provide CRUD operations.
|
||||||
|
|
||||||
|
All methods receive a database session as the first argument.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_by_id(self, db: "Session", id: int) -> object | None:
|
||||||
|
"""Get entity by ID."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def delete(self, db: "Session", id: int) -> bool:
|
||||||
|
"""Delete entity by ID. Returns True if deleted."""
|
||||||
|
...
|
||||||
148
app/modules/contracts/cms.py
Normal file
148
app/modules/contracts/cms.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# app/modules/contracts/cms.py
|
||||||
|
"""
|
||||||
|
CMS module contracts.
|
||||||
|
|
||||||
|
Defines the interface for content management functionality that other
|
||||||
|
modules can depend on without importing the concrete implementation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ContentServiceProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Protocol for content page service.
|
||||||
|
|
||||||
|
Defines the interface for retrieving and managing content pages
|
||||||
|
with three-tier resolution (platform > vendor default > vendor override).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_page_for_vendor(
|
||||||
|
self,
|
||||||
|
db: "Session",
|
||||||
|
platform_id: int,
|
||||||
|
slug: str,
|
||||||
|
vendor_id: int | None = None,
|
||||||
|
include_unpublished: bool = False,
|
||||||
|
) -> object | None:
|
||||||
|
"""
|
||||||
|
Get content page with three-tier resolution.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. Vendor override (platform_id + vendor_id + slug)
|
||||||
|
2. Vendor default (platform_id + vendor_id=NULL + is_platform_page=False + slug)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
platform_id: Platform ID
|
||||||
|
slug: Page slug
|
||||||
|
vendor_id: Vendor ID (None for defaults only)
|
||||||
|
include_unpublished: Include draft pages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ContentPage or None
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_platform_page(
|
||||||
|
self,
|
||||||
|
db: "Session",
|
||||||
|
platform_id: int,
|
||||||
|
slug: str,
|
||||||
|
include_unpublished: bool = False,
|
||||||
|
) -> object | None:
|
||||||
|
"""
|
||||||
|
Get a platform marketing page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
platform_id: Platform ID
|
||||||
|
slug: Page slug
|
||||||
|
include_unpublished: Include draft pages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ContentPage or None
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_pages_for_vendor(
|
||||||
|
self,
|
||||||
|
db: "Session",
|
||||||
|
platform_id: int,
|
||||||
|
vendor_id: int | None = None,
|
||||||
|
include_unpublished: bool = False,
|
||||||
|
footer_only: bool = False,
|
||||||
|
header_only: bool = False,
|
||||||
|
legal_only: bool = False,
|
||||||
|
) -> list:
|
||||||
|
"""
|
||||||
|
List all available pages for a vendor storefront.
|
||||||
|
|
||||||
|
Merges vendor overrides with vendor defaults, prioritizing overrides.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
platform_id: Platform ID
|
||||||
|
vendor_id: Vendor ID (None for vendor defaults only)
|
||||||
|
include_unpublished: Include draft pages
|
||||||
|
footer_only: Only pages marked for footer display
|
||||||
|
header_only: Only pages marked for header display
|
||||||
|
legal_only: Only pages marked for legal/bottom bar display
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ContentPage objects
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_platform_pages(
|
||||||
|
self,
|
||||||
|
db: "Session",
|
||||||
|
platform_id: int,
|
||||||
|
include_unpublished: bool = False,
|
||||||
|
footer_only: bool = False,
|
||||||
|
header_only: bool = False,
|
||||||
|
) -> list:
|
||||||
|
"""
|
||||||
|
List platform marketing pages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
platform_id: Platform ID
|
||||||
|
include_unpublished: Include draft pages
|
||||||
|
footer_only: Only pages marked for footer display
|
||||||
|
header_only: Only pages marked for header display
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of platform marketing ContentPage objects
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class MediaServiceProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Protocol for media library service.
|
||||||
|
|
||||||
|
Defines the interface for managing media files (images, documents, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_media_by_id(
|
||||||
|
self,
|
||||||
|
db: "Session",
|
||||||
|
media_id: int,
|
||||||
|
) -> object | None:
|
||||||
|
"""Get media item by ID."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_media_for_vendor(
|
||||||
|
self,
|
||||||
|
db: "Session",
|
||||||
|
vendor_id: int,
|
||||||
|
media_type: str | None = None,
|
||||||
|
) -> list:
|
||||||
|
"""List media items for a vendor."""
|
||||||
|
...
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,21 @@ Shared Jinja2 templates configuration.
|
|||||||
|
|
||||||
All route modules should import `templates` from here to ensure
|
All route modules should import `templates` from here to ensure
|
||||||
consistent globals (like translation function) are available.
|
consistent globals (like translation function) are available.
|
||||||
|
|
||||||
|
Template Loading Strategy:
|
||||||
|
- Core templates from app/templates/ (highest priority)
|
||||||
|
- Module templates from app/modules/<module>/templates/ (namespaced)
|
||||||
|
|
||||||
|
Module templates should use namespace prefix to avoid collisions:
|
||||||
|
app/modules/cms/templates/cms/admin/pages.html
|
||||||
|
-> Rendered as: templates.TemplateResponse("cms/admin/pages.html", ...)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||||
|
|
||||||
from app.utils.i18n import (
|
from app.utils.i18n import (
|
||||||
LANGUAGE_FLAGS,
|
LANGUAGE_FLAGS,
|
||||||
@@ -17,11 +28,60 @@ from app.utils.i18n import (
|
|||||||
create_translation_context,
|
create_translation_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Templates directory
|
logger = logging.getLogger(__name__)
|
||||||
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
||||||
|
|
||||||
# Create shared templates instance
|
# Core templates directory
|
||||||
|
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||||
|
MODULES_DIR = Path(__file__).parent / "modules"
|
||||||
|
|
||||||
|
|
||||||
|
def create_template_loaders() -> ChoiceLoader:
|
||||||
|
"""
|
||||||
|
Create a ChoiceLoader that searches multiple template directories.
|
||||||
|
|
||||||
|
Search order:
|
||||||
|
1. Core templates (app/templates/) - highest priority
|
||||||
|
2. Module templates (app/modules/<module>/templates/) - in alphabetical order
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChoiceLoader configured with all template directories
|
||||||
|
"""
|
||||||
|
loaders = [FileSystemLoader(str(TEMPLATES_DIR))] # Core templates first
|
||||||
|
|
||||||
|
# Add module template directories
|
||||||
|
if MODULES_DIR.exists():
|
||||||
|
for module_dir in sorted(MODULES_DIR.iterdir()):
|
||||||
|
if module_dir.is_dir():
|
||||||
|
templates_path = module_dir / "templates"
|
||||||
|
if templates_path.exists() and templates_path.is_dir():
|
||||||
|
loaders.append(FileSystemLoader(str(templates_path)))
|
||||||
|
logger.debug(f"[Templates] Added module templates: {module_dir.name}")
|
||||||
|
|
||||||
|
return ChoiceLoader(loaders)
|
||||||
|
|
||||||
|
|
||||||
|
def get_module_template_dirs() -> list[Path]:
|
||||||
|
"""
|
||||||
|
Get list of all module template directories.
|
||||||
|
|
||||||
|
Useful for debugging and introspection.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Path objects for module template directories
|
||||||
|
"""
|
||||||
|
dirs = []
|
||||||
|
if MODULES_DIR.exists():
|
||||||
|
for module_dir in sorted(MODULES_DIR.iterdir()):
|
||||||
|
if module_dir.is_dir():
|
||||||
|
templates_path = module_dir / "templates"
|
||||||
|
if templates_path.exists() and templates_path.is_dir():
|
||||||
|
dirs.append(templates_path)
|
||||||
|
return dirs
|
||||||
|
|
||||||
|
|
||||||
|
# Create shared templates instance with multi-directory loader
|
||||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
|
templates.env.loader = create_template_loaders()
|
||||||
|
|
||||||
# Add translation function to Jinja2 environment globals
|
# Add translation function to Jinja2 environment globals
|
||||||
# This makes _() available in all templates AND macros
|
# This makes _() available in all templates AND macros
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ Features:
|
|||||||
- SEO metadata
|
- SEO metadata
|
||||||
- Published/Draft status
|
- Published/Draft status
|
||||||
- Navigation placement (header, footer, legal)
|
- Navigation placement (header, footer, legal)
|
||||||
|
|
||||||
|
NOTE: This is the canonical location for the ContentPage model.
|
||||||
|
The CMS module (app.modules.cms) re-exports this model.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -230,8 +233,3 @@ class ContentPage(Base):
|
|||||||
"created_by": self.created_by,
|
"created_by": self.created_by,
|
||||||
"updated_by": self.updated_by,
|
"updated_by": self.updated_by,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Add relationship to Vendor model
|
|
||||||
# This should be added to models/database/vendor.py:
|
|
||||||
# content_pages = relationship("ContentPage", back_populates="vendor", cascade="all, delete-orphan")
|
|
||||||
|
|||||||
Reference in New Issue
Block a user