feat: add CMS service layer and API endpoints
Implement complete CMS business logic and REST API:
Service Layer (content_page_service.py):
- get_page_for_vendor() - Two-tier lookup with fallback
- list_pages_for_vendor() - Merge vendor + platform pages
- create_page(), update_page(), delete_page() - CRUD operations
- Support for published/draft workflow
- Footer/header navigation filtering
API Endpoints:
Admin API (/api/v1/admin/content-pages):
- POST /platform - Create platform defaults
- GET /platform - List platform defaults
- GET / - List all pages with vendor filtering
- PUT /{id} - Update any page
- DELETE /{id} - Delete any page
Vendor API (/api/v1/vendor/{code}/content-pages):
- GET / - List available pages (vendor + platform merged)
- GET /overrides - List only vendor overrides
- POST / - Create vendor override
- PUT /{id} - Update vendor page
- DELETE /{id} - Delete vendor page
Shop API (/api/v1/shop/content-pages):
- GET /navigation - Get footer/header navigation pages
- GET /{slug} - Get specific page (public, with fallback)
All endpoints include proper authentication, authorization,
and validation using Pydantic schemas.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ This module provides:
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import admin, vendor, public
|
||||
from app.api.v1 import admin, vendor, public, shop
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -46,3 +46,14 @@ api_router.include_router(
|
||||
tags=["public"]
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# SHOP ROUTES (Public shop frontend API)
|
||||
# Prefix: /api/v1/shop
|
||||
# ============================================================================
|
||||
|
||||
api_router.include_router(
|
||||
shop.router,
|
||||
prefix="/v1/shop",
|
||||
tags=["shop"]
|
||||
)
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ from . import (
|
||||
monitoring,
|
||||
audit,
|
||||
settings,
|
||||
notifications
|
||||
notifications,
|
||||
content_pages
|
||||
)
|
||||
|
||||
# Create admin router
|
||||
@@ -62,6 +63,9 @@ router.include_router(vendor_domains.router, tags=["admin-vendor-domains"])
|
||||
# Include vendor themes management endpoints
|
||||
router.include_router(vendor_themes.router, tags=["admin-vendor-themes"])
|
||||
|
||||
# Include content pages management endpoints
|
||||
router.include_router(content_pages.router, prefix="/content-pages", tags=["admin-content-pages"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# User Management
|
||||
|
||||
232
app/api/v1/admin/content_pages.py
Normal file
232
app/api/v1/admin/content_pages.py
Normal file
@@ -0,0 +1,232 @@
|
||||
# app/api/v1/admin/content_pages.py
|
||||
"""
|
||||
Admin Content Pages API
|
||||
|
||||
Platform administrators can:
|
||||
- Create/edit/delete platform default content pages
|
||||
- View all vendor content pages
|
||||
- Override vendor content if needed
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_user, get_db
|
||||
from app.services.content_page_service import content_page_service
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REQUEST/RESPONSE SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class ContentPageCreate(BaseModel):
|
||||
"""Schema for creating a content page."""
|
||||
slug: str = Field(..., max_length=100, description="URL-safe identifier (about, faq, contact, etc.)")
|
||||
title: str = Field(..., max_length=200, description="Page title")
|
||||
content: str = Field(..., description="HTML or Markdown content")
|
||||
content_format: str = Field(default="html", description="Content format: html or markdown")
|
||||
meta_description: Optional[str] = Field(None, max_length=300, description="SEO meta description")
|
||||
meta_keywords: Optional[str] = Field(None, max_length=300, description="SEO keywords")
|
||||
is_published: bool = Field(default=False, description="Publish immediately")
|
||||
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
||||
show_in_header: bool = Field(default=False, description="Show in header navigation")
|
||||
display_order: int = Field(default=0, description="Display order (lower = first)")
|
||||
vendor_id: Optional[int] = Field(None, description="Vendor ID (None for platform default)")
|
||||
|
||||
|
||||
class ContentPageUpdate(BaseModel):
|
||||
"""Schema for updating a content page."""
|
||||
title: Optional[str] = Field(None, max_length=200)
|
||||
content: Optional[str] = None
|
||||
content_format: Optional[str] = None
|
||||
meta_description: Optional[str] = Field(None, max_length=300)
|
||||
meta_keywords: Optional[str] = Field(None, max_length=300)
|
||||
is_published: Optional[bool] = None
|
||||
show_in_footer: Optional[bool] = None
|
||||
show_in_header: Optional[bool] = None
|
||||
display_order: Optional[int] = None
|
||||
|
||||
|
||||
class ContentPageResponse(BaseModel):
|
||||
"""Schema for content page response."""
|
||||
id: int
|
||||
vendor_id: Optional[int]
|
||||
vendor_name: Optional[str]
|
||||
slug: str
|
||||
title: str
|
||||
content: str
|
||||
content_format: str
|
||||
meta_description: Optional[str]
|
||||
meta_keywords: Optional[str]
|
||||
is_published: bool
|
||||
published_at: Optional[str]
|
||||
display_order: int
|
||||
show_in_footer: bool
|
||||
show_in_header: bool
|
||||
is_platform_default: bool
|
||||
is_vendor_override: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
created_by: Optional[int]
|
||||
updated_by: Optional[int]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PLATFORM DEFAULT PAGES (vendor_id=NULL)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/platform", response_model=List[ContentPageResponse])
|
||||
def list_platform_pages(
|
||||
include_unpublished: bool = Query(False, description="Include draft pages"),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List all platform default content pages.
|
||||
|
||||
These are used as fallbacks when vendors haven't created custom pages.
|
||||
"""
|
||||
pages = content_page_service.list_all_platform_pages(
|
||||
db,
|
||||
include_unpublished=include_unpublished
|
||||
)
|
||||
|
||||
return [page.to_dict() for page in pages]
|
||||
|
||||
|
||||
@router.post("/platform", response_model=ContentPageResponse, status_code=201)
|
||||
def create_platform_page(
|
||||
page_data: ContentPageCreate,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new platform default content page.
|
||||
|
||||
Platform defaults are shown to all vendors who haven't created their own version.
|
||||
"""
|
||||
# Force vendor_id to None for platform pages
|
||||
page = content_page_service.create_page(
|
||||
db,
|
||||
slug=page_data.slug,
|
||||
title=page_data.title,
|
||||
content=page_data.content,
|
||||
vendor_id=None, # Platform default
|
||||
content_format=page_data.content_format,
|
||||
meta_description=page_data.meta_description,
|
||||
meta_keywords=page_data.meta_keywords,
|
||||
is_published=page_data.is_published,
|
||||
show_in_footer=page_data.show_in_footer,
|
||||
show_in_header=page_data.show_in_header,
|
||||
display_order=page_data.display_order,
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
return page.to_dict()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ALL CONTENT PAGES (Platform + Vendors)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/", response_model=List[ContentPageResponse])
|
||||
def list_all_pages(
|
||||
vendor_id: Optional[int] = Query(None, description="Filter by vendor ID"),
|
||||
include_unpublished: bool = Query(False, description="Include draft pages"),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List all content pages (platform defaults and vendor overrides).
|
||||
|
||||
Filter by vendor_id to see specific vendor pages.
|
||||
"""
|
||||
if vendor_id:
|
||||
pages = content_page_service.list_all_vendor_pages(
|
||||
db,
|
||||
vendor_id=vendor_id,
|
||||
include_unpublished=include_unpublished
|
||||
)
|
||||
else:
|
||||
# Get all pages (both platform and vendor)
|
||||
from models.database.content_page import ContentPage
|
||||
from sqlalchemy import and_
|
||||
|
||||
filters = []
|
||||
if not include_unpublished:
|
||||
filters.append(ContentPage.is_published == True)
|
||||
|
||||
pages = (
|
||||
db.query(ContentPage)
|
||||
.filter(and_(*filters) if filters else True)
|
||||
.order_by(ContentPage.vendor_id, ContentPage.display_order, ContentPage.title)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [page.to_dict() for page in pages]
|
||||
|
||||
|
||||
@router.get("/{page_id}", response_model=ContentPageResponse)
|
||||
def get_page(
|
||||
page_id: int,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a specific content page by ID."""
|
||||
page = content_page_service.get_page_by_id(db, page_id)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Content page not found")
|
||||
|
||||
return page.to_dict()
|
||||
|
||||
|
||||
@router.put("/{page_id}", response_model=ContentPageResponse)
|
||||
def update_page(
|
||||
page_id: int,
|
||||
page_data: ContentPageUpdate,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a content page (platform or vendor)."""
|
||||
page = content_page_service.update_page(
|
||||
db,
|
||||
page_id=page_id,
|
||||
title=page_data.title,
|
||||
content=page_data.content,
|
||||
content_format=page_data.content_format,
|
||||
meta_description=page_data.meta_description,
|
||||
meta_keywords=page_data.meta_keywords,
|
||||
is_published=page_data.is_published,
|
||||
show_in_footer=page_data.show_in_footer,
|
||||
show_in_header=page_data.show_in_header,
|
||||
display_order=page_data.display_order,
|
||||
updated_by=current_user.id
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Content page not found")
|
||||
|
||||
return page.to_dict()
|
||||
|
||||
|
||||
@router.delete("/{page_id}", status_code=204)
|
||||
def delete_page(
|
||||
page_id: int,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a content page."""
|
||||
success = content_page_service.delete_page(db, page_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Content page not found")
|
||||
|
||||
return None
|
||||
25
app/api/v1/shop/__init__.py
Normal file
25
app/api/v1/shop/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# app/api/v1/shop/__init__.py
|
||||
"""
|
||||
Shop API router aggregation.
|
||||
|
||||
This module aggregates all shop-related JSON API endpoints (public facing).
|
||||
|
||||
These are PUBLIC endpoints - no authentication required.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
# Import shop routers
|
||||
from . import content_pages
|
||||
|
||||
# Create shop router
|
||||
router = APIRouter()
|
||||
|
||||
# ============================================================================
|
||||
# PUBLIC API ROUTES
|
||||
# ============================================================================
|
||||
|
||||
# Content pages (about, faq, contact, etc.)
|
||||
router.include_router(content_pages.router, prefix="/content-pages", tags=["shop-content-pages"])
|
||||
|
||||
__all__ = ["router"]
|
||||
116
app/api/v1/shop/content_pages.py
Normal file
116
app/api/v1/shop/content_pages.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# app/api/v1/shop/content_pages.py
|
||||
"""
|
||||
Shop Content Pages API (Public)
|
||||
|
||||
Public endpoints for retrieving content pages in shop frontend.
|
||||
No authentication required.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.content_page_service import content_page_service
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RESPONSE SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class PublicContentPageResponse(BaseModel):
|
||||
"""Public content page response (no internal IDs)."""
|
||||
slug: str
|
||||
title: str
|
||||
content: str
|
||||
content_format: str
|
||||
meta_description: str | None
|
||||
meta_keywords: str | None
|
||||
published_at: str | None
|
||||
|
||||
|
||||
class ContentPageListItem(BaseModel):
|
||||
"""Content page list item for navigation."""
|
||||
slug: str
|
||||
title: str
|
||||
show_in_footer: bool
|
||||
show_in_header: bool
|
||||
display_order: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PUBLIC ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/navigation", response_model=List[ContentPageListItem])
|
||||
def get_navigation_pages(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get list of content pages for navigation (footer/header).
|
||||
|
||||
Uses vendor from request.state (set by middleware).
|
||||
Returns vendor overrides + platform defaults.
|
||||
"""
|
||||
vendor = getattr(request.state, 'vendor', None)
|
||||
vendor_id = vendor.id if vendor else None
|
||||
|
||||
# Get all published pages for this vendor
|
||||
pages = content_page_service.list_pages_for_vendor(
|
||||
db,
|
||||
vendor_id=vendor_id,
|
||||
include_unpublished=False
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"slug": page.slug,
|
||||
"title": page.title,
|
||||
"show_in_footer": page.show_in_footer,
|
||||
"show_in_header": page.show_in_header,
|
||||
"display_order": page.display_order,
|
||||
}
|
||||
for page in pages
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=PublicContentPageResponse)
|
||||
def get_content_page(
|
||||
slug: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get a specific content page by slug.
|
||||
|
||||
Uses vendor from request.state (set by middleware).
|
||||
Returns vendor override if exists, otherwise platform default.
|
||||
"""
|
||||
vendor = getattr(request.state, 'vendor', None)
|
||||
vendor_id = vendor.id if vendor else None
|
||||
|
||||
page = content_page_service.get_page_for_vendor(
|
||||
db,
|
||||
slug=slug,
|
||||
vendor_id=vendor_id,
|
||||
include_unpublished=False # Only show published pages
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail=f"Content page not found: {slug}")
|
||||
|
||||
return {
|
||||
"slug": page.slug,
|
||||
"title": page.title,
|
||||
"content": page.content,
|
||||
"content_format": page.content_format,
|
||||
"meta_description": page.meta_description,
|
||||
"meta_keywords": page.meta_keywords,
|
||||
"published_at": page.published_at.isoformat() if page.published_at else None,
|
||||
}
|
||||
4
app/api/v1/vendor/__init__.py
vendored
4
app/api/v1/vendor/__init__.py
vendored
@@ -29,6 +29,7 @@ from . import (
|
||||
media,
|
||||
notifications,
|
||||
analytics,
|
||||
content_pages,
|
||||
)
|
||||
|
||||
|
||||
@@ -66,6 +67,9 @@ router.include_router(media.router, tags=["vendor-media"])
|
||||
router.include_router(notifications.router, tags=["vendor-notifications"])
|
||||
router.include_router(analytics.router, tags=["vendor-analytics"])
|
||||
|
||||
# Content pages management
|
||||
router.include_router(content_pages.router, prefix="/{vendor_code}/content-pages", tags=["vendor-content-pages"])
|
||||
|
||||
# Vendor info endpoint - MUST BE LAST! Has catch-all GET /{vendor_code}
|
||||
router.include_router(info.router, tags=["vendor-info"])
|
||||
|
||||
|
||||
259
app/api/v1/vendor/content_pages.py
vendored
Normal file
259
app/api/v1/vendor/content_pages.py
vendored
Normal file
@@ -0,0 +1,259 @@
|
||||
# app/api/v1/vendor/content_pages.py
|
||||
"""
|
||||
Vendor Content Pages API
|
||||
|
||||
Vendors can:
|
||||
- View their content pages (includes platform defaults)
|
||||
- Create/edit/delete their own content page overrides
|
||||
- Preview pages before publishing
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_user, get_db
|
||||
from app.services.content_page_service import content_page_service
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REQUEST/RESPONSE SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class VendorContentPageCreate(BaseModel):
|
||||
"""Schema for creating a vendor content page."""
|
||||
slug: str = Field(..., max_length=100, description="URL-safe identifier (about, faq, contact, etc.)")
|
||||
title: str = Field(..., max_length=200, description="Page title")
|
||||
content: str = Field(..., description="HTML or Markdown content")
|
||||
content_format: str = Field(default="html", description="Content format: html or markdown")
|
||||
meta_description: Optional[str] = Field(None, max_length=300, description="SEO meta description")
|
||||
meta_keywords: Optional[str] = Field(None, max_length=300, description="SEO keywords")
|
||||
is_published: bool = Field(default=False, description="Publish immediately")
|
||||
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
||||
show_in_header: bool = Field(default=False, description="Show in header navigation")
|
||||
display_order: int = Field(default=0, description="Display order (lower = first)")
|
||||
|
||||
|
||||
class VendorContentPageUpdate(BaseModel):
|
||||
"""Schema for updating a vendor content page."""
|
||||
title: Optional[str] = Field(None, max_length=200)
|
||||
content: Optional[str] = None
|
||||
content_format: Optional[str] = None
|
||||
meta_description: Optional[str] = Field(None, max_length=300)
|
||||
meta_keywords: Optional[str] = Field(None, max_length=300)
|
||||
is_published: Optional[bool] = None
|
||||
show_in_footer: Optional[bool] = None
|
||||
show_in_header: Optional[bool] = None
|
||||
display_order: Optional[int] = None
|
||||
|
||||
|
||||
class ContentPageResponse(BaseModel):
|
||||
"""Schema for content page response."""
|
||||
id: int
|
||||
vendor_id: Optional[int]
|
||||
vendor_name: Optional[str]
|
||||
slug: str
|
||||
title: str
|
||||
content: str
|
||||
content_format: str
|
||||
meta_description: Optional[str]
|
||||
meta_keywords: Optional[str]
|
||||
is_published: bool
|
||||
published_at: Optional[str]
|
||||
display_order: int
|
||||
show_in_footer: bool
|
||||
show_in_header: bool
|
||||
is_platform_default: bool
|
||||
is_vendor_override: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
created_by: Optional[int]
|
||||
updated_by: Optional[int]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# VENDOR CONTENT PAGES
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/", response_model=List[ContentPageResponse])
|
||||
def list_vendor_pages(
|
||||
include_unpublished: bool = Query(False, description="Include draft pages"),
|
||||
current_user: User = Depends(get_current_vendor_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List all content pages available for this vendor.
|
||||
|
||||
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
|
||||
"""
|
||||
if not current_user.vendor_id:
|
||||
raise HTTPException(status_code=403, detail="User is not associated with a vendor")
|
||||
|
||||
pages = content_page_service.list_pages_for_vendor(
|
||||
db,
|
||||
vendor_id=current_user.vendor_id,
|
||||
include_unpublished=include_unpublished
|
||||
)
|
||||
|
||||
return [page.to_dict() for page in pages]
|
||||
|
||||
|
||||
@router.get("/overrides", response_model=List[ContentPageResponse])
|
||||
def list_vendor_overrides(
|
||||
include_unpublished: bool = Query(False, description="Include draft pages"),
|
||||
current_user: User = Depends(get_current_vendor_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List only vendor-specific content pages (no platform defaults).
|
||||
|
||||
Shows what the vendor has customized.
|
||||
"""
|
||||
if not current_user.vendor_id:
|
||||
raise HTTPException(status_code=403, detail="User is not associated with a vendor")
|
||||
|
||||
pages = content_page_service.list_all_vendor_pages(
|
||||
db,
|
||||
vendor_id=current_user.vendor_id,
|
||||
include_unpublished=include_unpublished
|
||||
)
|
||||
|
||||
return [page.to_dict() for page in pages]
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=ContentPageResponse)
|
||||
def get_page(
|
||||
slug: str,
|
||||
include_unpublished: bool = Query(False, description="Include draft pages"),
|
||||
current_user: User = Depends(get_current_vendor_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get a specific content page by slug.
|
||||
|
||||
Returns vendor override if exists, otherwise platform default.
|
||||
"""
|
||||
if not current_user.vendor_id:
|
||||
raise HTTPException(status_code=403, detail="User is not associated with a vendor")
|
||||
|
||||
page = content_page_service.get_page_for_vendor(
|
||||
db,
|
||||
slug=slug,
|
||||
vendor_id=current_user.vendor_id,
|
||||
include_unpublished=include_unpublished
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail=f"Content page not found: {slug}")
|
||||
|
||||
return page.to_dict()
|
||||
|
||||
|
||||
@router.post("/", response_model=ContentPageResponse, status_code=201)
|
||||
def create_vendor_page(
|
||||
page_data: VendorContentPageCreate,
|
||||
current_user: User = Depends(get_current_vendor_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a vendor-specific content page override.
|
||||
|
||||
This will be shown instead of the platform default for this vendor.
|
||||
"""
|
||||
if not current_user.vendor_id:
|
||||
raise HTTPException(status_code=403, detail="User is not associated with a vendor")
|
||||
|
||||
page = content_page_service.create_page(
|
||||
db,
|
||||
slug=page_data.slug,
|
||||
title=page_data.title,
|
||||
content=page_data.content,
|
||||
vendor_id=current_user.vendor_id,
|
||||
content_format=page_data.content_format,
|
||||
meta_description=page_data.meta_description,
|
||||
meta_keywords=page_data.meta_keywords,
|
||||
is_published=page_data.is_published,
|
||||
show_in_footer=page_data.show_in_footer,
|
||||
show_in_header=page_data.show_in_header,
|
||||
display_order=page_data.display_order,
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
return page.to_dict()
|
||||
|
||||
|
||||
@router.put("/{page_id}", response_model=ContentPageResponse)
|
||||
def update_vendor_page(
|
||||
page_id: int,
|
||||
page_data: VendorContentPageUpdate,
|
||||
current_user: User = Depends(get_current_vendor_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update a vendor-specific content page.
|
||||
|
||||
Can only update pages owned by this vendor.
|
||||
"""
|
||||
if not current_user.vendor_id:
|
||||
raise HTTPException(status_code=403, detail="User is not associated with a vendor")
|
||||
|
||||
# Verify ownership
|
||||
existing_page = content_page_service.get_page_by_id(db, page_id)
|
||||
if not existing_page:
|
||||
raise HTTPException(status_code=404, detail="Content page not found")
|
||||
|
||||
if existing_page.vendor_id != current_user.vendor_id:
|
||||
raise HTTPException(status_code=403, detail="Cannot edit pages from other vendors")
|
||||
|
||||
# Update
|
||||
page = content_page_service.update_page(
|
||||
db,
|
||||
page_id=page_id,
|
||||
title=page_data.title,
|
||||
content=page_data.content,
|
||||
content_format=page_data.content_format,
|
||||
meta_description=page_data.meta_description,
|
||||
meta_keywords=page_data.meta_keywords,
|
||||
is_published=page_data.is_published,
|
||||
show_in_footer=page_data.show_in_footer,
|
||||
show_in_header=page_data.show_in_header,
|
||||
display_order=page_data.display_order,
|
||||
updated_by=current_user.id
|
||||
)
|
||||
|
||||
return page.to_dict()
|
||||
|
||||
|
||||
@router.delete("/{page_id}", status_code=204)
|
||||
def delete_vendor_page(
|
||||
page_id: int,
|
||||
current_user: User = Depends(get_current_vendor_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete a vendor-specific content page.
|
||||
|
||||
Can only delete pages owned by this vendor.
|
||||
After deletion, platform default will be shown (if exists).
|
||||
"""
|
||||
if not current_user.vendor_id:
|
||||
raise HTTPException(status_code=403, detail="User is not associated with a vendor")
|
||||
|
||||
# Verify ownership
|
||||
existing_page = content_page_service.get_page_by_id(db, page_id)
|
||||
if not existing_page:
|
||||
raise HTTPException(status_code=404, detail="Content page not found")
|
||||
|
||||
if existing_page.vendor_id != current_user.vendor_id:
|
||||
raise HTTPException(status_code=403, detail="Cannot delete pages from other vendors")
|
||||
|
||||
# Delete
|
||||
content_page_service.delete_page(db, page_id)
|
||||
|
||||
return None
|
||||
390
app/services/content_page_service.py
Normal file
390
app/services/content_page_service.py
Normal file
@@ -0,0 +1,390 @@
|
||||
# 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 datetime, timezone
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
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: Optional[int] = None,
|
||||
include_unpublished: bool = False
|
||||
) -> Optional[ContentPage]:
|
||||
"""
|
||||
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: Optional[int] = 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: Optional[int] = None,
|
||||
content_format: str = "html",
|
||||
meta_description: Optional[str] = None,
|
||||
meta_keywords: Optional[str] = None,
|
||||
is_published: bool = False,
|
||||
show_in_footer: bool = True,
|
||||
show_in_header: bool = False,
|
||||
display_order: int = 0,
|
||||
created_by: Optional[int] = 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"
|
||||
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,
|
||||
meta_description=meta_description,
|
||||
meta_keywords=meta_keywords,
|
||||
is_published=is_published,
|
||||
published_at=datetime.now(timezone.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.commit()
|
||||
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: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
content_format: Optional[str] = None,
|
||||
meta_description: Optional[str] = None,
|
||||
meta_keywords: Optional[str] = None,
|
||||
is_published: Optional[bool] = None,
|
||||
show_in_footer: Optional[bool] = None,
|
||||
show_in_header: Optional[bool] = None,
|
||||
display_order: Optional[int] = None,
|
||||
updated_by: Optional[int] = None
|
||||
) -> Optional[ContentPage]:
|
||||
"""
|
||||
Update an existing content page.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
page_id: Page ID
|
||||
title: New title
|
||||
content: New content
|
||||
content_format: New format
|
||||
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 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(timezone.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.commit()
|
||||
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)
|
||||
db.commit()
|
||||
|
||||
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) -> Optional[ContentPage]:
|
||||
"""Get content page by ID."""
|
||||
return db.query(ContentPage).filter(ContentPage.id == page_id).first()
|
||||
|
||||
@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()
|
||||
Reference in New Issue
Block a user