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:
2026-01-26 21:35:36 +01:00
parent 4cf37add1b
commit 2ce19e66b1
14 changed files with 1663 additions and 1086 deletions

View File

@@ -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)

View File

@@ -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})>"

View File

@@ -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

View File

@@ -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,
) )

View 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}")

View 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",
]

View 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",
]

File diff suppressed because it is too large Load Diff

View 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",
]

View 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."""
...

View 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

View File

@@ -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

View File

@@ -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")