diff --git a/alembic/env.py b/alembic/env.py
index 8217dcbe..3c1d6f56 100644
--- a/alembic/env.py
+++ b/alembic/env.py
@@ -95,12 +95,12 @@ except ImportError as e:
print(f" ✗ VendorTheme model failed: {e}")
# ----------------------------------------------------------------------------
-# CONTENT PAGE MODEL (CMS)
+# CONTENT PAGE MODEL (CMS Module)
# ----------------------------------------------------------------------------
try:
- from models.database.content_page import ContentPage
+ from app.modules.cms.models import ContentPage
- print(" ✓ ContentPage model imported")
+ print(" ✓ ContentPage model imported (from CMS module)")
except ImportError as e:
print(f" ✗ ContentPage model failed: {e}")
diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py
index 5296609e..987aec02 100644
--- a/app/api/v1/admin/__init__.py
+++ b/app/api/v1/admin/__init__.py
@@ -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"])
diff --git a/app/api/v1/shop/__init__.py b/app/api/v1/shop/__init__.py
index e6a8225c..f10a5063 100644
--- a/app/api/v1/shop/__init__.py
+++ b/app/api/v1/shop/__init__.py
@@ -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"]
diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/vendor/__init__.py
index 1ad06858..0c8ebabc 100644
--- a/app/api/v1/vendor/__init__.py
+++ b/app/api/v1/vendor/__init__.py
@@ -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"])
diff --git a/app/exceptions/content_page.py b/app/exceptions/content_page.py
deleted file mode 100644
index 3cbd10ee..00000000
--- a/app/exceptions/content_page.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# app/exceptions/content_page.py
-"""
-DEPRECATED: This module has moved to app.modules.cms.exceptions
-
-Please update your imports:
- # Old (deprecated):
- from app.exceptions.content_page import ContentPageNotFoundException
-
- # New (preferred):
- from app.modules.cms.exceptions import ContentPageNotFoundException
-
-This shim re-exports from the new location for backwards compatibility.
-"""
-
-import warnings
-
-warnings.warn(
- "Import from app.modules.cms.exceptions instead of "
- "app.exceptions.content_page. This shim will be removed in a future version.",
- DeprecationWarning,
- stacklevel=2,
-)
-
-# Re-export everything from the new location
-from app.modules.cms.exceptions import ( # noqa: E402, F401
- ContentPageAlreadyExistsException,
- ContentPageNotFoundException,
- ContentPageNotPublishedException,
- ContentPageSlugReservedException,
- ContentPageValidationException,
- UnauthorizedContentPageAccessException,
- VendorNotAssociatedException,
-)
-
-__all__ = [
- "ContentPageNotFoundException",
- "ContentPageAlreadyExistsException",
- "ContentPageSlugReservedException",
- "ContentPageNotPublishedException",
- "UnauthorizedContentPageAccessException",
- "VendorNotAssociatedException",
- "ContentPageValidationException",
-]
diff --git a/app/modules/cms/__init__.py b/app/modules/cms/__init__.py
index f0d44725..5820ee98 100644
--- a/app/modules/cms/__init__.py
+++ b/app/modules/cms/__init__.py
@@ -22,14 +22,9 @@ Menu Items:
- Vendor: content-pages, media
Usage:
- # Preferred: Import from module directly
from app.modules.cms.services import content_page_service
from app.modules.cms.models import ContentPage
from app.modules.cms.exceptions import ContentPageNotFoundException
-
- # Legacy: Still works via re-export shims (deprecated)
- from app.services.content_page_service import content_page_service
- from models.database.content_page import ContentPage
"""
from app.modules.cms.definition import cms_module
diff --git a/app/modules/cms/definition.py b/app/modules/cms/definition.py
index 49db8591..5c70ef69 100644
--- a/app/modules/cms/definition.py
+++ b/app/modules/cms/definition.py
@@ -9,9 +9,7 @@ This is a self-contained module with:
- Services: app.modules.cms.services
- Models: app.modules.cms.models
- Exceptions: app.modules.cms.exceptions
-
-Templates remain in core (app/templates/admin/) for now due to
-admin/base.html inheritance dependency.
+- Templates: app.modules.cms.templates (namespaced as cms/)
"""
from app.modules.base import ModuleDefinition
@@ -61,8 +59,8 @@ cms_module = ModuleDefinition(
services_path="app.modules.cms.services",
models_path="app.modules.cms.models",
exceptions_path="app.modules.cms.exceptions",
- # Templates remain in core for now (admin/content-pages*.html)
- templates_path=None,
+ # Module templates (namespaced as cms/admin/*.html and cms/vendor/*.html)
+ templates_path="templates",
# Module-specific translations (accessible via cms.* keys)
locales_path="locales",
)
diff --git a/app/modules/cms/models/__init__.py b/app/modules/cms/models/__init__.py
index 60b51aac..5d10eb19 100644
--- a/app/modules/cms/models/__init__.py
+++ b/app/modules/cms/models/__init__.py
@@ -2,19 +2,20 @@
"""
CMS module database models.
-This package re-exports the ContentPage model from its canonical location
-in models.database. SQLAlchemy models must remain in a single location to
-avoid circular imports at startup time.
+This is the canonical location for CMS models. Module models are automatically
+discovered and registered with SQLAlchemy's Base.metadata at startup.
Usage:
- from app.modules.cms.models import ContentPage
-
-The canonical model is at: models.database.content_page.ContentPage
+ from app.modules.cms.models import ContentPage, MediaFile, ProductMedia
"""
-# Import from canonical location to avoid circular imports
-from models.database.content_page import ContentPage
+from app.modules.cms.models.content_page import ContentPage
+
+# Media models remain in core for now (used by multiple modules)
+from models.database.media import MediaFile, ProductMedia
__all__ = [
"ContentPage",
+ "MediaFile",
+ "ProductMedia",
]
diff --git a/models/database/content_page.py b/app/modules/cms/models/content_page.py
similarity index 98%
rename from models/database/content_page.py
rename to app/modules/cms/models/content_page.py
index 03e8ac30..95e8e3b3 100644
--- a/models/database/content_page.py
+++ b/app/modules/cms/models/content_page.py
@@ -1,4 +1,4 @@
-# models/database/content_page.py
+# app/modules/cms/models/content_page.py
"""
Content Page Model
@@ -24,9 +24,6 @@ Features:
- SEO metadata
- Published/Draft status
- Navigation placement (header, footer, legal)
-
-NOTE: This is the canonical location for the ContentPage model.
-The CMS module (app.modules.cms) re-exports this model.
"""
from datetime import UTC, datetime
diff --git a/app/modules/cms/routes/api/__init__.py b/app/modules/cms/routes/api/__init__.py
new file mode 100644
index 00000000..8794c27c
--- /dev/null
+++ b/app/modules/cms/routes/api/__init__.py
@@ -0,0 +1,15 @@
+# app/modules/cms/routes/api/__init__.py
+"""
+CMS module API routes.
+
+Provides REST API endpoints for content page management:
+- Admin API: Full CRUD for platform administrators
+- Vendor API: Vendor-scoped CRUD with ownership checks
+- Shop API: Public read-only access for storefronts
+"""
+
+from app.modules.cms.routes.api.admin import router as admin_router
+from app.modules.cms.routes.api.vendor import router as vendor_router
+from app.modules.cms.routes.api.shop import router as shop_router
+
+__all__ = ["admin_router", "vendor_router", "shop_router"]
diff --git a/app/api/v1/admin/content_pages.py b/app/modules/cms/routes/api/admin.py
similarity index 71%
rename from app/api/v1/admin/content_pages.py
rename to app/modules/cms/routes/api/admin.py
index e5243205..8f2c9215 100644
--- a/app/api/v1/admin/content_pages.py
+++ b/app/modules/cms/routes/api/admin.py
@@ -1,4 +1,4 @@
-# app/api/v1/admin/content_pages.py
+# app/modules/cms/routes/api/admin.py
"""
Admin Content Pages API
@@ -11,106 +11,24 @@ Platform administrators can:
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 app.modules.cms.schemas import (
+ ContentPageCreate,
+ ContentPageUpdate,
+ ContentPageResponse,
+ HomepageSectionsResponse,
+ SectionUpdateResponse,
+)
+from app.modules.cms.services 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)
# ============================================================================
@@ -291,21 +209,6 @@ def delete_page(
# ============================================================================
-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,
diff --git a/app/api/v1/shop/content_pages.py b/app/modules/cms/routes/api/shop.py
similarity index 74%
rename from app/api/v1/shop/content_pages.py
rename to app/modules/cms/routes/api/shop.py
index 1ad27ad4..54efb08f 100644
--- a/app/api/v1/shop/content_pages.py
+++ b/app/modules/cms/routes/api/shop.py
@@ -1,4 +1,4 @@
-# app/api/v1/shop/content_pages.py
+# app/modules/cms/routes/api/shop.py
"""
Shop Content Pages API (Public)
@@ -9,49 +9,25 @@ 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
+from app.modules.cms.schemas import (
+ PublicContentPageResponse,
+ ContentPageListItem,
+)
+from app.modules.cms.services 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
+@router.get("/navigation", response_model=list[ContentPageListItem])
def get_navigation_pages(request: Request, db: Session = Depends(get_db)):
"""
Get list of content pages for navigation (footer/header).
diff --git a/app/api/v1/vendor/content_pages.py b/app/modules/cms/routes/api/vendor.py
similarity index 71%
rename from app/api/v1/vendor/content_pages.py
rename to app/modules/cms/routes/api/vendor.py
index a833c7ca..96c293f2 100644
--- a/app/api/v1/vendor/content_pages.py
+++ b/app/modules/cms/routes/api/vendor.py
@@ -1,4 +1,4 @@
-# app/api/v1/vendor/content_pages.py
+# app/modules/cms/routes/api/vendor.py
"""
Vendor Content Pages API
@@ -14,106 +14,26 @@ Vendors can:
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.modules.cms.exceptions import ContentPageNotFoundException
+from app.modules.cms.schemas import (
+ VendorContentPageCreate,
+ VendorContentPageUpdate,
+ ContentPageResponse,
+ CMSUsageResponse,
+)
+from app.modules.cms.services 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")
+router = APIRouter()
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
# ============================================================================
@@ -155,6 +75,96 @@ def list_vendor_overrides(
return [page.to_dict() for page in pages]
+@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:
+ raise ContentPageNotFoundException(slug)
+
+ return page.to_dict()
+
+
@router.get("/{slug}", response_model=ContentPageResponse)
def get_page(
slug: str,
@@ -258,99 +268,3 @@ 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.
- """
- 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()
diff --git a/app/modules/cms/routes/pages/__init__.py b/app/modules/cms/routes/pages/__init__.py
new file mode 100644
index 00000000..e53999f0
--- /dev/null
+++ b/app/modules/cms/routes/pages/__init__.py
@@ -0,0 +1,13 @@
+# app/modules/cms/routes/pages/__init__.py
+"""
+CMS module page routes (HTML rendering).
+
+Provides Jinja2 template rendering for content page management:
+- Admin pages: Platform content page management
+- Vendor pages: Vendor content page management and CMS rendering
+"""
+
+from app.modules.cms.routes.pages.admin import router as admin_router
+from app.modules.cms.routes.pages.vendor import router as vendor_router
+
+__all__ = ["admin_router", "vendor_router"]
diff --git a/app/modules/cms/routes/pages/admin.py b/app/modules/cms/routes/pages/admin.py
new file mode 100644
index 00000000..93817a47
--- /dev/null
+++ b/app/modules/cms/routes/pages/admin.py
@@ -0,0 +1,104 @@
+# app/modules/cms/routes/pages/admin.py
+"""
+CMS Admin Page Routes (HTML rendering).
+
+Admin pages for managing platform and vendor content pages.
+"""
+
+from fastapi import APIRouter, Depends, Path, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+from sqlalchemy.orm import Session
+
+from app.api.deps import get_db, require_menu_access
+from app.templates_config import templates
+from models.database.admin_menu_config import FrontendType
+from models.database.user import User
+
+router = APIRouter()
+
+
+# ============================================================================
+# CONTENT PAGES MANAGEMENT
+# ============================================================================
+
+
+@router.get("/platform-homepage", include_in_schema=False)
+async def admin_platform_homepage_manager(
+ request: Request,
+ current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)),
+ db: Session = Depends(get_db),
+):
+ """
+ Deprecated: Redirects to platforms page.
+
+ Platform homepages are now managed via:
+ - /admin/platforms → Select platform → Homepage button
+ - Or directly: /admin/content-pages?platform_code={code}&slug=home
+ """
+ return RedirectResponse(url="/admin/platforms", status_code=302)
+
+
+@router.get("/content-pages", response_class=HTMLResponse, include_in_schema=False)
+async def admin_content_pages_list(
+ request: Request,
+ current_user: User = Depends(require_menu_access("content-pages", FrontendType.ADMIN)),
+ db: Session = Depends(get_db),
+):
+ """
+ Render content pages list.
+ Shows all platform defaults and vendor overrides with filtering.
+ """
+ return templates.TemplateResponse(
+ "cms/admin/content-pages.html",
+ {
+ "request": request,
+ "user": current_user,
+ },
+ )
+
+
+@router.get(
+ "/content-pages/create", response_class=HTMLResponse, include_in_schema=False
+)
+async def admin_content_page_create(
+ request: Request,
+ current_user: User = Depends(require_menu_access("content-pages", FrontendType.ADMIN)),
+ db: Session = Depends(get_db),
+):
+ """
+ Render create content page form.
+ Allows creating new platform defaults or vendor-specific pages.
+ """
+ return templates.TemplateResponse(
+ "cms/admin/content-page-edit.html",
+ {
+ "request": request,
+ "user": current_user,
+ "page_id": None, # Indicates this is a create operation
+ },
+ )
+
+
+@router.get(
+ "/content-pages/{page_id}/edit",
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+async def admin_content_page_edit(
+ request: Request,
+ page_id: int = Path(..., description="Content page ID"),
+ current_user: User = Depends(require_menu_access("content-pages", FrontendType.ADMIN)),
+ db: Session = Depends(get_db),
+):
+ """
+ Render edit content page form.
+ Allows editing existing platform or vendor content pages.
+ """
+ return templates.TemplateResponse(
+ "cms/admin/content-page-edit.html",
+ {
+ "request": request,
+ "user": current_user,
+ "page_id": page_id,
+ },
+ )
diff --git a/app/modules/cms/routes/pages/vendor.py b/app/modules/cms/routes/pages/vendor.py
new file mode 100644
index 00000000..78da9c95
--- /dev/null
+++ b/app/modules/cms/routes/pages/vendor.py
@@ -0,0 +1,225 @@
+# app/modules/cms/routes/pages/vendor.py
+"""
+CMS Vendor Page Routes (HTML rendering).
+
+Vendor pages for managing content pages and rendering CMS content.
+"""
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException, Path, Request
+from fastapi.responses import HTMLResponse
+from sqlalchemy.orm import Session
+
+from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
+from app.modules.cms.services import content_page_service
+from app.services.platform_settings_service import platform_settings_service
+from app.templates_config import templates
+from models.database.user import User
+from models.database.vendor import Vendor
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+# ============================================================================
+# HELPER: Build Vendor Dashboard Context
+# ============================================================================
+
+
+def get_vendor_context(
+ request: Request,
+ db: Session,
+ current_user: User,
+ vendor_code: str,
+ **extra_context,
+) -> dict:
+ """
+ Build template context for vendor dashboard pages.
+
+ Resolves locale/currency using the platform settings service with
+ vendor override support.
+ """
+ # Load vendor from database
+ vendor = db.query(Vendor).filter(Vendor.subdomain == vendor_code).first()
+
+ # Get platform defaults
+ platform_config = platform_settings_service.get_storefront_config(db)
+
+ # Resolve with vendor override
+ storefront_locale = platform_config["locale"]
+ storefront_currency = platform_config["currency"]
+
+ if vendor and vendor.storefront_locale:
+ storefront_locale = vendor.storefront_locale
+
+ context = {
+ "request": request,
+ "user": current_user,
+ "vendor": vendor,
+ "vendor_code": vendor_code,
+ "storefront_locale": storefront_locale,
+ "storefront_currency": storefront_currency,
+ "dashboard_language": vendor.dashboard_language if vendor else "en",
+ }
+
+ # Add any extra context
+ if extra_context:
+ context.update(extra_context)
+
+ return context
+
+
+# ============================================================================
+# CONTENT PAGES MANAGEMENT
+# ============================================================================
+
+
+@router.get(
+ "/{vendor_code}/content-pages", response_class=HTMLResponse, include_in_schema=False
+)
+async def vendor_content_pages_list(
+ request: Request,
+ vendor_code: str = Path(..., description="Vendor code"),
+ current_user: User = Depends(get_current_vendor_from_cookie_or_header),
+ db: Session = Depends(get_db),
+):
+ """
+ Render content pages management page.
+ Shows platform defaults (can be overridden) and vendor custom pages.
+ """
+ return templates.TemplateResponse(
+ "cms/vendor/content-pages.html",
+ get_vendor_context(request, db, current_user, vendor_code),
+ )
+
+
+@router.get(
+ "/{vendor_code}/content-pages/create",
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+async def vendor_content_page_create(
+ request: Request,
+ vendor_code: str = Path(..., description="Vendor code"),
+ current_user: User = Depends(get_current_vendor_from_cookie_or_header),
+ db: Session = Depends(get_db),
+):
+ """
+ Render content page creation form.
+ """
+ return templates.TemplateResponse(
+ "cms/vendor/content-page-edit.html",
+ get_vendor_context(request, db, current_user, vendor_code, page_id=None),
+ )
+
+
+@router.get(
+ "/{vendor_code}/content-pages/{page_id}/edit",
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+async def vendor_content_page_edit(
+ request: Request,
+ vendor_code: str = Path(..., description="Vendor code"),
+ page_id: int = Path(..., description="Content page ID"),
+ current_user: User = Depends(get_current_vendor_from_cookie_or_header),
+ db: Session = Depends(get_db),
+):
+ """
+ Render content page edit form.
+ """
+ return templates.TemplateResponse(
+ "cms/vendor/content-page-edit.html",
+ get_vendor_context(request, db, current_user, vendor_code, page_id=page_id),
+ )
+
+
+# ============================================================================
+# DYNAMIC CONTENT PAGES (CMS) - Public Shop Display
+# ============================================================================
+
+
+@router.get(
+ "/{vendor_code}/{slug}", response_class=HTMLResponse, include_in_schema=False
+)
+async def vendor_content_page(
+ request: Request,
+ vendor_code: str = Path(..., description="Vendor code"),
+ slug: str = Path(..., description="Content page slug"),
+ db: Session = Depends(get_db),
+):
+ """
+ Generic content page handler for vendor shop (CMS).
+
+ Handles dynamic content pages like:
+ - /vendors/wizamart/about, /vendors/wizamart/faq, /vendors/wizamart/contact, etc.
+
+ Features:
+ - Two-tier system: Vendor overrides take priority, fallback to platform defaults
+ - Only shows published pages
+ - Returns 404 if page not found or unpublished
+
+ NOTE: This is a catch-all route and must be registered LAST to avoid
+ shadowing other specific routes.
+ """
+ logger.debug(
+ "[CMS] vendor_content_page REACHED",
+ extra={
+ "path": request.url.path,
+ "vendor_code": vendor_code,
+ "slug": slug,
+ "vendor": getattr(request.state, "vendor", "NOT SET"),
+ "context": getattr(request.state, "context_type", "NOT SET"),
+ },
+ )
+
+ vendor = getattr(request.state, "vendor", None)
+ vendor_id = vendor.id if vendor else None
+
+ # Load content page from database (vendor override → platform default)
+ page = content_page_service.get_page_for_vendor(
+ db, slug=slug, vendor_id=vendor_id, include_unpublished=False
+ )
+
+ if not page:
+ logger.info(
+ f"[CMS] Content page not found: {slug}",
+ extra={
+ "slug": slug,
+ "vendor_code": vendor_code,
+ "vendor_id": vendor_id,
+ },
+ )
+ raise HTTPException(status_code=404, detail="Page not found")
+
+ logger.info(
+ f"[CMS] Rendering page: {page.title}",
+ extra={
+ "slug": slug,
+ "page_id": page.id,
+ "is_vendor_override": page.vendor_id is not None,
+ "vendor_id": vendor_id,
+ },
+ )
+
+ # Resolve locale for shop template
+ platform_config = platform_settings_service.get_storefront_config(db)
+ storefront_locale = platform_config["locale"]
+ storefront_currency = platform_config["currency"]
+
+ if vendor and vendor.storefront_locale:
+ storefront_locale = vendor.storefront_locale
+
+ return templates.TemplateResponse(
+ "shop/content-page.html",
+ {
+ "request": request,
+ "page": page,
+ "vendor": vendor,
+ "vendor_code": vendor_code,
+ "storefront_locale": storefront_locale,
+ "storefront_currency": storefront_currency,
+ },
+ )
diff --git a/app/modules/cms/schemas/__init__.py b/app/modules/cms/schemas/__init__.py
new file mode 100644
index 00000000..231a1600
--- /dev/null
+++ b/app/modules/cms/schemas/__init__.py
@@ -0,0 +1,36 @@
+# app/modules/cms/schemas/__init__.py
+"""
+CMS module Pydantic schemas for API request/response validation.
+"""
+
+from app.modules.cms.schemas.content_page import (
+ # Admin schemas
+ ContentPageCreate,
+ ContentPageUpdate,
+ ContentPageResponse,
+ HomepageSectionsResponse,
+ SectionUpdateResponse,
+ # Vendor schemas
+ VendorContentPageCreate,
+ VendorContentPageUpdate,
+ CMSUsageResponse,
+ # Public/Shop schemas
+ PublicContentPageResponse,
+ ContentPageListItem,
+)
+
+__all__ = [
+ # Admin
+ "ContentPageCreate",
+ "ContentPageUpdate",
+ "ContentPageResponse",
+ "HomepageSectionsResponse",
+ "SectionUpdateResponse",
+ # Vendor
+ "VendorContentPageCreate",
+ "VendorContentPageUpdate",
+ "CMSUsageResponse",
+ # Public
+ "PublicContentPageResponse",
+ "ContentPageListItem",
+]
diff --git a/app/modules/cms/schemas/content_page.py b/app/modules/cms/schemas/content_page.py
new file mode 100644
index 00000000..ee99168d
--- /dev/null
+++ b/app/modules/cms/schemas/content_page.py
@@ -0,0 +1,201 @@
+# app/modules/cms/schemas/content_page.py
+"""
+Content Page Pydantic schemas for API request/response validation.
+
+Schemas are organized by context:
+- Admin: Full CRUD with platform-level access
+- Vendor: Vendor-scoped CRUD with usage limits
+- Public/Shop: Read-only public access
+"""
+
+from pydantic import BaseModel, Field
+
+
+# ============================================================================
+# ADMIN SCHEMAS
+# ============================================================================
+
+
+class ContentPageCreate(BaseModel):
+ """Schema for creating a content page (admin)."""
+
+ 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 (admin)."""
+
+ 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 (admin/vendor)."""
+
+ 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
+
+
+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
+
+
+# ============================================================================
+# VENDOR 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 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
+
+
+# ============================================================================
+# PUBLIC/SHOP 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
diff --git a/app/modules/cms/services/content_page_service.py b/app/modules/cms/services/content_page_service.py
index ebd5ff7e..292400f4 100644
--- a/app/modules/cms/services/content_page_service.py
+++ b/app/modules/cms/services/content_page_service.py
@@ -32,8 +32,7 @@ from app.modules.cms.exceptions import (
ContentPageNotFoundException,
UnauthorizedContentPageAccessException,
)
-# Import from canonical location to avoid circular imports
-from models.database.content_page import ContentPage
+from app.modules.cms.models import ContentPage
logger = logging.getLogger(__name__)
diff --git a/static/admin/js/content-page-edit.js b/app/modules/cms/static/admin/js/content-page-edit.js
similarity index 100%
rename from static/admin/js/content-page-edit.js
rename to app/modules/cms/static/admin/js/content-page-edit.js
diff --git a/static/admin/js/content-pages.js b/app/modules/cms/static/admin/js/content-pages.js
similarity index 100%
rename from static/admin/js/content-pages.js
rename to app/modules/cms/static/admin/js/content-pages.js
diff --git a/static/vendor/js/content-page-edit.js b/app/modules/cms/static/vendor/js/content-page-edit.js
similarity index 100%
rename from static/vendor/js/content-page-edit.js
rename to app/modules/cms/static/vendor/js/content-page-edit.js
diff --git a/static/vendor/js/content-pages.js b/app/modules/cms/static/vendor/js/content-pages.js
similarity index 100%
rename from static/vendor/js/content-pages.js
rename to app/modules/cms/static/vendor/js/content-pages.js
diff --git a/app/templates/admin/content-page-edit.html b/app/modules/cms/templates/cms/admin/content-page-edit.html
similarity index 99%
rename from app/templates/admin/content-page-edit.html
rename to app/modules/cms/templates/cms/admin/content-page-edit.html
index 51676367..fb9d00ac 100644
--- a/app/templates/admin/content-page-edit.html
+++ b/app/modules/cms/templates/cms/admin/content-page-edit.html
@@ -1,4 +1,4 @@
-{# app/templates/admin/content-page-edit.html #}
+{# app/modules/cms/templates/cms/admin/content-page-edit.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state, alert_dynamic %}
{% from 'shared/macros/headers.html' import page_header_flex, back_button, action_button %}
@@ -636,5 +636,5 @@
{% endblock %}
{% block extra_scripts %}
-
+
{% endblock %}
diff --git a/app/templates/admin/content-pages.html b/app/modules/cms/templates/cms/admin/content-pages.html
similarity index 98%
rename from app/templates/admin/content-pages.html
rename to app/modules/cms/templates/cms/admin/content-pages.html
index a38d1f44..f8808455 100644
--- a/app/templates/admin/content-pages.html
+++ b/app/modules/cms/templates/cms/admin/content-pages.html
@@ -1,4 +1,4 @@
-{# app/templates/admin/content-pages.html #}
+{# app/modules/cms/templates/cms/admin/content-pages.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
@@ -178,5 +178,5 @@
{% endblock %}
{% block extra_scripts %}
-
+
{% endblock %}
diff --git a/app/templates/vendor/content-page-edit.html b/app/modules/cms/templates/cms/vendor/content-page-edit.html
similarity index 99%
rename from app/templates/vendor/content-page-edit.html
rename to app/modules/cms/templates/cms/vendor/content-page-edit.html
index 06bf5e5f..dbe3e10d 100644
--- a/app/templates/vendor/content-page-edit.html
+++ b/app/modules/cms/templates/cms/vendor/content-page-edit.html
@@ -1,4 +1,4 @@
-{# app/templates/vendor/content-page-edit.html #}
+{# app/modules/cms/templates/cms/vendor/content-page-edit.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state, alert_dynamic %}
{% from 'shared/macros/headers.html' import back_button %}
@@ -322,5 +322,5 @@
{% endblock %}
{% block extra_scripts %}
-
+
{% endblock %}
diff --git a/app/templates/vendor/content-pages.html b/app/modules/cms/templates/cms/vendor/content-pages.html
similarity index 99%
rename from app/templates/vendor/content-pages.html
rename to app/modules/cms/templates/cms/vendor/content-pages.html
index c19bc8af..c08775f5 100644
--- a/app/templates/vendor/content-pages.html
+++ b/app/modules/cms/templates/cms/vendor/content-pages.html
@@ -1,4 +1,4 @@
-{# app/templates/vendor/content-pages.html #}
+{# app/modules/cms/templates/cms/vendor/content-pages.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
@@ -323,5 +323,5 @@
{% endblock %}
{% block extra_scripts %}
-
+
{% endblock %}
diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py
index 80be812d..01625a15 100644
--- a/app/routes/admin_pages.py
+++ b/app/routes/admin_pages.py
@@ -44,7 +44,6 @@ Routes:
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse
-from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.api.deps import (
@@ -53,11 +52,11 @@ from app.api.deps import (
require_menu_access,
)
from app.core.config import settings
+from app.templates_config import templates
from models.database.admin_menu_config import FrontendType
from models.database.user import User
router = APIRouter()
-templates = Jinja2Templates(directory="app/templates")
# ============================================================================
@@ -1278,88 +1277,8 @@ async def admin_platform_modules(
# ============================================================================
# CONTENT MANAGEMENT SYSTEM (CMS) ROUTES
# ============================================================================
-
-
-@router.get("/platform-homepage", include_in_schema=False)
-async def admin_platform_homepage_manager(
- request: Request,
- current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)),
- db: Session = Depends(get_db),
-):
- """
- Deprecated: Redirects to platforms page.
-
- Platform homepages are now managed via:
- - /admin/platforms → Select platform → Homepage button
- - Or directly: /admin/content-pages?platform_code={code}&slug=home
- """
- return RedirectResponse(url="/admin/platforms", status_code=302)
-
-
-@router.get("/content-pages", response_class=HTMLResponse, include_in_schema=False)
-async def admin_content_pages_list(
- request: Request,
- current_user: User = Depends(require_menu_access("content-pages", FrontendType.ADMIN)),
- db: Session = Depends(get_db),
-):
- """
- Render content pages list.
- Shows all platform defaults and vendor overrides with filtering.
- """
- return templates.TemplateResponse(
- "admin/content-pages.html",
- {
- "request": request,
- "user": current_user,
- },
- )
-
-
-@router.get(
- "/content-pages/create", response_class=HTMLResponse, include_in_schema=False
-)
-async def admin_content_page_create(
- request: Request,
- current_user: User = Depends(require_menu_access("content-pages", FrontendType.ADMIN)),
- db: Session = Depends(get_db),
-):
- """
- Render create content page form.
- Allows creating new platform defaults or vendor-specific pages.
- """
- return templates.TemplateResponse(
- "admin/content-page-edit.html",
- {
- "request": request,
- "user": current_user,
- "page_id": None, # Indicates this is a create operation
- },
- )
-
-
-@router.get(
- "/content-pages/{page_id}/edit",
- response_class=HTMLResponse,
- include_in_schema=False,
-)
-async def admin_content_page_edit(
- request: Request,
- page_id: int = Path(..., description="Content page ID"),
- current_user: User = Depends(require_menu_access("content-pages", FrontendType.ADMIN)),
- db: Session = Depends(get_db),
-):
- """
- Render edit content page form.
- Allows editing existing platform or vendor content pages.
- """
- return templates.TemplateResponse(
- "admin/content-page-edit.html",
- {
- "request": request,
- "user": current_user,
- "page_id": page_id,
- },
- )
+# NOTE: CMS routes moved to self-contained module: app.modules.cms.routes.pages.admin
+# Routes are registered directly in main.py from the CMS module
# ============================================================================
diff --git a/app/routes/platform_pages.py b/app/routes/platform_pages.py
index 540b03b0..92261e1f 100644
--- a/app/routes/platform_pages.py
+++ b/app/routes/platform_pages.py
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
-from app.services.content_page_service import content_page_service
+from app.modules.cms.services import content_page_service
from app.utils.i18n import get_jinja2_globals
router = APIRouter()
diff --git a/app/routes/shop_pages.py b/app/routes/shop_pages.py
index ea82d35f..0fe15989 100644
--- a/app/routes/shop_pages.py
+++ b/app/routes/shop_pages.py
@@ -34,16 +34,15 @@ import logging
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse
-from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_from_cookie_or_header, get_db
-from app.services.content_page_service import content_page_service
+from app.modules.cms.services import content_page_service
from app.services.platform_settings_service import platform_settings_service
+from app.templates_config import templates
from models.database.customer import Customer
router = APIRouter()
-templates = Jinja2Templates(directory="app/templates")
logger = logging.getLogger(__name__)
diff --git a/app/routes/vendor_pages.py b/app/routes/vendor_pages.py
index f47f4eb7..908f7331 100644
--- a/app/routes/vendor_pages.py
+++ b/app/routes/vendor_pages.py
@@ -26,7 +26,6 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse
-from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.api.deps import (
@@ -34,16 +33,15 @@ from app.api.deps import (
get_current_vendor_optional,
get_db,
)
-from app.services.content_page_service import content_page_service
from app.services.onboarding_service import OnboardingService
from app.services.platform_settings_service import platform_settings_service
+from app.templates_config import templates
from models.database.user import User
from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
router = APIRouter()
-templates = Jinja2Templates(directory="app/templates")
# ============================================================================
@@ -697,154 +695,12 @@ async def vendor_analytics_page(
# ============================================================================
-# CONTENT PAGES MANAGEMENT
+# CONTENT PAGES MANAGEMENT & CMS
# ============================================================================
-
-
-@router.get(
- "/{vendor_code}/content-pages", response_class=HTMLResponse, include_in_schema=False
-)
-async def vendor_content_pages_list(
- request: Request,
- vendor_code: str = Path(..., description="Vendor code"),
- current_user: User = Depends(get_current_vendor_from_cookie_or_header),
- db: Session = Depends(get_db),
-):
- """
- Render content pages management page.
- Shows platform defaults (can be overridden) and vendor custom pages.
- """
- return templates.TemplateResponse(
- "vendor/content-pages.html",
- get_vendor_context(request, db, current_user, vendor_code),
- )
-
-
-@router.get(
- "/{vendor_code}/content-pages/create",
- response_class=HTMLResponse,
- include_in_schema=False,
-)
-async def vendor_content_page_create(
- request: Request,
- vendor_code: str = Path(..., description="Vendor code"),
- current_user: User = Depends(get_current_vendor_from_cookie_or_header),
- db: Session = Depends(get_db),
-):
- """
- Render content page creation form.
- """
- return templates.TemplateResponse(
- "vendor/content-page-edit.html",
- get_vendor_context(request, db, current_user, vendor_code, page_id=None),
- )
-
-
-@router.get(
- "/{vendor_code}/content-pages/{page_id}/edit",
- response_class=HTMLResponse,
- include_in_schema=False,
-)
-async def vendor_content_page_edit(
- request: Request,
- vendor_code: str = Path(..., description="Vendor code"),
- page_id: int = Path(..., description="Content page ID"),
- current_user: User = Depends(get_current_vendor_from_cookie_or_header),
- db: Session = Depends(get_db),
-):
- """
- Render content page edit form.
- """
- return templates.TemplateResponse(
- "vendor/content-page-edit.html",
- get_vendor_context(request, db, current_user, vendor_code, page_id=page_id),
- )
-
-
-# ============================================================================
-# DYNAMIC CONTENT PAGES (CMS)
-# ============================================================================
-
-
-@router.get(
- "/{vendor_code}/{slug}", response_class=HTMLResponse, include_in_schema=False
-)
-async def vendor_content_page(
- request: Request,
- vendor_code: str = Path(..., description="Vendor code"),
- slug: str = Path(..., description="Content page slug"),
- db: Session = Depends(get_db),
-):
- """
- Generic content page handler for vendor shop (CMS).
-
- Handles dynamic content pages like:
- - /vendors/wizamart/about, /vendors/wizamart/faq, /vendors/wizamart/contact, etc.
-
- Features:
- - Two-tier system: Vendor overrides take priority, fallback to platform defaults
- - Only shows published pages
- - Returns 404 if page not found or unpublished
-
- NOTE: This is a catch-all route and must be registered LAST to avoid
- shadowing other specific routes.
- """
- logger.debug(
- "[VENDOR_HANDLER] vendor_content_page REACHED",
- extra={
- "path": request.url.path,
- "vendor_code": vendor_code,
- "slug": slug,
- "vendor": getattr(request.state, "vendor", "NOT SET"),
- "context": getattr(request.state, "context_type", "NOT SET"),
- },
- )
-
- vendor = getattr(request.state, "vendor", None)
- vendor_id = vendor.id if vendor else None
-
- # Load content page from database (vendor override → platform default)
- page = content_page_service.get_page_for_vendor(
- db, slug=slug, vendor_id=vendor_id, include_unpublished=False
- )
-
- if not page:
- logger.info(
- f"[VENDOR_HANDLER] Content page not found: {slug}",
- extra={
- "slug": slug,
- "vendor_code": vendor_code,
- "vendor_id": vendor_id,
- },
- )
- raise HTTPException(status_code=404, detail="Page not found")
-
- logger.info(
- f"[VENDOR_HANDLER] Rendering CMS page: {page.title}",
- extra={
- "slug": slug,
- "page_id": page.id,
- "is_vendor_override": page.vendor_id is not None,
- "vendor_id": vendor_id,
- },
- )
-
- # Resolve locale for shop template (uses same resolution chain as shop routes)
- platform_config = platform_settings_service.get_storefront_config(db)
- storefront_locale = platform_config["locale"]
- storefront_currency = platform_config["currency"]
-
- if vendor and vendor.storefront_locale:
- storefront_locale = vendor.storefront_locale
-
- return templates.TemplateResponse(
- "shop/content-page.html",
- {
- "request": request,
- "page": page,
- "vendor": vendor,
- "vendor_code": vendor_code,
- "storefront_locale": storefront_locale,
- "storefront_currency": storefront_currency,
- },
- )
+# NOTE: CMS routes moved to self-contained module: app.modules.cms.routes.pages.vendor
+# Routes are registered directly in main.py from the CMS module
+# This includes:
+# - /{vendor_code}/content-pages (list)
+# - /{vendor_code}/content-pages/create
+# - /{vendor_code}/content-pages/{page_id}/edit
+# - /{vendor_code}/{slug} (catch-all CMS page viewer)
diff --git a/app/services/content_page_service.py b/app/services/content_page_service.py
deleted file mode 100644
index e79ae9e8..00000000
--- a/app/services/content_page_service.py
+++ /dev/null
@@ -1,30 +0,0 @@
-# app/services/content_page_service.py
-"""
-DEPRECATED: This module has moved to app.modules.cms.services.content_page_service
-
-Please update your imports:
- # Old (deprecated):
- from app.services.content_page_service import content_page_service
-
- # New (preferred):
- from app.modules.cms.services import content_page_service
-
-This shim re-exports from the new location for backwards compatibility.
-"""
-
-import warnings
-
-warnings.warn(
- "Import from app.modules.cms.services.content_page_service instead of "
- "app.services.content_page_service. This shim will be removed in a future version.",
- DeprecationWarning,
- stacklevel=2,
-)
-
-# Re-export everything from the new location
-from app.modules.cms.services.content_page_service import ( # noqa: E402, F401
- ContentPageService,
- content_page_service,
-)
-
-__all__ = ["ContentPageService", "content_page_service"]
diff --git a/main.py b/main.py
index 3479f6be..24d80fda 100644
--- a/main.py
+++ b/main.py
@@ -63,6 +63,10 @@ from app.exceptions.handler import setup_exception_handlers
# Import page routers
from app.routes import admin_pages, platform_pages, shop_pages, vendor_pages
+
+# Import CMS module page routers (self-contained module)
+from app.modules.cms.routes.pages import admin_router as cms_admin_pages
+from app.modules.cms.routes.pages import vendor_router as cms_vendor_pages
from app.utils.i18n import get_jinja2_globals
from middleware.context import ContextMiddleware
from middleware.language import LanguageMiddleware
@@ -180,6 +184,19 @@ if STATIC_DIR.exists():
else:
logger.warning(f"Static directory not found at {STATIC_DIR}")
+# Mount module static files (self-contained modules)
+MODULES_DIR = BASE_DIR / "app" / "modules"
+if MODULES_DIR.exists():
+ for module_dir in sorted(MODULES_DIR.iterdir()):
+ if not module_dir.is_dir():
+ continue
+ module_static = module_dir / "static"
+ if module_static.exists():
+ module_name = module_dir.name
+ mount_path = f"/static/modules/{module_name}"
+ app.mount(mount_path, StaticFiles(directory=str(module_static)), name=f"{module_name}_static")
+ logger.info(f"Mounted module static files: {mount_path} -> {module_static}")
+
# Mount uploads directory for user-uploaded media files
UPLOADS_DIR = BASE_DIR / "uploads"
if UPLOADS_DIR.exists():
@@ -301,6 +318,13 @@ app.include_router(
admin_pages.router, prefix="/admin", tags=["admin-pages"], include_in_schema=False
)
+# CMS module admin pages (self-contained module)
+# NOTE: These routes are specific (/content-pages/*) so they won't conflict
+logger.info("Registering CMS admin page routes: /admin/content-pages/*")
+app.include_router(
+ cms_admin_pages, prefix="/admin", tags=["cms-admin-pages"], include_in_schema=False
+)
+
# Vendor management pages (dashboard, products, orders, etc.)
logger.info("Registering vendor page routes: /vendor/{code}/*")
app.include_router(
@@ -310,6 +334,13 @@ app.include_router(
include_in_schema=False,
)
+# CMS module vendor pages (self-contained module)
+# NOTE: Includes catch-all /{vendor_code}/{slug} - must be registered AFTER vendor_pages
+logger.info("Registering CMS vendor page routes: /vendor/{code}/content-pages/*")
+app.include_router(
+ cms_vendor_pages, prefix="/vendor", tags=["cms-vendor-pages"], include_in_schema=False
+)
+
# Customer shop pages - Register at TWO prefixes:
# 1. /shop/* (for subdomain/custom domain modes)
# 2. /vendors/{code}/shop/* (for path-based development mode)
@@ -345,7 +376,7 @@ async def vendor_root_path(
raise HTTPException(status_code=404, detail=f"Vendor '{vendor_code}' not found")
from app.routes.shop_pages import get_shop_context
- from app.services.content_page_service import content_page_service
+ from app.modules.cms.services import content_page_service
# Get platform_id (use platform from context or default to 1 for OMS)
platform_id = platform.id if platform else 1
diff --git a/models/database/__init__.py b/models/database/__init__.py
index 2e24b622..42848464 100644
--- a/models/database/__init__.py
+++ b/models/database/__init__.py
@@ -1,5 +1,27 @@
# models/database/__init__.py
-"""Database models package."""
+"""
+Database models package.
+
+This package imports all SQLAlchemy models to ensure they are registered
+with Base.metadata. This includes:
+1. Core models (defined in this directory)
+2. Module models (discovered from app/modules//models/)
+
+Module Model Discovery:
+- Modules can define their own models in app/modules//models/
+- These are automatically imported when this package loads
+- Module models must use `from app.core.database import Base`
+"""
+
+import importlib
+import logging
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# ============================================================================
+# CORE MODELS (always loaded)
+# ============================================================================
from .admin import (
AdminAuditLog,
@@ -18,7 +40,6 @@ from .architecture_scan import (
)
from .base import Base
from .company import Company
-from .content_page import ContentPage
from .platform import Platform
from .platform_module import PlatformModule
from .vendor_platform import VendorPlatform
@@ -82,6 +103,49 @@ from .vendor import Role, Vendor, VendorUser
from .vendor_domain import VendorDomain
from .vendor_theme import VendorTheme
+# ============================================================================
+# MODULE MODELS (dynamically discovered)
+# ============================================================================
+
+
+def _discover_module_models():
+ """
+ Discover and import models from app/modules//models/ directories.
+
+ This ensures module models are registered with Base.metadata for:
+ 1. Alembic migrations
+ 2. SQLAlchemy queries
+
+ Module models must:
+ - Be in app/modules//models/__init__.py or individual files
+ - Import Base from app.core.database
+ """
+ modules_dir = Path(__file__).parent.parent.parent / "app" / "modules"
+
+ if not modules_dir.exists():
+ return
+
+ for module_dir in sorted(modules_dir.iterdir()):
+ if not module_dir.is_dir():
+ continue
+
+ models_init = module_dir / "models" / "__init__.py"
+ if models_init.exists():
+ module_name = f"app.modules.{module_dir.name}.models"
+ try:
+ importlib.import_module(module_name)
+ logger.debug(f"[Models] Loaded module models: {module_name}")
+ except ImportError as e:
+ logger.warning(f"[Models] Failed to import {module_name}: {e}")
+
+
+# Run discovery at import time
+_discover_module_models()
+
+# ============================================================================
+# EXPORTS
+# ============================================================================
+
__all__ = [
# Admin-specific models
"AdminAuditLog",
@@ -113,8 +177,6 @@ __all__ = [
"Role",
"VendorDomain",
"VendorTheme",
- # Content
- "ContentPage",
# Platform
"Platform",
"PlatformModule",
diff --git a/scripts/create_default_content_pages.py b/scripts/create_default_content_pages.py
index 2cf069f3..aec8f2dc 100755
--- a/scripts/create_default_content_pages.py
+++ b/scripts/create_default_content_pages.py
@@ -37,7 +37,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
-from models.database.content_page import ContentPage
+from app.modules.cms.models import ContentPage
# ============================================================================
# DEFAULT PAGE CONTENT
diff --git a/scripts/create_landing_page.py b/scripts/create_landing_page.py
index 219f832e..f6b4a21d 100755
--- a/scripts/create_landing_page.py
+++ b/scripts/create_landing_page.py
@@ -17,7 +17,7 @@ from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
-from models.database.content_page import ContentPage
+from app.modules.cms.models import ContentPage
from models.database.vendor import Vendor
diff --git a/scripts/create_platform_pages.py b/scripts/create_platform_pages.py
index cd694bad..75e6627b 100755
--- a/scripts/create_platform_pages.py
+++ b/scripts/create_platform_pages.py
@@ -24,7 +24,7 @@ project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root))
from app.core.database import SessionLocal
-from app.services.content_page_service import content_page_service
+from app.modules.cms.services import content_page_service
def create_platform_pages():
@@ -38,7 +38,7 @@ def create_platform_pages():
print()
# Import ContentPage for checking existing pages
- from models.database.content_page import ContentPage
+ from app.modules.cms.models import ContentPage
# ========================================================================
# 1. PLATFORM HOMEPAGE
diff --git a/scripts/seed_demo.py b/scripts/seed_demo.py
index 234d5ff0..d44e8c7f 100644
--- a/scripts/seed_demo.py
+++ b/scripts/seed_demo.py
@@ -51,9 +51,9 @@ from app.core.config import settings
from app.core.database import SessionLocal
from app.core.environment import get_environment, is_production
from middleware.auth import AuthManager
+from app.modules.cms.models import ContentPage
from models.database.admin import PlatformAlert
from models.database.company import Company
-from models.database.content_page import ContentPage
from models.database.customer import Customer, CustomerAddress
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.marketplace_product import MarketplaceProduct
diff --git a/tests/fixtures/content_page_fixtures.py b/tests/fixtures/content_page_fixtures.py
index 2001288c..6e6daf30 100644
--- a/tests/fixtures/content_page_fixtures.py
+++ b/tests/fixtures/content_page_fixtures.py
@@ -10,7 +10,7 @@ import uuid
import pytest
-from models.database.content_page import ContentPage
+from app.modules.cms.models import ContentPage
@pytest.fixture
diff --git a/tests/unit/services/test_content_page_service.py b/tests/unit/services/test_content_page_service.py
index ea38687e..27957b9a 100644
--- a/tests/unit/services/test_content_page_service.py
+++ b/tests/unit/services/test_content_page_service.py
@@ -5,12 +5,12 @@ import uuid
import pytest
-from app.exceptions.content_page import (
+from app.modules.cms.exceptions import (
ContentPageNotFoundException,
UnauthorizedContentPageAccessException,
)
-from app.services.content_page_service import ContentPageService, content_page_service
-from models.database.content_page import ContentPage
+from app.modules.cms.models import ContentPage
+from app.modules.cms.services import ContentPageService, content_page_service
@pytest.mark.unit