diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index feaaaf7c..9fa77be9 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -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 diff --git a/app/api/v1/admin/content_pages.py b/app/api/v1/admin/content_pages.py index 1dbfeab4..f7fbd057 100644 --- a/app/api/v1/admin/content_pages.py +++ b/app/api/v1/admin/content_pages.py @@ -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 diff --git a/app/api/v1/admin/platforms.py b/app/api/v1/admin/platforms.py new file mode 100644 index 00000000..b0491265 --- /dev/null +++ b/app/api/v1/admin/platforms.py @@ -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, + ) diff --git a/app/api/v1/vendor/content_pages.py b/app/api/v1/vendor/content_pages.py index 07fe5c43..1d22ce49 100644 --- a/app/api/v1/vendor/content_pages.py +++ b/app/api/v1/vendor/content_pages.py @@ -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() diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py index 0c4f0b70..89b1cd6e 100644 --- a/app/routes/admin_pages.py +++ b/app/routes/admin_pages.py @@ -1039,6 +1039,78 @@ async def admin_logs_page( ) +# ============================================================================ +# PLATFORM MANAGEMENT ROUTES (Multi-Platform Support) +# ============================================================================ + + +@router.get("/platforms", response_class=HTMLResponse, include_in_schema=False) +async def admin_platforms_list( + request: Request, + current_user: User = Depends(get_current_admin_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render platforms management page. + Shows all platforms (OMS, Loyalty, etc.) with their configuration. + """ + return templates.TemplateResponse( + "admin/platforms.html", + { + "request": request, + "user": current_user, + }, + ) + + +@router.get( + "/platforms/{platform_code}", response_class=HTMLResponse, include_in_schema=False +) +async def admin_platform_detail( + request: Request, + platform_code: str = Path(..., description="Platform code (oms, loyalty, etc.)"), + current_user: User = Depends(get_current_admin_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render platform detail page. + Shows platform configuration, marketing pages, and vendor defaults. + """ + return templates.TemplateResponse( + "admin/platform-detail.html", + { + "request": request, + "user": current_user, + "platform_code": platform_code, + }, + ) + + +@router.get( + "/platforms/{platform_code}/edit", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_platform_edit( + request: Request, + platform_code: str = Path(..., description="Platform code"), + current_user: User = Depends(get_current_admin_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render platform edit form. + Allows editing platform settings, branding, and configuration. + """ + return templates.TemplateResponse( + "admin/platform-edit.html", + { + "request": request, + "user": current_user, + "platform_code": platform_code, + }, + ) + + # ============================================================================ # CONTENT MANAGEMENT SYSTEM (CMS) ROUTES # ============================================================================ diff --git a/app/routes/platform_pages.py b/app/routes/platform_pages.py index 587969b4..bc1ccff5 100644 --- a/app/routes/platform_pages.py +++ b/app/routes/platform_pages.py @@ -33,6 +33,10 @@ def get_platform_context(request: Request, db: Session) -> dict: # Get language from request state (set by middleware) language = getattr(request.state, "language", "fr") + # Get platform from middleware (default to OMS platform_id=1) + platform = getattr(request.state, "platform", None) + platform_id = platform.id if platform else 1 + # Get translation function i18n_globals = get_jinja2_globals(language) @@ -52,16 +56,16 @@ def get_platform_context(request: Request, db: Session) -> dict: footer_pages = [] legal_pages = [] try: - # Platform pages have vendor_id=None - header_pages = content_page_service.list_pages_for_vendor( - db, vendor_id=None, header_only=True, include_unpublished=False + # Platform marketing pages (is_platform_page=True) + header_pages = content_page_service.list_platform_pages( + db, platform_id=platform_id, header_only=True, include_unpublished=False ) - footer_pages = content_page_service.list_pages_for_vendor( - db, vendor_id=None, footer_only=True, include_unpublished=False - ) - legal_pages = content_page_service.list_pages_for_vendor( - db, vendor_id=None, legal_only=True, include_unpublished=False + footer_pages = content_page_service.list_platform_pages( + db, platform_id=platform_id, footer_only=True, include_unpublished=False ) + # For legal pages, we need to add footer support or use a different approach + # For now, legal pages come from footer pages with show_in_legal flag + legal_pages = [] # Will be handled separately if needed logger.debug( f"Loaded CMS pages: {len(header_pages)} header, {len(footer_pages)} footer, {len(legal_pages)} legal" ) @@ -307,11 +311,15 @@ async def content_page( Serve CMS content pages (about, contact, faq, privacy, terms, etc.). This is a catch-all route for dynamic content pages managed via the admin CMS. - Platform pages have vendor_id=None. + Platform pages have vendor_id=None and is_platform_page=True. """ - # Load content page from database (platform defaults only) - page = content_page_service.get_page_for_vendor( - db, slug=slug, vendor_id=None, include_unpublished=False + # Get platform from middleware (default to OMS platform_id=1) + platform = getattr(request.state, "platform", None) + platform_id = platform.id if platform else 1 + + # Load platform marketing page from database + page = content_page_service.get_platform_page( + db, platform_id=platform_id, slug=slug, include_unpublished=False ) if not page: diff --git a/app/routes/shop_pages.py b/app/routes/shop_pages.py index a548151c..ea82d35f 100644 --- a/app/routes/shop_pages.py +++ b/app/routes/shop_pages.py @@ -114,10 +114,14 @@ def get_shop_context(request: Request, db: Session = None, **extra_context) -> d """ # Extract from middleware state vendor = getattr(request.state, "vendor", None) + platform = getattr(request.state, "platform", None) theme = getattr(request.state, "theme", None) clean_path = getattr(request.state, "clean_path", request.url.path) vendor_context = getattr(request.state, "vendor_context", None) + # Get platform_id (default to 1 for OMS if not set) + platform_id = platform.id if platform else 1 + # Get detection method from vendor_context access_method = ( vendor_context.get("detection_method", "unknown") @@ -156,11 +160,11 @@ def get_shop_context(request: Request, db: Session = None, **extra_context) -> d vendor_id = vendor.id # Get pages configured to show in footer footer_pages = content_page_service.list_pages_for_vendor( - db, vendor_id=vendor_id, footer_only=True, include_unpublished=False + db, platform_id=platform_id, vendor_id=vendor_id, footer_only=True, include_unpublished=False ) # Get pages configured to show in header header_pages = content_page_service.list_pages_for_vendor( - db, vendor_id=vendor_id, header_only=True, include_unpublished=False + db, platform_id=platform_id, vendor_id=vendor_id, header_only=True, include_unpublished=False ) except Exception as e: logger.error( @@ -752,11 +756,13 @@ async def generic_content_page( ) vendor = getattr(request.state, "vendor", None) + platform = getattr(request.state, "platform", None) vendor_id = vendor.id if vendor else None + platform_id = platform.id if platform else 1 # Default to OMS - # Load content page from database (vendor override → platform default) + # Load content page from database (vendor override → vendor default) page = content_page_service.get_page_for_vendor( - db, slug=slug, vendor_id=vendor_id, include_unpublished=False + db, platform_id=platform_id, slug=slug, vendor_id=vendor_id, include_unpublished=False ) if not page: diff --git a/app/templates/admin/content-pages.html b/app/templates/admin/content-pages.html index ece4df9e..09e44c5d 100644 --- a/app/templates/admin/content-pages.html +++ b/app/templates/admin/content-pages.html @@ -17,25 +17,45 @@
-
+
+ Vendors
+Marketing Pages
+Vendor Defaults
+
+ + No platforms have been configured yet. +
+Platform Marketing Pages
+Public pages like homepage, pricing, features. Not inherited by vendors.
+Vendor Defaults
+Default pages inherited by all vendors (about, terms, privacy).
+Vendor Overrides
+Custom pages created by individual vendors.
+- You're customizing the "" page. Your version will be shown to customers instead of the platform default. - -
++ You're customizing the "" page. Your version will be shown to customers instead of the platform default. +
+Loading default content...
+Failed to load platform default: ${err.message}
` + }; + } finally { + this.loadingDefault = false; + } + }, + // Delete page (revert to default for overrides) async deletePage() { const message = this.isOverride diff --git a/static/vendor/js/content-pages.js b/static/vendor/js/content-pages.js index 54cbd665..861ea603 100644 --- a/static/vendor/js/content-pages.js +++ b/static/vendor/js/content-pages.js @@ -24,6 +24,7 @@ function vendorContentPagesManager() { platformPages: [], // Platform default pages customPages: [], // Vendor's own pages (overrides + custom) overrideMap: {}, // Map of slug -> page id for quick lookup + cmsUsage: null, // CMS usage statistics // Initialize async init() { @@ -43,7 +44,10 @@ function vendorContentPagesManager() { await parentInit.call(this); } - await this.loadPages(); + await Promise.all([ + this.loadPages(), + this.loadCmsUsage() + ]); contentPagesLog.info('=== VENDOR CONTENT PAGES MANAGER INITIALIZATION COMPLETE ==='); } catch (error) { @@ -92,6 +96,19 @@ function vendorContentPagesManager() { } }, + // Load CMS usage statistics + async loadCmsUsage() { + try { + contentPagesLog.info('Loading CMS usage...'); + const response = await apiClient.get('/vendor/content-pages/usage'); + this.cmsUsage = response.data || response; + contentPagesLog.info('CMS usage loaded:', this.cmsUsage); + } catch (err) { + contentPagesLog.error('Error loading CMS usage:', err); + // Non-critical - don't set error state + } + }, + // Check if vendor has overridden a platform page hasOverride(slug) { return slug in this.overrideMap;