feat: complete CMS as fully autonomous self-contained module

Transform CMS from a thin wrapper into a fully self-contained module with
all code living within app/modules/cms/:

Module Structure:
- models/: ContentPage model (canonical location with dynamic discovery)
- schemas/: Pydantic schemas for API validation
- services/: ContentPageService business logic
- exceptions/: Module-specific exceptions
- routes/api/: REST API endpoints (admin, vendor, shop)
- routes/pages/: HTML page routes (admin, vendor)
- templates/cms/: Jinja2 templates (namespaced)
- static/: JavaScript files (admin/vendor)
- locales/: i18n translations (en, fr, de, lb)

Key Changes:
- Move ContentPage model to module with dynamic model discovery
- Create Pydantic schemas package for request/response validation
- Extract API routes from app/api/v1/*/ to module
- Extract page routes from admin_pages.py/vendor_pages.py to module
- Move static JS files to module with dedicated mount point
- Update templates to use cms_static for module assets
- Add module static file mounting in main.py
- Delete old scattered files (no shims - hard errors on old imports)

This establishes the pattern for migrating other modules to be
fully autonomous and independently deployable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 22:42:46 +01:00
parent 8ff9c39845
commit ec4ec045fc
40 changed files with 878 additions and 695 deletions

View File

@@ -48,7 +48,7 @@ from . import (
background_tasks,
code_quality,
companies,
content_pages,
# content_pages - moved to app.modules.cms.routes.api.admin
customers,
dashboard,
email_templates,
@@ -89,6 +89,9 @@ from app.modules.orders.routes.admin import admin_exceptions_router as orders_ex
from app.modules.marketplace.routes.admin import admin_router as marketplace_admin_router
from app.modules.marketplace.routes.admin import admin_letzshop_router as letzshop_admin_router
# CMS module router
from app.modules.cms.routes.api.admin import router as cms_admin_router
# Create admin router
router = APIRouter()
@@ -117,10 +120,11 @@ 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
# Include CMS module router (self-contained module)
router.include_router(
content_pages.router, prefix="/content-pages", tags=["admin-content-pages"]
cms_admin_router, prefix="/content-pages", tags=["admin-content-pages"]
)
# Legacy: content_pages.router moved to app.modules.cms.routes.api.admin
# Include platforms management endpoints (multi-platform CMS)
router.include_router(platforms.router, tags=["admin-platforms"])

View File

@@ -1,399 +0,0 @@
# 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 fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, get_db
from app.exceptions import ValidationException
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"
)
template: str = Field(
default="default",
max_length=50,
description="Template name (default, minimal, modern)",
)
meta_description: str | None = Field(
None, max_length=300, description="SEO meta description"
)
meta_keywords: str | None = 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")
show_in_legal: bool = Field(
default=False, description="Show in legal/bottom bar (next to copyright)"
)
display_order: int = Field(default=0, description="Display order (lower = first)")
vendor_id: int | None = Field(
None, description="Vendor ID (None for platform default)"
)
class ContentPageUpdate(BaseModel):
"""Schema for updating a content page."""
title: str | None = Field(None, max_length=200)
content: str | None = None
content_format: str | None = None
template: str | None = Field(None, max_length=50)
meta_description: str | None = Field(None, max_length=300)
meta_keywords: str | None = Field(None, max_length=300)
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
class ContentPageResponse(BaseModel):
"""Schema for content page response."""
id: int
platform_id: int | None = None
platform_code: str | None = None
platform_name: str | None = None
vendor_id: int | None
vendor_name: str | None
slug: str
title: str
content: str
content_format: str
template: str | None = None
meta_description: str | None
meta_keywords: str | None
is_published: bool
published_at: str | None
display_order: int
show_in_footer: bool
show_in_header: bool
show_in_legal: bool
is_platform_page: bool = False
is_platform_default: bool = False # Deprecated: use is_platform_page
is_vendor_default: bool = False
is_vendor_override: bool = False
page_tier: str | None = None
created_at: str
updated_at: str
created_by: int | None
updated_by: int | None
# ============================================================================
# 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_api),
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_api),
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,
template=page_data.template,
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,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
created_by=current_user.id,
)
db.commit()
return page.to_dict()
# ============================================================================
# VENDOR PAGES
# ============================================================================
@router.post("/vendor", response_model=ContentPageResponse, status_code=201)
def create_vendor_page(
page_data: ContentPageCreate,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Create a vendor-specific content page override.
Vendor pages override platform defaults for a specific vendor.
"""
if not page_data.vendor_id:
raise ValidationException(
message="vendor_id is required for vendor pages. Use /platform for platform defaults.",
field="vendor_id",
)
page = content_page_service.create_page(
db,
slug=page_data.slug,
title=page_data.title,
content=page_data.content,
vendor_id=page_data.vendor_id,
content_format=page_data.content_format,
template=page_data.template,
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,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
created_by=current_user.id,
)
db.commit()
return page.to_dict()
# ============================================================================
# ALL CONTENT PAGES (Platform + Vendors)
# ============================================================================
@router.get("/", response_model=list[ContentPageResponse])
def list_all_pages(
vendor_id: int | None = Query(None, description="Filter by vendor ID"),
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
List all content pages (platform defaults and vendor overrides).
Filter by vendor_id to see specific vendor pages.
"""
pages = content_page_service.list_all_pages(
db, vendor_id=vendor_id, include_unpublished=include_unpublished
)
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_api),
db: Session = Depends(get_db),
):
"""Get a specific content page by ID."""
page = content_page_service.get_page_by_id_or_raise(db, page_id)
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_api),
db: Session = Depends(get_db),
):
"""Update a content page (platform or vendor)."""
page = content_page_service.update_page_or_raise(
db,
page_id=page_id,
title=page_data.title,
content=page_data.content,
content_format=page_data.content_format,
template=page_data.template,
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,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
updated_by=current_user.id,
)
db.commit()
return page.to_dict()
@router.delete("/{page_id}", status_code=204)
def delete_page(
page_id: int,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Delete a content page."""
content_page_service.delete_page_or_raise(db, page_id)
db.commit()
# ============================================================================
# HOMEPAGE SECTIONS MANAGEMENT
# ============================================================================
class HomepageSectionsResponse(BaseModel):
"""Response containing homepage sections with platform language info."""
sections: dict | None = None
supported_languages: list[str] = Field(default_factory=lambda: ["fr", "de", "en"])
default_language: str = "fr"
class SectionUpdateResponse(BaseModel):
"""Response after updating sections."""
message: str
sections: dict | None = None
@router.get("/{page_id}/sections", response_model=HomepageSectionsResponse)
def get_page_sections(
page_id: int,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get homepage sections for a content page.
Returns sections along with platform language settings for the editor.
"""
page = content_page_service.get_page_by_id_or_raise(db, page_id)
# Get platform languages
platform = page.platform
supported_languages = (
platform.supported_languages if platform else ["fr", "de", "en"]
)
default_language = platform.default_language if platform else "fr"
return {
"sections": page.sections,
"supported_languages": supported_languages,
"default_language": default_language,
}
@router.put("/{page_id}/sections", response_model=SectionUpdateResponse)
def update_page_sections(
page_id: int,
sections: dict,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update all homepage sections at once.
Expected structure:
{
"hero": { ... },
"features": { ... },
"pricing": { ... },
"cta": { ... }
}
"""
page = content_page_service.update_homepage_sections(
db,
page_id=page_id,
sections=sections,
updated_by=current_user.id,
)
db.commit()
return {
"message": "Sections updated successfully",
"sections": page.sections,
}
@router.put("/{page_id}/sections/{section_name}", response_model=SectionUpdateResponse)
def update_single_section(
page_id: int,
section_name: str,
section_data: dict,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update a single section (hero, features, pricing, or cta).
section_name must be one of: hero, features, pricing, cta
"""
if section_name not in ["hero", "features", "pricing", "cta"]:
raise ValidationException(
message=f"Invalid section name: {section_name}. Must be one of: hero, features, pricing, cta",
field="section_name",
)
page = content_page_service.update_single_section(
db,
page_id=page_id,
section_name=section_name,
section_data=section_data,
updated_by=current_user.id,
)
db.commit()
return {
"message": f"Section '{section_name}' updated successfully",
"sections": page.sections,
}

View File

@@ -21,7 +21,10 @@ Authentication:
from fastapi import APIRouter
# Import shop routers
from . import addresses, auth, carts, content_pages, messages, orders, products, profile
from . import addresses, auth, carts, messages, orders, products, profile
# CMS module router
from app.modules.cms.routes.api.shop import router as cms_shop_router
# Create shop router
router = APIRouter()
@@ -51,9 +54,10 @@ router.include_router(messages.router, tags=["shop-messages"])
# Profile (authenticated)
router.include_router(profile.router, tags=["shop-profile"])
# Content pages (public)
# CMS module router (self-contained module)
router.include_router(
content_pages.router, prefix="/content-pages", tags=["shop-content-pages"]
cms_shop_router, prefix="/content-pages", tags=["shop-content-pages"]
)
# Legacy: content_pages.router moved to app.modules.cms.routes.api.shop
__all__ = ["router"]

View File

@@ -1,108 +0,0 @@
# 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 fastapi import APIRouter, Depends, 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]) # public
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_or_raise(
db,
slug=slug,
vendor_id=vendor_id,
include_unpublished=False, # Only show published pages
)
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,
}

View File

@@ -34,7 +34,7 @@ from . import (
analytics,
auth,
billing,
content_pages,
# content_pages - moved to app.modules.cms.routes.api.vendor
customers,
dashboard,
email_settings,
@@ -68,6 +68,9 @@ from app.modules.orders.routes.vendor import vendor_exceptions_router as orders_
from app.modules.marketplace.routes.vendor import vendor_router as marketplace_vendor_router
from app.modules.marketplace.routes.vendor import vendor_letzshop_router as letzshop_vendor_router
# CMS module router
from app.modules.cms.routes.api.vendor import router as cms_vendor_router
# Create vendor router
router = APIRouter()
@@ -128,8 +131,9 @@ router.include_router(billing_vendor_router, tags=["vendor-billing"])
router.include_router(features.router, tags=["vendor-features"])
router.include_router(usage.router, tags=["vendor-usage"])
# Content pages management
router.include_router(content_pages.router, tags=["vendor-content-pages"])
# CMS module router (self-contained module)
router.include_router(cms_vendor_router, tags=["vendor-content-pages"])
# Legacy: content_pages.router moved to app.modules.cms.routes.api.vendor
# Vendor info endpoint - MUST BE LAST! Has catch-all GET /{vendor_code}
router.include_router(info.router, tags=["vendor-info"])

View File

@@ -1,356 +0,0 @@
# app/api/v1/vendor/content_pages.py
"""
Vendor Content Pages API
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
Vendors can:
- View their content pages (includes platform defaults)
- Create/edit/delete their own content page overrides
- Preview pages before publishing
"""
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, get_db
from app.services.content_page_service import content_page_service
from app.services.vendor_service import VendorService
from models.database.user import User
vendor_service = VendorService()
router = APIRouter(prefix="/content-pages")
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: str | None = Field(
None, max_length=300, description="SEO meta description"
)
meta_keywords: str | None = 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")
show_in_legal: bool = Field(
default=False, description="Show in legal/bottom bar (next to copyright)"
)
display_order: int = Field(default=0, description="Display order (lower = first)")
class VendorContentPageUpdate(BaseModel):
"""Schema for updating a vendor content page."""
title: str | None = Field(None, max_length=200)
content: str | None = None
content_format: str | None = None
meta_description: str | None = Field(None, max_length=300)
meta_keywords: str | None = Field(None, max_length=300)
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
class ContentPageResponse(BaseModel):
"""Schema for content page response."""
id: int
vendor_id: int | None
vendor_name: str | None
slug: str
title: str
content: str
content_format: str
meta_description: str | None
meta_keywords: str | None
is_published: bool
published_at: str | None
display_order: int
show_in_footer: bool
show_in_header: bool
show_in_legal: bool
is_platform_default: bool
is_vendor_override: bool
created_at: str
updated_at: str
created_by: int | None
updated_by: int | None
class CMSUsageResponse(BaseModel):
"""Schema for CMS usage statistics."""
total_pages: int
custom_pages: int
override_pages: int
pages_limit: int | None
custom_pages_limit: int | None
can_create_page: bool
can_create_custom: bool
usage_percent: float
custom_usage_percent: float
# ============================================================================
# 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_api),
db: Session = Depends(get_db),
):
"""
List all content pages available for this vendor.
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
"""
pages = content_page_service.list_pages_for_vendor(
db, vendor_id=current_user.token_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_api),
db: Session = Depends(get_db),
):
"""
List only vendor-specific content pages (no platform defaults).
Shows what the vendor has customized.
"""
pages = content_page_service.list_all_vendor_pages(
db, vendor_id=current_user.token_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_api),
db: Session = Depends(get_db),
):
"""
Get a specific content page by slug.
Returns vendor override if exists, otherwise platform default.
"""
page = content_page_service.get_page_for_vendor_or_raise(
db,
slug=slug,
vendor_id=current_user.token_vendor_id,
include_unpublished=include_unpublished,
)
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_api),
db: Session = Depends(get_db),
):
"""
Create a vendor-specific content page override.
This will be shown instead of the platform default for this vendor.
"""
page = content_page_service.create_page(
db,
slug=page_data.slug,
title=page_data.title,
content=page_data.content,
vendor_id=current_user.token_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,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
created_by=current_user.id,
)
db.commit()
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_api),
db: Session = Depends(get_db),
):
"""
Update a vendor-specific content page.
Can only update pages owned by this vendor.
"""
# Update with ownership check in service layer
page = content_page_service.update_vendor_page(
db,
page_id=page_id,
vendor_id=current_user.token_vendor_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,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
updated_by=current_user.id,
)
db.commit()
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_api),
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).
"""
# Delete with ownership check in service layer
content_page_service.delete_vendor_page(db, page_id, current_user.token_vendor_id)
db.commit()
# ============================================================================
# CMS USAGE & LIMITS
# ============================================================================
@router.get("/usage", response_model=CMSUsageResponse)
def get_cms_usage(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get CMS usage statistics for the vendor.
Returns page counts and limits based on subscription tier.
"""
vendor = vendor_service.get_vendor_by_id_optional(db, current_user.token_vendor_id)
if not vendor:
return CMSUsageResponse(
total_pages=0,
custom_pages=0,
override_pages=0,
pages_limit=3,
custom_pages_limit=0,
can_create_page=False,
can_create_custom=False,
usage_percent=0,
custom_usage_percent=0,
)
# Get vendor's pages
vendor_pages = content_page_service.list_all_vendor_pages(
db, vendor_id=current_user.token_vendor_id, include_unpublished=True
)
total_pages = len(vendor_pages)
override_pages = sum(1 for p in vendor_pages if p.is_vendor_override)
custom_pages = total_pages - override_pages
# Get limits from subscription tier
pages_limit = None
custom_pages_limit = None
if vendor.subscription and vendor.subscription.tier:
pages_limit = vendor.subscription.tier.cms_pages_limit
custom_pages_limit = vendor.subscription.tier.cms_custom_pages_limit
# Calculate can_create flags
can_create_page = pages_limit is None or total_pages < pages_limit
can_create_custom = custom_pages_limit is None or custom_pages < custom_pages_limit
# Calculate usage percentages
usage_percent = 0 if pages_limit is None else min(100, (total_pages / pages_limit) * 100) if pages_limit > 0 else 100
custom_usage_percent = 0 if custom_pages_limit is None else min(100, (custom_pages / custom_pages_limit) * 100) if custom_pages_limit > 0 else 100
return CMSUsageResponse(
total_pages=total_pages,
custom_pages=custom_pages,
override_pages=override_pages,
pages_limit=pages_limit,
custom_pages_limit=custom_pages_limit,
can_create_page=can_create_page,
can_create_custom=can_create_custom,
usage_percent=usage_percent,
custom_usage_percent=custom_usage_percent,
)
@router.get("/platform-default/{slug}", response_model=ContentPageResponse)
def get_platform_default(
slug: str,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get the platform default content for a slug.
Useful for vendors to view the original before/after overriding.
"""
# Get vendor's platform
vendor = vendor_service.get_vendor_by_id_optional(db, current_user.token_vendor_id)
platform_id = 1 # Default to OMS
if vendor and vendor.platforms:
platform_id = vendor.platforms[0].id
# Get platform default (vendor_id=None)
page = content_page_service.get_vendor_default_page(
db, platform_id=platform_id, slug=slug, include_unpublished=True
)
if not page:
from app.exceptions import NotFoundException
raise NotFoundException(f"No platform default found for slug: {slug}")
return page.to_dict()