fix: resolve architecture validation errors

- Create platform_service.py to move DB queries from platforms.py API
- Create platform.py exceptions for PlatformNotFoundException
- Update platforms.py API to use platform_service
- Update vendor/content_pages.py to use vendor_service
- Add get_vendor_by_id_optional method to VendorService
- Fix platforms.js: add centralized logger and init guard
- Fix content-page-edit.html: use modal macro instead of inline modal

All 21 architecture validation errors resolved.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 18:42:30 +01:00
parent 4f55fe31c8
commit d70a9f38d4
7 changed files with 432 additions and 276 deletions

View File

@@ -17,16 +17,13 @@ Platforms are business offerings (OMS, Loyalty, Site Builder) with their own:
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from fastapi import APIRouter, Depends, 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 app.services.platform_service import platform_service
from models.database.user import User
from models.database.vendor_platform import VendorPlatform
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/platforms")
@@ -107,140 +104,12 @@ class PlatformStatsResponse(BaseModel):
# =============================================================================
# API Endpoints
# Helper Functions
# =============================================================================
@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
)
def _build_platform_response(db: Session, platform) -> PlatformResponse:
"""Build PlatformResponse from Platform model with computed fields."""
return PlatformResponse(
id=platform.id,
code=platform.code,
@@ -259,12 +128,52 @@ async def get_platform(
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,
vendor_count=platform_service.get_vendor_count(db, platform.id),
platform_pages_count=platform_service.get_platform_pages_count(db, platform.id),
vendor_defaults_count=platform_service.get_vendor_defaults_count(db, platform.id),
)
# =============================================================================
# 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.
"""
platforms = platform_service.list_platforms(db, include_inactive=include_inactive)
result = [_build_platform_response(db, platform) for platform in platforms]
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 = platform_service.get_platform_by_code(db, code)
return _build_platform_response(db, platform)
@router.put("/{code}", response_model=PlatformResponse)
async def update_platform(
update_data: PlatformUpdateRequest,
@@ -277,24 +186,15 @@ async def update_platform(
Allows updating name, description, branding, and configuration.
"""
platform = db.query(Platform).filter(Platform.code == code).first()
platform = platform_service.get_platform_by_code(db, code)
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)
platform = platform_service.update_platform(db, platform, update_dict)
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)
return _build_platform_response(db, platform)
@router.get("/{code}/stats", response_model=PlatformStatsResponse)
@@ -308,84 +208,17 @@ async def get_platform_stats(
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
)
platform = platform_service.get_platform_by_code(db, code)
stats = platform_service.get_platform_stats(db, platform)
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,
platform_id=stats.platform_id,
platform_code=stats.platform_code,
platform_name=stats.platform_name,
vendor_count=stats.vendor_count,
platform_pages_count=stats.platform_pages_count,
vendor_defaults_count=stats.vendor_defaults_count,
vendor_overrides_count=stats.vendor_overrides_count,
published_pages_count=stats.published_pages_count,
draft_pages_count=stats.draft_pages_count,
)

View File

@@ -19,8 +19,11 @@ 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__)
@@ -272,9 +275,7 @@ def get_cms_usage(
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()
vendor = vendor_service.get_vendor_by_id_optional(db, current_user.token_vendor_id)
if not vendor:
return CMSUsageResponse(
total_pages=0,
@@ -336,10 +337,8 @@ def get_platform_default(
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()
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:

View File

@@ -0,0 +1,44 @@
# app/exceptions/platform.py
"""
Platform-related exceptions.
Custom exceptions for platform management operations.
"""
from app.exceptions.base import WizamartException
class PlatformNotFoundException(WizamartException):
"""Raised when a platform is not found."""
def __init__(self, code: str):
super().__init__(
message=f"Platform not found: {code}",
error_code="PLATFORM_NOT_FOUND",
status_code=404,
details={"platform_code": code},
)
class PlatformInactiveException(WizamartException):
"""Raised when trying to access an inactive platform."""
def __init__(self, code: str):
super().__init__(
message=f"Platform is inactive: {code}",
error_code="PLATFORM_INACTIVE",
status_code=403,
details={"platform_code": code},
)
class PlatformUpdateException(WizamartException):
"""Raised when platform update fails."""
def __init__(self, code: str, reason: str):
super().__init__(
message=f"Failed to update platform {code}: {reason}",
error_code="PLATFORM_UPDATE_FAILED",
status_code=400,
details={"platform_code": code, "reason": reason},
)

View File

@@ -0,0 +1,288 @@
# app/services/platform_service.py
"""
Platform Service
Business logic for platform management in the Multi-Platform CMS.
Platforms represent different business offerings (OMS, Loyalty, Site Builder, Main Marketing).
Each platform has its own:
- Marketing pages (homepage, pricing, features)
- Vendor defaults (about, terms, privacy)
- Configuration and branding
"""
import logging
from dataclasses import dataclass
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions.platform import (
PlatformNotFoundException,
)
from models.database.content_page import ContentPage
from models.database.platform import Platform
from models.database.vendor_platform import VendorPlatform
logger = logging.getLogger(__name__)
@dataclass
class PlatformStats:
"""Platform statistics."""
platform_id: int
platform_code: str
platform_name: str
vendor_count: int
platform_pages_count: int
vendor_defaults_count: int
vendor_overrides_count: int = 0
published_pages_count: int = 0
draft_pages_count: int = 0
class PlatformService:
"""Service for platform operations."""
@staticmethod
def get_platform_by_code(db: Session, code: str) -> Platform:
"""
Get platform by code.
Args:
db: Database session
code: Platform code (oms, loyalty, main, etc.)
Returns:
Platform object
Raises:
PlatformNotFoundException: If platform not found
"""
platform = db.query(Platform).filter(Platform.code == code).first()
if not platform:
raise PlatformNotFoundException(code)
return platform
@staticmethod
def get_platform_by_code_optional(db: Session, code: str) -> Platform | None:
"""
Get platform by code, returns None if not found.
Args:
db: Database session
code: Platform code
Returns:
Platform object or None
"""
return db.query(Platform).filter(Platform.code == code).first()
@staticmethod
def list_platforms(
db: Session, include_inactive: bool = False
) -> list[Platform]:
"""
List all platforms.
Args:
db: Database session
include_inactive: Include inactive platforms
Returns:
List of Platform objects
"""
query = db.query(Platform)
if not include_inactive:
query = query.filter(Platform.is_active == True)
return query.order_by(Platform.id).all()
@staticmethod
def get_vendor_count(db: Session, platform_id: int) -> int:
"""
Get count of vendors on a platform.
Args:
db: Database session
platform_id: Platform ID
Returns:
Vendor count
"""
return (
db.query(func.count(VendorPlatform.vendor_id))
.filter(VendorPlatform.platform_id == platform_id)
.scalar()
or 0
)
@staticmethod
def get_platform_pages_count(db: Session, platform_id: int) -> int:
"""
Get count of platform marketing pages.
Args:
db: Database session
platform_id: Platform ID
Returns:
Platform pages count
"""
return (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform_id,
ContentPage.vendor_id == None,
ContentPage.is_platform_page == True,
)
.scalar()
or 0
)
@staticmethod
def get_vendor_defaults_count(db: Session, platform_id: int) -> int:
"""
Get count of vendor default pages.
Args:
db: Database session
platform_id: Platform ID
Returns:
Vendor defaults count
"""
return (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform_id,
ContentPage.vendor_id == None,
ContentPage.is_platform_page == False,
)
.scalar()
or 0
)
@staticmethod
def get_vendor_overrides_count(db: Session, platform_id: int) -> int:
"""
Get count of vendor override pages.
Args:
db: Database session
platform_id: Platform ID
Returns:
Vendor overrides count
"""
return (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform_id,
ContentPage.vendor_id != None,
)
.scalar()
or 0
)
@staticmethod
def get_published_pages_count(db: Session, platform_id: int) -> int:
"""
Get count of published pages on a platform.
Args:
db: Database session
platform_id: Platform ID
Returns:
Published pages count
"""
return (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform_id,
ContentPage.is_published == True,
)
.scalar()
or 0
)
@staticmethod
def get_draft_pages_count(db: Session, platform_id: int) -> int:
"""
Get count of draft pages on a platform.
Args:
db: Database session
platform_id: Platform ID
Returns:
Draft pages count
"""
return (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform_id,
ContentPage.is_published == False,
)
.scalar()
or 0
)
@classmethod
def get_platform_stats(cls, db: Session, platform: Platform) -> PlatformStats:
"""
Get comprehensive statistics for a platform.
Args:
db: Database session
platform: Platform object
Returns:
PlatformStats dataclass
"""
return PlatformStats(
platform_id=platform.id,
platform_code=platform.code,
platform_name=platform.name,
vendor_count=cls.get_vendor_count(db, platform.id),
platform_pages_count=cls.get_platform_pages_count(db, platform.id),
vendor_defaults_count=cls.get_vendor_defaults_count(db, platform.id),
vendor_overrides_count=cls.get_vendor_overrides_count(db, platform.id),
published_pages_count=cls.get_published_pages_count(db, platform.id),
draft_pages_count=cls.get_draft_pages_count(db, platform.id),
)
@staticmethod
def update_platform(
db: Session, platform: Platform, update_data: dict
) -> Platform:
"""
Update platform fields.
Note: This method does NOT commit the transaction.
The caller (API endpoint) is responsible for committing.
Args:
db: Database session
platform: Platform to update
update_data: Dictionary of fields to update
Returns:
Updated Platform object (with pending changes)
"""
for field, value in update_data.items():
if hasattr(platform, field):
setattr(platform, field, value)
logger.info(f"[PLATFORMS] Updated platform: {platform.code}")
return platform
# Singleton instance for convenience
platform_service = PlatformService()

View File

@@ -257,6 +257,19 @@ class VendorService:
return vendor
def get_vendor_by_id_optional(self, db: Session, vendor_id: int) -> Vendor | None:
"""
Get vendor by ID, returns None if not found.
Args:
db: Database session
vendor_id: Vendor ID to find
Returns:
Vendor object or None if not found
"""
return db.query(Vendor).filter(Vendor.id == vendor_id).first()
def get_active_vendor_by_code(self, db: Session, vendor_code: str) -> Vendor:
"""
Get active vendor by vendor_code for public access (no auth required).

View File

@@ -3,6 +3,7 @@
{% from 'shared/macros/alerts.html' import loading_state, error_state, alert_dynamic %}
{% from 'shared/macros/headers.html' import back_button %}
{% from 'shared/macros/inputs.html' import number_stepper %}
{% from 'shared/macros/modals.html' import modal %}
{% block title %}{% if page_id %}Edit{% else %}Create{% endif %} Content Page{% endblock %}
@@ -71,46 +72,18 @@
</div>
<!-- Default Content Preview Modal -->
<div
x-show="showingDefaultPreview"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click.self="showingDefaultPreview = false"
>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[80vh] overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Platform Default Content</h3>
<button @click="showingDefaultPreview = false" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
</button>
</div>
<div class="p-6 overflow-y-auto max-h-[60vh]">
<div x-show="loadingDefault" class="text-center py-8">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-sm text-gray-500">Loading default content...</p>
</div>
<div x-show="!loadingDefault && defaultContent">
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Title</h4>
<p class="text-lg font-semibold text-gray-900 dark:text-white mb-4" x-text="defaultContent?.title"></p>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Content</h4>
<div class="prose dark:prose-invert max-w-none bg-gray-50 dark:bg-gray-700 rounded-lg p-4" x-html="defaultContent?.content"></div>
</div>
</div>
<div class="flex justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50">
<button
@click="showingDefaultPreview = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
Close
</button>
</div>
{% call modal('defaultPreviewModal', 'Platform Default Content', 'showingDefaultPreview', size='lg', show_footer=false) %}
<div x-show="loadingDefault" class="text-center py-8">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-sm text-gray-500">Loading default content...</p>
</div>
</div>
<div x-show="!loadingDefault && defaultContent">
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Title</h4>
<p class="text-lg font-semibold text-gray-900 dark:text-white mb-4" x-text="defaultContent?.title"></p>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Content</h4>
<div class="prose dark:prose-invert max-w-none bg-gray-50 dark:bg-gray-700 rounded-lg p-4" x-html="defaultContent?.content"></div>
</div>
{% endcall %}
<!-- Main Form -->
<div x-show="!loading" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">

View File

@@ -4,6 +4,8 @@
* Handles platform listing and management for multi-platform CMS.
*/
const platformsLog = window.LogConfig.createLogger('PLATFORMS');
function platformsManager() {
return {
// State
@@ -13,6 +15,10 @@ function platformsManager() {
// Lifecycle
async init() {
// Duplicate initialization guard
if (window._adminPlatformsInitialized) return;
window._adminPlatformsInitialized = true;
this.currentPage = "platforms";
await this.loadPlatforms();
},
@@ -25,9 +31,9 @@ function platformsManager() {
try {
const response = await apiClient.get("/admin/platforms");
this.platforms = response.platforms || [];
console.log(`[PLATFORMS] Loaded ${this.platforms.length} platforms`);
platformsLog.info(`Loaded ${this.platforms.length} platforms`);
} catch (err) {
console.error("[PLATFORMS] Error loading platforms:", err);
platformsLog.error("Error loading platforms:", err);
this.error = err.message || "Failed to load platforms";
} finally {
this.loading = false;