- Add "Show in Legal" checkbox to content page editor UI - Update API schemas (ContentPageCreate, ContentPageUpdate, ContentPageResponse) - Add show_in_legal parameter to service methods (create_page, update_page, etc.) - Fix ContentPageNotFoundException to pass identifier correctly - Fix UnauthorizedContentPageAccessException to use correct AuthorizationException API - Add comprehensive unit tests for ContentPageService (35 tests) - Add content page fixtures for testing - Update CMS documentation with navigation categories diagram 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
607 lines
19 KiB
Python
607 lines
19 KiB
Python
# app/services/content_page_service.py
|
|
"""
|
|
Content Page Service
|
|
|
|
Business logic for managing content pages with platform defaults
|
|
and vendor-specific overrides.
|
|
|
|
Lookup Strategy:
|
|
1. Check for vendor-specific override (vendor_id + slug + published)
|
|
2. If not found, check for platform default (slug + published)
|
|
3. If neither exists, return None
|
|
|
|
This allows:
|
|
- Platform admin to create default content for all vendors
|
|
- Vendors to override specific pages with custom content
|
|
- Fallback to platform defaults when vendor hasn't customized
|
|
"""
|
|
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
|
|
from sqlalchemy import and_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.exceptions.content_page import (
|
|
ContentPageNotFoundException,
|
|
UnauthorizedContentPageAccessException,
|
|
)
|
|
from models.database.content_page import ContentPage
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ContentPageService:
|
|
"""Service for content page operations."""
|
|
|
|
@staticmethod
|
|
def get_page_for_vendor(
|
|
db: Session,
|
|
slug: str,
|
|
vendor_id: int | None = None,
|
|
include_unpublished: bool = False,
|
|
) -> ContentPage | None:
|
|
"""
|
|
Get content page for a vendor with fallback to platform default.
|
|
|
|
Lookup order:
|
|
1. Vendor-specific override (vendor_id + slug)
|
|
2. Platform default (vendor_id=NULL + slug)
|
|
|
|
Args:
|
|
db: Database session
|
|
slug: Page slug (about, faq, contact, etc.)
|
|
vendor_id: Vendor ID (None for platform defaults only)
|
|
include_unpublished: Include draft pages (for preview)
|
|
|
|
Returns:
|
|
ContentPage or None
|
|
"""
|
|
filters = [ContentPage.slug == slug]
|
|
|
|
if not include_unpublished:
|
|
filters.append(ContentPage.is_published == True)
|
|
|
|
# Try vendor-specific override first
|
|
if vendor_id:
|
|
vendor_page = (
|
|
db.query(ContentPage)
|
|
.filter(and_(ContentPage.vendor_id == vendor_id, *filters))
|
|
.first()
|
|
)
|
|
|
|
if vendor_page:
|
|
logger.debug(
|
|
f"Found vendor-specific page: {slug} for vendor_id={vendor_id}"
|
|
)
|
|
return vendor_page
|
|
|
|
# Fallback to platform default
|
|
platform_page = (
|
|
db.query(ContentPage)
|
|
.filter(and_(ContentPage.vendor_id == None, *filters))
|
|
.first()
|
|
)
|
|
|
|
if platform_page:
|
|
logger.debug(f"Using platform default page: {slug}")
|
|
else:
|
|
logger.debug(f"No page found for slug: {slug}")
|
|
|
|
return platform_page
|
|
|
|
@staticmethod
|
|
def list_pages_for_vendor(
|
|
db: Session,
|
|
vendor_id: int | None = None,
|
|
include_unpublished: bool = False,
|
|
footer_only: bool = False,
|
|
header_only: bool = False,
|
|
legal_only: bool = False,
|
|
) -> list[ContentPage]:
|
|
"""
|
|
List all available pages for a vendor (includes vendor overrides + platform defaults).
|
|
|
|
Merges vendor-specific overrides with platform defaults, prioritizing vendor overrides.
|
|
|
|
Args:
|
|
db: Database session
|
|
vendor_id: Vendor ID (None for platform pages 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
|
|
"""
|
|
filters = []
|
|
|
|
if not include_unpublished:
|
|
filters.append(ContentPage.is_published == True)
|
|
|
|
if footer_only:
|
|
filters.append(ContentPage.show_in_footer == True)
|
|
|
|
if header_only:
|
|
filters.append(ContentPage.show_in_header == True)
|
|
|
|
if legal_only:
|
|
filters.append(ContentPage.show_in_legal == True)
|
|
|
|
# Get vendor-specific pages
|
|
vendor_pages = []
|
|
if vendor_id:
|
|
vendor_pages = (
|
|
db.query(ContentPage)
|
|
.filter(and_(ContentPage.vendor_id == vendor_id, *filters))
|
|
.order_by(ContentPage.display_order, ContentPage.title)
|
|
.all()
|
|
)
|
|
|
|
# Get platform defaults
|
|
platform_pages = (
|
|
db.query(ContentPage)
|
|
.filter(and_(ContentPage.vendor_id == None, *filters))
|
|
.order_by(ContentPage.display_order, ContentPage.title)
|
|
.all()
|
|
)
|
|
|
|
# Merge: vendor overrides take precedence
|
|
vendor_slugs = {page.slug for page in vendor_pages}
|
|
all_pages = vendor_pages + [
|
|
page for page in platform_pages if page.slug not in vendor_slugs
|
|
]
|
|
|
|
# Sort by display_order
|
|
all_pages.sort(key=lambda p: (p.display_order, p.title))
|
|
|
|
return all_pages
|
|
|
|
@staticmethod
|
|
def create_page(
|
|
db: Session,
|
|
slug: str,
|
|
title: str,
|
|
content: str,
|
|
vendor_id: int | None = None,
|
|
content_format: str = "html",
|
|
template: str = "default",
|
|
meta_description: str | None = None,
|
|
meta_keywords: str | None = None,
|
|
is_published: bool = False,
|
|
show_in_footer: bool = True,
|
|
show_in_header: bool = False,
|
|
show_in_legal: bool = False,
|
|
display_order: int = 0,
|
|
created_by: int | None = None,
|
|
) -> ContentPage:
|
|
"""
|
|
Create a new content page.
|
|
|
|
Args:
|
|
db: Database session
|
|
slug: URL-safe identifier
|
|
title: Page title
|
|
content: HTML or Markdown content
|
|
vendor_id: Vendor ID (None for platform default)
|
|
content_format: "html" or "markdown"
|
|
template: Template name for homepage/landing pages (default, minimal, modern, etc.)
|
|
meta_description: SEO description
|
|
meta_keywords: SEO keywords
|
|
is_published: Publish immediately
|
|
show_in_footer: Show in footer navigation
|
|
show_in_header: Show in header navigation
|
|
show_in_legal: Show in legal/bottom bar navigation
|
|
display_order: Sort order
|
|
created_by: User ID who created it
|
|
|
|
Returns:
|
|
Created ContentPage
|
|
"""
|
|
page = ContentPage(
|
|
vendor_id=vendor_id,
|
|
slug=slug,
|
|
title=title,
|
|
content=content,
|
|
content_format=content_format,
|
|
template=template,
|
|
meta_description=meta_description,
|
|
meta_keywords=meta_keywords,
|
|
is_published=is_published,
|
|
published_at=datetime.now(UTC) if is_published else None,
|
|
show_in_footer=show_in_footer,
|
|
show_in_header=show_in_header,
|
|
show_in_legal=show_in_legal,
|
|
display_order=display_order,
|
|
created_by=created_by,
|
|
updated_by=created_by,
|
|
)
|
|
|
|
db.add(page)
|
|
db.flush()
|
|
db.refresh(page)
|
|
|
|
logger.info(
|
|
f"Created content page: {slug} (vendor_id={vendor_id}, id={page.id})"
|
|
)
|
|
return page
|
|
|
|
@staticmethod
|
|
def update_page(
|
|
db: Session,
|
|
page_id: int,
|
|
title: str | None = None,
|
|
content: str | None = None,
|
|
content_format: str | None = None,
|
|
template: str | None = None,
|
|
meta_description: str | None = None,
|
|
meta_keywords: str | None = None,
|
|
is_published: bool | None = None,
|
|
show_in_footer: bool | None = None,
|
|
show_in_header: bool | None = None,
|
|
show_in_legal: bool | None = None,
|
|
display_order: int | None = None,
|
|
updated_by: int | None = None,
|
|
) -> ContentPage | None:
|
|
"""
|
|
Update an existing content page.
|
|
|
|
Args:
|
|
db: Database session
|
|
page_id: Page ID
|
|
title: New title
|
|
content: New content
|
|
content_format: New format
|
|
template: New template name
|
|
meta_description: New SEO description
|
|
meta_keywords: New SEO keywords
|
|
is_published: New publish status
|
|
show_in_footer: New footer visibility
|
|
show_in_header: New header visibility
|
|
show_in_legal: New legal bar visibility
|
|
display_order: New sort order
|
|
updated_by: User ID who updated it
|
|
|
|
Returns:
|
|
Updated ContentPage or None if not found
|
|
"""
|
|
page = db.query(ContentPage).filter(ContentPage.id == page_id).first()
|
|
|
|
if not page:
|
|
logger.warning(f"Content page not found: id={page_id}")
|
|
return None
|
|
|
|
# Update fields if provided
|
|
if title is not None:
|
|
page.title = title
|
|
if content is not None:
|
|
page.content = content
|
|
if content_format is not None:
|
|
page.content_format = content_format
|
|
if template is not None:
|
|
page.template = template
|
|
if meta_description is not None:
|
|
page.meta_description = meta_description
|
|
if meta_keywords is not None:
|
|
page.meta_keywords = meta_keywords
|
|
if is_published is not None:
|
|
page.is_published = is_published
|
|
if is_published and not page.published_at:
|
|
page.published_at = datetime.now(UTC)
|
|
if show_in_footer is not None:
|
|
page.show_in_footer = show_in_footer
|
|
if show_in_header is not None:
|
|
page.show_in_header = show_in_header
|
|
if show_in_legal is not None:
|
|
page.show_in_legal = show_in_legal
|
|
if display_order is not None:
|
|
page.display_order = display_order
|
|
if updated_by is not None:
|
|
page.updated_by = updated_by
|
|
|
|
db.flush()
|
|
db.refresh(page)
|
|
|
|
logger.info(f"Updated content page: id={page_id}, slug={page.slug}")
|
|
return page
|
|
|
|
@staticmethod
|
|
def delete_page(db: Session, page_id: int) -> bool:
|
|
"""
|
|
Delete a content page.
|
|
|
|
Args:
|
|
db: Database session
|
|
page_id: Page ID
|
|
|
|
Returns:
|
|
True if deleted, False if not found
|
|
"""
|
|
page = db.query(ContentPage).filter(ContentPage.id == page_id).first()
|
|
|
|
if not page:
|
|
logger.warning(f"Content page not found for deletion: id={page_id}")
|
|
return False
|
|
|
|
db.delete(page)
|
|
|
|
logger.info(f"Deleted content page: id={page_id}, slug={page.slug}")
|
|
return True
|
|
|
|
@staticmethod
|
|
def get_page_by_id(db: Session, page_id: int) -> ContentPage | None:
|
|
"""Get content page by ID."""
|
|
return db.query(ContentPage).filter(ContentPage.id == page_id).first()
|
|
|
|
@staticmethod
|
|
def get_page_by_id_or_raise(db: Session, page_id: int) -> ContentPage:
|
|
"""
|
|
Get content page by ID or raise ContentPageNotFoundException.
|
|
|
|
Args:
|
|
db: Database session
|
|
page_id: Page ID
|
|
|
|
Returns:
|
|
ContentPage
|
|
|
|
Raises:
|
|
ContentPageNotFoundException: If page not found
|
|
"""
|
|
page = db.query(ContentPage).filter(ContentPage.id == page_id).first()
|
|
if not page:
|
|
raise ContentPageNotFoundException(identifier=page_id)
|
|
return page
|
|
|
|
@staticmethod
|
|
def get_page_for_vendor_or_raise(
|
|
db: Session,
|
|
slug: str,
|
|
vendor_id: int | None = None,
|
|
include_unpublished: bool = False,
|
|
) -> ContentPage:
|
|
"""
|
|
Get content page for a vendor with fallback to platform default.
|
|
Raises ContentPageNotFoundException if not found.
|
|
|
|
Args:
|
|
db: Database session
|
|
slug: Page slug
|
|
vendor_id: Vendor ID
|
|
include_unpublished: Include draft pages
|
|
|
|
Returns:
|
|
ContentPage
|
|
|
|
Raises:
|
|
ContentPageNotFoundException: If page not found
|
|
"""
|
|
page = ContentPageService.get_page_for_vendor(
|
|
db, slug=slug, vendor_id=vendor_id, include_unpublished=include_unpublished
|
|
)
|
|
if not page:
|
|
raise ContentPageNotFoundException(identifier=slug)
|
|
return page
|
|
|
|
@staticmethod
|
|
def update_page_or_raise(
|
|
db: Session,
|
|
page_id: int,
|
|
title: str | None = None,
|
|
content: str | None = None,
|
|
content_format: str | None = None,
|
|
template: str | None = None,
|
|
meta_description: str | None = None,
|
|
meta_keywords: str | None = None,
|
|
is_published: bool | None = None,
|
|
show_in_footer: bool | None = None,
|
|
show_in_header: bool | None = None,
|
|
show_in_legal: bool | None = None,
|
|
display_order: int | None = None,
|
|
updated_by: int | None = None,
|
|
) -> ContentPage:
|
|
"""
|
|
Update an existing content page or raise exception.
|
|
|
|
Raises:
|
|
ContentPageNotFoundException: If page not found
|
|
"""
|
|
page = ContentPageService.update_page(
|
|
db,
|
|
page_id=page_id,
|
|
title=title,
|
|
content=content,
|
|
content_format=content_format,
|
|
template=template,
|
|
meta_description=meta_description,
|
|
meta_keywords=meta_keywords,
|
|
is_published=is_published,
|
|
show_in_footer=show_in_footer,
|
|
show_in_header=show_in_header,
|
|
show_in_legal=show_in_legal,
|
|
display_order=display_order,
|
|
updated_by=updated_by,
|
|
)
|
|
if not page:
|
|
raise ContentPageNotFoundException(identifier=page_id)
|
|
return page
|
|
|
|
@staticmethod
|
|
def delete_page_or_raise(db: Session, page_id: int) -> None:
|
|
"""
|
|
Delete a content page or raise exception.
|
|
|
|
Raises:
|
|
ContentPageNotFoundException: If page not found
|
|
"""
|
|
success = ContentPageService.delete_page(db, page_id)
|
|
if not success:
|
|
raise ContentPageNotFoundException(identifier=page_id)
|
|
|
|
@staticmethod
|
|
def update_vendor_page(
|
|
db: Session,
|
|
page_id: int,
|
|
vendor_id: int,
|
|
title: str | None = None,
|
|
content: str | None = None,
|
|
content_format: str | None = None,
|
|
meta_description: str | None = None,
|
|
meta_keywords: str | None = None,
|
|
is_published: bool | None = None,
|
|
show_in_footer: bool | None = None,
|
|
show_in_header: bool | None = None,
|
|
show_in_legal: bool | None = None,
|
|
display_order: int | None = None,
|
|
updated_by: int | None = None,
|
|
) -> ContentPage:
|
|
"""
|
|
Update a vendor-specific content page with ownership check.
|
|
|
|
Args:
|
|
db: Database session
|
|
page_id: Page ID
|
|
vendor_id: Vendor ID (for ownership verification)
|
|
... other fields
|
|
|
|
Returns:
|
|
Updated ContentPage
|
|
|
|
Raises:
|
|
ContentPageNotFoundException: If page not found
|
|
UnauthorizedContentPageAccessException: If page doesn't belong to vendor
|
|
"""
|
|
page = ContentPageService.get_page_by_id_or_raise(db, page_id)
|
|
|
|
if page.vendor_id != vendor_id:
|
|
raise UnauthorizedContentPageAccessException(action="edit")
|
|
|
|
return ContentPageService.update_page_or_raise(
|
|
db,
|
|
page_id=page_id,
|
|
title=title,
|
|
content=content,
|
|
content_format=content_format,
|
|
meta_description=meta_description,
|
|
meta_keywords=meta_keywords,
|
|
is_published=is_published,
|
|
show_in_footer=show_in_footer,
|
|
show_in_header=show_in_header,
|
|
show_in_legal=show_in_legal,
|
|
display_order=display_order,
|
|
updated_by=updated_by,
|
|
)
|
|
|
|
@staticmethod
|
|
def delete_vendor_page(db: Session, page_id: int, vendor_id: int) -> None:
|
|
"""
|
|
Delete a vendor-specific content page with ownership check.
|
|
|
|
Args:
|
|
db: Database session
|
|
page_id: Page ID
|
|
vendor_id: Vendor ID (for ownership verification)
|
|
|
|
Raises:
|
|
ContentPageNotFoundException: If page not found
|
|
UnauthorizedContentPageAccessException: If page doesn't belong to vendor
|
|
"""
|
|
page = ContentPageService.get_page_by_id_or_raise(db, page_id)
|
|
|
|
if page.vendor_id != vendor_id:
|
|
raise UnauthorizedContentPageAccessException(action="delete")
|
|
|
|
ContentPageService.delete_page_or_raise(db, page_id)
|
|
|
|
@staticmethod
|
|
def list_all_pages(
|
|
db: Session,
|
|
vendor_id: int | None = None,
|
|
include_unpublished: bool = False,
|
|
) -> list[ContentPage]:
|
|
"""
|
|
List all content pages (platform defaults and vendor overrides).
|
|
|
|
Args:
|
|
db: Database session
|
|
vendor_id: Optional filter by vendor ID
|
|
include_unpublished: Include draft pages
|
|
|
|
Returns:
|
|
List of ContentPage objects
|
|
"""
|
|
filters = []
|
|
|
|
if vendor_id:
|
|
filters.append(ContentPage.vendor_id == vendor_id)
|
|
|
|
if not include_unpublished:
|
|
filters.append(ContentPage.is_published == True)
|
|
|
|
return (
|
|
db.query(ContentPage)
|
|
.filter(and_(*filters) if filters else True)
|
|
.order_by(
|
|
ContentPage.vendor_id, ContentPage.display_order, ContentPage.title
|
|
)
|
|
.all()
|
|
)
|
|
|
|
@staticmethod
|
|
def list_all_vendor_pages(
|
|
db: Session, vendor_id: int, include_unpublished: bool = False
|
|
) -> list[ContentPage]:
|
|
"""
|
|
List only vendor-specific pages (no platform defaults).
|
|
|
|
Args:
|
|
db: Database session
|
|
vendor_id: Vendor ID
|
|
include_unpublished: Include draft pages
|
|
|
|
Returns:
|
|
List of vendor-specific ContentPage objects
|
|
"""
|
|
filters = [ContentPage.vendor_id == vendor_id]
|
|
|
|
if not include_unpublished:
|
|
filters.append(ContentPage.is_published == True)
|
|
|
|
return (
|
|
db.query(ContentPage)
|
|
.filter(and_(*filters))
|
|
.order_by(ContentPage.display_order, ContentPage.title)
|
|
.all()
|
|
)
|
|
|
|
@staticmethod
|
|
def list_all_platform_pages(
|
|
db: Session, include_unpublished: bool = False
|
|
) -> list[ContentPage]:
|
|
"""
|
|
List only platform default pages.
|
|
|
|
Args:
|
|
db: Database session
|
|
include_unpublished: Include draft pages
|
|
|
|
Returns:
|
|
List of platform default ContentPage objects
|
|
"""
|
|
filters = [ContentPage.vendor_id == None]
|
|
|
|
if not include_unpublished:
|
|
filters.append(ContentPage.is_published == True)
|
|
|
|
return (
|
|
db.query(ContentPage)
|
|
.filter(and_(*filters))
|
|
.order_by(ContentPage.display_order, ContentPage.title)
|
|
.all()
|
|
)
|
|
|
|
|
|
# Singleton instance
|
|
content_page_service = ContentPageService()
|