feat: complete multi-platform CMS phases 2-5
Phase 2 - OMS Migration & Integration:
- Fix platform_pages.py to use get_platform_page for marketing pages
- Fix shop_pages.py to pass platform_id to content page service calls
Phase 3 - Admin Interface:
- Add platform management API (app/api/v1/admin/platforms.py)
- Add platforms admin page with stats cards
- Add Platforms menu item to admin sidebar
- Update content pages admin with platform filter and four-tab tier system
Phase 4 - Documentation:
- Add comprehensive architecture docs (docs/architecture/multi-platform-cms.md)
- Update implementation plan with completion status
Phase 5 - Vendor Dashboard:
- Add CMS usage API endpoint with tier limits
- Add usage progress bar to vendor content pages
- Add platform-default/{slug} API for preview
- Add View Default button and modal in page editor
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,7 @@ from . import (
|
||||
order_item_exceptions,
|
||||
orders,
|
||||
platform_health,
|
||||
platforms,
|
||||
products,
|
||||
settings,
|
||||
subscriptions,
|
||||
@@ -91,6 +92,9 @@ router.include_router(
|
||||
content_pages.router, prefix="/content-pages", tags=["admin-content-pages"]
|
||||
)
|
||||
|
||||
# Include platforms management endpoints (multi-platform CMS)
|
||||
router.include_router(platforms.router, tags=["admin-platforms"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# User Management
|
||||
|
||||
@@ -82,6 +82,9 @@ 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
|
||||
@@ -97,8 +100,11 @@ class ContentPageResponse(BaseModel):
|
||||
show_in_footer: bool
|
||||
show_in_header: bool
|
||||
show_in_legal: bool
|
||||
is_platform_default: bool
|
||||
is_vendor_override: 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
|
||||
|
||||
391
app/api/v1/admin/platforms.py
Normal file
391
app/api/v1/admin/platforms.py
Normal file
@@ -0,0 +1,391 @@
|
||||
# app/api/v1/admin/platforms.py
|
||||
"""
|
||||
Admin API endpoints for Platform management (Multi-Platform CMS).
|
||||
|
||||
Provides CRUD operations for platforms:
|
||||
- GET /platforms - List all platforms
|
||||
- GET /platforms/{code} - Get platform details
|
||||
- PUT /platforms/{code} - Update platform settings
|
||||
- GET /platforms/{code}/stats - Get platform statistics
|
||||
|
||||
Platforms are business offerings (OMS, Loyalty, Site Builder) with their own:
|
||||
- Marketing pages (homepage, pricing, features)
|
||||
- Vendor defaults (about, terms, privacy)
|
||||
- Configuration and branding
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_from_cookie_or_header, get_db
|
||||
from models.database.content_page import ContentPage
|
||||
from models.database.platform import Platform
|
||||
from models.database.user import User
|
||||
from models.database.vendor_platform import VendorPlatform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/platforms")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class PlatformResponse(BaseModel):
|
||||
"""Platform response schema."""
|
||||
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
domain: str | None = None
|
||||
path_prefix: str | None = None
|
||||
logo: str | None = None
|
||||
logo_dark: str | None = None
|
||||
favicon: str | None = None
|
||||
theme_config: dict[str, Any] = Field(default_factory=dict)
|
||||
default_language: str = "fr"
|
||||
supported_languages: list[str] = Field(default_factory=lambda: ["fr", "de", "en"])
|
||||
is_active: bool = True
|
||||
is_public: bool = True
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
# Computed fields (added by endpoint)
|
||||
vendor_count: int = 0
|
||||
platform_pages_count: int = 0
|
||||
vendor_defaults_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PlatformListResponse(BaseModel):
|
||||
"""Response for platform list."""
|
||||
|
||||
platforms: list[PlatformResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class PlatformUpdateRequest(BaseModel):
|
||||
"""Request schema for updating a platform."""
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
domain: str | None = None
|
||||
path_prefix: str | None = None
|
||||
logo: str | None = None
|
||||
logo_dark: str | None = None
|
||||
favicon: str | None = None
|
||||
theme_config: dict[str, Any] | None = None
|
||||
default_language: str | None = None
|
||||
supported_languages: list[str] | None = None
|
||||
is_active: bool | None = None
|
||||
is_public: bool | None = None
|
||||
settings: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class PlatformStatsResponse(BaseModel):
|
||||
"""Platform statistics response."""
|
||||
|
||||
platform_id: int
|
||||
platform_code: str
|
||||
platform_name: str
|
||||
vendor_count: int
|
||||
platform_pages_count: int
|
||||
vendor_defaults_count: int
|
||||
vendor_overrides_count: int
|
||||
published_pages_count: int
|
||||
draft_pages_count: int
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("", response_model=PlatformListResponse)
|
||||
async def list_platforms(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||
include_inactive: bool = Query(False, description="Include inactive platforms"),
|
||||
):
|
||||
"""
|
||||
List all platforms with their statistics.
|
||||
|
||||
Returns all platforms (OMS, Loyalty, etc.) with vendor counts and page counts.
|
||||
"""
|
||||
query = db.query(Platform)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(Platform.is_active == True)
|
||||
|
||||
platforms = query.order_by(Platform.id).all()
|
||||
|
||||
# Build response with computed fields
|
||||
result = []
|
||||
for platform in platforms:
|
||||
# Count vendors on this platform
|
||||
vendor_count = (
|
||||
db.query(func.count(VendorPlatform.vendor_id))
|
||||
.filter(VendorPlatform.platform_id == platform.id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Count platform marketing pages
|
||||
platform_pages_count = (
|
||||
db.query(func.count(ContentPage.id))
|
||||
.filter(
|
||||
ContentPage.platform_id == platform.id,
|
||||
ContentPage.vendor_id == None,
|
||||
ContentPage.is_platform_page == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Count vendor default pages
|
||||
vendor_defaults_count = (
|
||||
db.query(func.count(ContentPage.id))
|
||||
.filter(
|
||||
ContentPage.platform_id == platform.id,
|
||||
ContentPage.vendor_id == None,
|
||||
ContentPage.is_platform_page == False,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
platform_data = PlatformResponse(
|
||||
id=platform.id,
|
||||
code=platform.code,
|
||||
name=platform.name,
|
||||
description=platform.description,
|
||||
domain=platform.domain,
|
||||
path_prefix=platform.path_prefix,
|
||||
logo=platform.logo,
|
||||
logo_dark=platform.logo_dark,
|
||||
favicon=platform.favicon,
|
||||
theme_config=platform.theme_config or {},
|
||||
default_language=platform.default_language,
|
||||
supported_languages=platform.supported_languages or ["fr", "de", "en"],
|
||||
is_active=platform.is_active,
|
||||
is_public=platform.is_public,
|
||||
settings=platform.settings or {},
|
||||
created_at=platform.created_at.isoformat(),
|
||||
updated_at=platform.updated_at.isoformat(),
|
||||
vendor_count=vendor_count,
|
||||
platform_pages_count=platform_pages_count,
|
||||
vendor_defaults_count=vendor_defaults_count,
|
||||
)
|
||||
result.append(platform_data)
|
||||
|
||||
logger.info(f"[PLATFORMS] Listed {len(result)} platforms")
|
||||
|
||||
return PlatformListResponse(platforms=result, total=len(result))
|
||||
|
||||
|
||||
@router.get("/{code}", response_model=PlatformResponse)
|
||||
async def get_platform(
|
||||
code: str = Path(..., description="Platform code (oms, loyalty, etc.)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Get platform details by code.
|
||||
|
||||
Returns full platform configuration including statistics.
|
||||
"""
|
||||
platform = db.query(Platform).filter(Platform.code == code).first()
|
||||
|
||||
if not platform:
|
||||
raise HTTPException(status_code=404, detail=f"Platform not found: {code}")
|
||||
|
||||
# Count vendors on this platform
|
||||
vendor_count = (
|
||||
db.query(func.count(VendorPlatform.vendor_id))
|
||||
.filter(VendorPlatform.platform_id == platform.id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Count platform marketing pages
|
||||
platform_pages_count = (
|
||||
db.query(func.count(ContentPage.id))
|
||||
.filter(
|
||||
ContentPage.platform_id == platform.id,
|
||||
ContentPage.vendor_id == None,
|
||||
ContentPage.is_platform_page == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Count vendor default pages
|
||||
vendor_defaults_count = (
|
||||
db.query(func.count(ContentPage.id))
|
||||
.filter(
|
||||
ContentPage.platform_id == platform.id,
|
||||
ContentPage.vendor_id == None,
|
||||
ContentPage.is_platform_page == False,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return PlatformResponse(
|
||||
id=platform.id,
|
||||
code=platform.code,
|
||||
name=platform.name,
|
||||
description=platform.description,
|
||||
domain=platform.domain,
|
||||
path_prefix=platform.path_prefix,
|
||||
logo=platform.logo,
|
||||
logo_dark=platform.logo_dark,
|
||||
favicon=platform.favicon,
|
||||
theme_config=platform.theme_config or {},
|
||||
default_language=platform.default_language,
|
||||
supported_languages=platform.supported_languages or ["fr", "de", "en"],
|
||||
is_active=platform.is_active,
|
||||
is_public=platform.is_public,
|
||||
settings=platform.settings or {},
|
||||
created_at=platform.created_at.isoformat(),
|
||||
updated_at=platform.updated_at.isoformat(),
|
||||
vendor_count=vendor_count,
|
||||
platform_pages_count=platform_pages_count,
|
||||
vendor_defaults_count=vendor_defaults_count,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{code}", response_model=PlatformResponse)
|
||||
async def update_platform(
|
||||
update_data: PlatformUpdateRequest,
|
||||
code: str = Path(..., description="Platform code"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Update platform settings.
|
||||
|
||||
Allows updating name, description, branding, and configuration.
|
||||
"""
|
||||
platform = db.query(Platform).filter(Platform.code == code).first()
|
||||
|
||||
if not platform:
|
||||
raise HTTPException(status_code=404, detail=f"Platform not found: {code}")
|
||||
|
||||
# Update fields if provided
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_dict.items():
|
||||
if hasattr(platform, field):
|
||||
setattr(platform, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(platform)
|
||||
|
||||
logger.info(f"[PLATFORMS] Updated platform: {code}")
|
||||
|
||||
# Return updated platform with stats
|
||||
return await get_platform(code=code, db=db, current_user=current_user)
|
||||
|
||||
|
||||
@router.get("/{code}/stats", response_model=PlatformStatsResponse)
|
||||
async def get_platform_stats(
|
||||
code: str = Path(..., description="Platform code"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Get detailed statistics for a platform.
|
||||
|
||||
Returns counts for vendors, pages, and content breakdown.
|
||||
"""
|
||||
platform = db.query(Platform).filter(Platform.code == code).first()
|
||||
|
||||
if not platform:
|
||||
raise HTTPException(status_code=404, detail=f"Platform not found: {code}")
|
||||
|
||||
# Count vendors
|
||||
vendor_count = (
|
||||
db.query(func.count(VendorPlatform.vendor_id))
|
||||
.filter(VendorPlatform.platform_id == platform.id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Count platform marketing pages
|
||||
platform_pages_count = (
|
||||
db.query(func.count(ContentPage.id))
|
||||
.filter(
|
||||
ContentPage.platform_id == platform.id,
|
||||
ContentPage.vendor_id == None,
|
||||
ContentPage.is_platform_page == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Count vendor default pages
|
||||
vendor_defaults_count = (
|
||||
db.query(func.count(ContentPage.id))
|
||||
.filter(
|
||||
ContentPage.platform_id == platform.id,
|
||||
ContentPage.vendor_id == None,
|
||||
ContentPage.is_platform_page == False,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Count vendor override pages
|
||||
vendor_overrides_count = (
|
||||
db.query(func.count(ContentPage.id))
|
||||
.filter(
|
||||
ContentPage.platform_id == platform.id,
|
||||
ContentPage.vendor_id != None,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Count published pages
|
||||
published_pages_count = (
|
||||
db.query(func.count(ContentPage.id))
|
||||
.filter(
|
||||
ContentPage.platform_id == platform.id,
|
||||
ContentPage.is_published == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Count draft pages
|
||||
draft_pages_count = (
|
||||
db.query(func.count(ContentPage.id))
|
||||
.filter(
|
||||
ContentPage.platform_id == platform.id,
|
||||
ContentPage.is_published == False,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return PlatformStatsResponse(
|
||||
platform_id=platform.id,
|
||||
platform_code=platform.code,
|
||||
platform_name=platform.name,
|
||||
vendor_count=vendor_count,
|
||||
platform_pages_count=platform_pages_count,
|
||||
vendor_defaults_count=vendor_defaults_count,
|
||||
vendor_overrides_count=vendor_overrides_count,
|
||||
published_pages_count=published_pages_count,
|
||||
draft_pages_count=draft_pages_count,
|
||||
)
|
||||
114
app/api/v1/vendor/content_pages.py
vendored
114
app/api/v1/vendor/content_pages.py
vendored
@@ -97,6 +97,20 @@ class ContentPageResponse(BaseModel):
|
||||
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
|
||||
# ============================================================================
|
||||
@@ -241,3 +255,103 @@ def delete_vendor_page(
|
||||
# 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.
|
||||
"""
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
vendor = db.query(Vendor).filter(Vendor.id == current_user.token_vendor_id).first()
|
||||
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.
|
||||
"""
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
# Get vendor's platform
|
||||
vendor = db.query(Vendor).filter(Vendor.id == current_user.token_vendor_id).first()
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user