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:
2026-01-19 16:30:31 +01:00
parent fe49008fef
commit 968002630e
19 changed files with 1424 additions and 99 deletions

View File

@@ -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

View File

@@ -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

View 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,
)

View File

@@ -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()