# 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, ) -> 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 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) # 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, 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 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, 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, 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 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 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, 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, 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, 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, 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()