feat: complete CMS as fully autonomous self-contained module

Transform CMS from a thin wrapper into a fully self-contained module with
all code living within app/modules/cms/:

Module Structure:
- models/: ContentPage model (canonical location with dynamic discovery)
- schemas/: Pydantic schemas for API validation
- services/: ContentPageService business logic
- exceptions/: Module-specific exceptions
- routes/api/: REST API endpoints (admin, vendor, shop)
- routes/pages/: HTML page routes (admin, vendor)
- templates/cms/: Jinja2 templates (namespaced)
- static/: JavaScript files (admin/vendor)
- locales/: i18n translations (en, fr, de, lb)

Key Changes:
- Move ContentPage model to module with dynamic model discovery
- Create Pydantic schemas package for request/response validation
- Extract API routes from app/api/v1/*/ to module
- Extract page routes from admin_pages.py/vendor_pages.py to module
- Move static JS files to module with dedicated mount point
- Update templates to use cms_static for module assets
- Add module static file mounting in main.py
- Delete old scattered files (no shims - hard errors on old imports)

This establishes the pattern for migrating other modules to be
fully autonomous and independently deployable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 22:42:46 +01:00
parent 8ff9c39845
commit ec4ec045fc
40 changed files with 878 additions and 695 deletions

View File

@@ -95,12 +95,12 @@ except ImportError as e:
print(f" ✗ VendorTheme model failed: {e}") print(f" ✗ VendorTheme model failed: {e}")
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# CONTENT PAGE MODEL (CMS) # CONTENT PAGE MODEL (CMS Module)
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
try: 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: except ImportError as e:
print(f" ✗ ContentPage model failed: {e}") print(f" ✗ ContentPage model failed: {e}")

View File

@@ -48,7 +48,7 @@ from . import (
background_tasks, background_tasks,
code_quality, code_quality,
companies, companies,
content_pages, # content_pages - moved to app.modules.cms.routes.api.admin
customers, customers,
dashboard, dashboard,
email_templates, 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_router as marketplace_admin_router
from app.modules.marketplace.routes.admin import admin_letzshop_router as letzshop_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 # Create admin router
router = APIRouter() router = APIRouter()
@@ -117,10 +120,11 @@ router.include_router(vendor_domains.router, tags=["admin-vendor-domains"])
# Include vendor themes management endpoints # Include vendor themes management endpoints
router.include_router(vendor_themes.router, tags=["admin-vendor-themes"]) 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( 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) # Include platforms management endpoints (multi-platform CMS)
router.include_router(platforms.router, tags=["admin-platforms"]) router.include_router(platforms.router, tags=["admin-platforms"])

View File

@@ -21,7 +21,10 @@ Authentication:
from fastapi import APIRouter from fastapi import APIRouter
# Import shop routers # 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 # Create shop router
router = APIRouter() router = APIRouter()
@@ -51,9 +54,10 @@ router.include_router(messages.router, tags=["shop-messages"])
# Profile (authenticated) # Profile (authenticated)
router.include_router(profile.router, tags=["shop-profile"]) router.include_router(profile.router, tags=["shop-profile"])
# Content pages (public) # CMS module router (self-contained module)
router.include_router( 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"] __all__ = ["router"]

View File

@@ -34,7 +34,7 @@ from . import (
analytics, analytics,
auth, auth,
billing, billing,
content_pages, # content_pages - moved to app.modules.cms.routes.api.vendor
customers, customers,
dashboard, dashboard,
email_settings, 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_router as marketplace_vendor_router
from app.modules.marketplace.routes.vendor import vendor_letzshop_router as letzshop_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 # Create vendor router
router = APIRouter() 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(features.router, tags=["vendor-features"])
router.include_router(usage.router, tags=["vendor-usage"]) router.include_router(usage.router, tags=["vendor-usage"])
# Content pages management # CMS module router (self-contained module)
router.include_router(content_pages.router, tags=["vendor-content-pages"]) 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} # Vendor info endpoint - MUST BE LAST! Has catch-all GET /{vendor_code}
router.include_router(info.router, tags=["vendor-info"]) router.include_router(info.router, tags=["vendor-info"])

View File

@@ -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",
]

View File

@@ -22,14 +22,9 @@ Menu Items:
- Vendor: content-pages, media - Vendor: content-pages, media
Usage: Usage:
# Preferred: Import from module directly
from app.modules.cms.services import content_page_service from app.modules.cms.services import content_page_service
from app.modules.cms.models import ContentPage from app.modules.cms.models import ContentPage
from app.modules.cms.exceptions import ContentPageNotFoundException 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 from app.modules.cms.definition import cms_module

View File

@@ -9,9 +9,7 @@ This is a self-contained module with:
- Services: app.modules.cms.services - Services: app.modules.cms.services
- Models: app.modules.cms.models - Models: app.modules.cms.models
- Exceptions: app.modules.cms.exceptions - Exceptions: app.modules.cms.exceptions
- Templates: app.modules.cms.templates (namespaced as cms/)
Templates remain in core (app/templates/admin/) for now due to
admin/base.html inheritance dependency.
""" """
from app.modules.base import ModuleDefinition from app.modules.base import ModuleDefinition
@@ -61,8 +59,8 @@ cms_module = ModuleDefinition(
services_path="app.modules.cms.services", services_path="app.modules.cms.services",
models_path="app.modules.cms.models", models_path="app.modules.cms.models",
exceptions_path="app.modules.cms.exceptions", exceptions_path="app.modules.cms.exceptions",
# Templates remain in core for now (admin/content-pages*.html) # Module templates (namespaced as cms/admin/*.html and cms/vendor/*.html)
templates_path=None, templates_path="templates",
# Module-specific translations (accessible via cms.* keys) # Module-specific translations (accessible via cms.* keys)
locales_path="locales", locales_path="locales",
) )

View File

@@ -2,19 +2,20 @@
""" """
CMS module database models. CMS module database models.
This package re-exports the ContentPage model from its canonical location This is the canonical location for CMS models. Module models are automatically
in models.database. SQLAlchemy models must remain in a single location to discovered and registered with SQLAlchemy's Base.metadata at startup.
avoid circular imports at startup time.
Usage: Usage:
from app.modules.cms.models import ContentPage from app.modules.cms.models import ContentPage, MediaFile, ProductMedia
The canonical model is at: models.database.content_page.ContentPage
""" """
# Import from canonical location to avoid circular imports from app.modules.cms.models.content_page import ContentPage
from models.database.content_page import ContentPage
# Media models remain in core for now (used by multiple modules)
from models.database.media import MediaFile, ProductMedia
__all__ = [ __all__ = [
"ContentPage", "ContentPage",
"MediaFile",
"ProductMedia",
] ]

View File

@@ -1,4 +1,4 @@
# models/database/content_page.py # app/modules/cms/models/content_page.py
""" """
Content Page Model Content Page Model
@@ -24,9 +24,6 @@ Features:
- SEO metadata - SEO metadata
- Published/Draft status - Published/Draft status
- Navigation placement (header, footer, legal) - 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 from datetime import UTC, datetime

View File

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

View File

@@ -1,4 +1,4 @@
# app/api/v1/admin/content_pages.py # app/modules/cms/routes/api/admin.py
""" """
Admin Content Pages API Admin Content Pages API
@@ -11,106 +11,24 @@ Platform administrators can:
import logging import logging
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, get_db from app.api.deps import get_current_admin_api, get_db
from app.exceptions import ValidationException 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 from models.database.user import User
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) 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) # 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) @router.get("/{page_id}/sections", response_model=HomepageSectionsResponse)
def get_page_sections( def get_page_sections(
page_id: int, page_id: int,

View File

@@ -1,4 +1,4 @@
# app/api/v1/shop/content_pages.py # app/modules/cms/routes/api/shop.py
""" """
Shop Content Pages API (Public) Shop Content Pages API (Public)
@@ -9,49 +9,25 @@ No authentication required.
import logging import logging
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db 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() router = APIRouter()
logger = logging.getLogger(__name__) 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 # 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)): def get_navigation_pages(request: Request, db: Session = Depends(get_db)):
""" """
Get list of content pages for navigation (footer/header). Get list of content pages for navigation (footer/header).

View File

@@ -1,4 +1,4 @@
# app/api/v1/vendor/content_pages.py # app/modules/cms/routes/api/vendor.py
""" """
Vendor Content Pages API Vendor Content Pages API
@@ -14,106 +14,26 @@ Vendors can:
import logging import logging
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, get_db 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 app.services.vendor_service import VendorService
from models.database.user import User from models.database.user import User
vendor_service = VendorService() vendor_service = VendorService()
router = APIRouter(prefix="/content-pages") router = APIRouter()
logger = logging.getLogger(__name__) 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 # VENDOR CONTENT PAGES
# ============================================================================ # ============================================================================
@@ -155,6 +75,96 @@ def list_vendor_overrides(
return [page.to_dict() for page in pages] 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) @router.get("/{slug}", response_model=ContentPageResponse)
def get_page( def get_page(
slug: str, slug: str,
@@ -258,99 +268,3 @@ def delete_vendor_page(
# Delete with ownership check in service layer # Delete with ownership check in service layer
content_page_service.delete_vendor_page(db, page_id, current_user.token_vendor_id) content_page_service.delete_vendor_page(db, page_id, current_user.token_vendor_id)
db.commit() 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()

View File

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

View File

@@ -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,
},
)

View File

@@ -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,
},
)

View File

@@ -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",
]

View File

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

View File

@@ -32,8 +32,7 @@ from app.modules.cms.exceptions import (
ContentPageNotFoundException, ContentPageNotFoundException,
UnauthorizedContentPageAccessException, UnauthorizedContentPageAccessException,
) )
# Import from canonical location to avoid circular imports from app.modules.cms.models import ContentPage
from models.database.content_page import ContentPage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -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" %} {% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state, alert_dynamic %} {% 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 %} {% from 'shared/macros/headers.html' import page_header_flex, back_button, action_button %}
@@ -636,5 +636,5 @@
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/content-page-edit.js') }}"></script> <script src="{{ url_for('cms_static', path='admin/js/content-page-edit.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -1,4 +1,4 @@
{# app/templates/admin/content-pages.html #} {# app/modules/cms/templates/cms/admin/content-pages.html #}
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %} {% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/alerts.html' import loading_state, error_state %}
@@ -178,5 +178,5 @@
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/content-pages.js') }}"></script> <script src="{{ url_for('cms_static', path='admin/js/content-pages.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -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" %} {% extends "vendor/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state, alert_dynamic %} {% from 'shared/macros/alerts.html' import loading_state, error_state, alert_dynamic %}
{% from 'shared/macros/headers.html' import back_button %} {% from 'shared/macros/headers.html' import back_button %}
@@ -322,5 +322,5 @@
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
<script src="{{ url_for('static', path='vendor/js/content-page-edit.js') }}"></script> <script src="{{ url_for('cms_static', path='vendor/js/content-page-edit.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -1,4 +1,4 @@
{# app/templates/vendor/content-pages.html #} {# app/modules/cms/templates/cms/vendor/content-pages.html #}
{% extends "vendor/base.html" %} {% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header %} {% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/alerts.html' import loading_state, error_state %}
@@ -323,5 +323,5 @@
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
<script src="{{ url_for('static', path='vendor/js/content-pages.js') }}"></script> <script src="{{ url_for('cms_static', path='vendor/js/content-pages.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -44,7 +44,6 @@ Routes:
from fastapi import APIRouter, Depends, Path, Request from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import ( from app.api.deps import (
@@ -53,11 +52,11 @@ from app.api.deps import (
require_menu_access, require_menu_access,
) )
from app.core.config import settings from app.core.config import settings
from app.templates_config import templates
from models.database.admin_menu_config import FrontendType from models.database.admin_menu_config import FrontendType
from models.database.user import User from models.database.user import User
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# ============================================================================ # ============================================================================
@@ -1278,88 +1277,8 @@ async def admin_platform_modules(
# ============================================================================ # ============================================================================
# CONTENT MANAGEMENT SYSTEM (CMS) ROUTES # CONTENT MANAGEMENT SYSTEM (CMS) ROUTES
# ============================================================================ # ============================================================================
# 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
@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,
},
)
# ============================================================================ # ============================================================================

View File

@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db 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 from app.utils.i18n import get_jinja2_globals
router = APIRouter() router = APIRouter()

View File

@@ -34,16 +34,15 @@ import logging
from fastapi import APIRouter, Depends, Path, Request from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_from_cookie_or_header, get_db 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.services.platform_settings_service import platform_settings_service
from app.templates_config import templates
from models.database.customer import Customer from models.database.customer import Customer
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -26,7 +26,6 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Path, Request from fastapi import APIRouter, Depends, HTTPException, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import ( from app.api.deps import (
@@ -34,16 +33,15 @@ from app.api.deps import (
get_current_vendor_optional, get_current_vendor_optional,
get_db, get_db,
) )
from app.services.content_page_service import content_page_service
from app.services.onboarding_service import OnboardingService from app.services.onboarding_service import OnboardingService
from app.services.platform_settings_service import platform_settings_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.user import User
from models.database.vendor import Vendor from models.database.vendor import Vendor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# ============================================================================ # ============================================================================
@@ -697,154 +695,12 @@ async def vendor_analytics_page(
# ============================================================================ # ============================================================================
# CONTENT PAGES MANAGEMENT # CONTENT PAGES MANAGEMENT & CMS
# ============================================================================ # ============================================================================
# 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
@router.get( # This includes:
"/{vendor_code}/content-pages", response_class=HTMLResponse, include_in_schema=False # - /{vendor_code}/content-pages (list)
) # - /{vendor_code}/content-pages/create
async def vendor_content_pages_list( # - /{vendor_code}/content-pages/{page_id}/edit
request: Request, # - /{vendor_code}/{slug} (catch-all CMS page viewer)
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,
},
)

View File

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

33
main.py
View File

@@ -63,6 +63,10 @@ from app.exceptions.handler import setup_exception_handlers
# Import page routers # Import page routers
from app.routes import admin_pages, platform_pages, shop_pages, vendor_pages 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 app.utils.i18n import get_jinja2_globals
from middleware.context import ContextMiddleware from middleware.context import ContextMiddleware
from middleware.language import LanguageMiddleware from middleware.language import LanguageMiddleware
@@ -180,6 +184,19 @@ if STATIC_DIR.exists():
else: else:
logger.warning(f"Static directory not found at {STATIC_DIR}") 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 # Mount uploads directory for user-uploaded media files
UPLOADS_DIR = BASE_DIR / "uploads" UPLOADS_DIR = BASE_DIR / "uploads"
if UPLOADS_DIR.exists(): if UPLOADS_DIR.exists():
@@ -301,6 +318,13 @@ app.include_router(
admin_pages.router, prefix="/admin", tags=["admin-pages"], include_in_schema=False 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.) # Vendor management pages (dashboard, products, orders, etc.)
logger.info("Registering vendor page routes: /vendor/{code}/*") logger.info("Registering vendor page routes: /vendor/{code}/*")
app.include_router( app.include_router(
@@ -310,6 +334,13 @@ app.include_router(
include_in_schema=False, 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: # Customer shop pages - Register at TWO prefixes:
# 1. /shop/* (for subdomain/custom domain modes) # 1. /shop/* (for subdomain/custom domain modes)
# 2. /vendors/{code}/shop/* (for path-based development mode) # 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") raise HTTPException(status_code=404, detail=f"Vendor '{vendor_code}' not found")
from app.routes.shop_pages import get_shop_context 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) # Get platform_id (use platform from context or default to 1 for OMS)
platform_id = platform.id if platform else 1 platform_id = platform.id if platform else 1

View File

@@ -1,5 +1,27 @@
# models/database/__init__.py # 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/<module>/models/)
Module Model Discovery:
- Modules can define their own models in app/modules/<module>/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 ( from .admin import (
AdminAuditLog, AdminAuditLog,
@@ -18,7 +40,6 @@ from .architecture_scan import (
) )
from .base import Base from .base import Base
from .company import Company from .company import Company
from .content_page import ContentPage
from .platform import Platform from .platform import Platform
from .platform_module import PlatformModule from .platform_module import PlatformModule
from .vendor_platform import VendorPlatform from .vendor_platform import VendorPlatform
@@ -82,6 +103,49 @@ from .vendor import Role, Vendor, VendorUser
from .vendor_domain import VendorDomain from .vendor_domain import VendorDomain
from .vendor_theme import VendorTheme from .vendor_theme import VendorTheme
# ============================================================================
# MODULE MODELS (dynamically discovered)
# ============================================================================
def _discover_module_models():
"""
Discover and import models from app/modules/<module>/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/<module>/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__ = [ __all__ = [
# Admin-specific models # Admin-specific models
"AdminAuditLog", "AdminAuditLog",
@@ -113,8 +177,6 @@ __all__ = [
"Role", "Role",
"VendorDomain", "VendorDomain",
"VendorTheme", "VendorTheme",
# Content
"ContentPage",
# Platform # Platform
"Platform", "Platform",
"PlatformModule", "PlatformModule",

View File

@@ -37,7 +37,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import SessionLocal from app.core.database import SessionLocal
from models.database.content_page import ContentPage from app.modules.cms.models import ContentPage
# ============================================================================ # ============================================================================
# DEFAULT PAGE CONTENT # DEFAULT PAGE CONTENT

View File

@@ -17,7 +17,7 @@ from datetime import UTC, datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import SessionLocal 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 from models.database.vendor import Vendor

View File

@@ -24,7 +24,7 @@ project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root)) sys.path.insert(0, str(project_root))
from app.core.database import SessionLocal 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(): def create_platform_pages():
@@ -38,7 +38,7 @@ def create_platform_pages():
print() print()
# Import ContentPage for checking existing pages # Import ContentPage for checking existing pages
from models.database.content_page import ContentPage from app.modules.cms.models import ContentPage
# ======================================================================== # ========================================================================
# 1. PLATFORM HOMEPAGE # 1. PLATFORM HOMEPAGE

View File

@@ -51,9 +51,9 @@ from app.core.config import settings
from app.core.database import SessionLocal from app.core.database import SessionLocal
from app.core.environment import get_environment, is_production from app.core.environment import get_environment, is_production
from middleware.auth import AuthManager from middleware.auth import AuthManager
from app.modules.cms.models import ContentPage
from models.database.admin import PlatformAlert from models.database.admin import PlatformAlert
from models.database.company import Company from models.database.company import Company
from models.database.content_page import ContentPage
from models.database.customer import Customer, CustomerAddress from models.database.customer import Customer, CustomerAddress
from models.database.marketplace_import_job import MarketplaceImportJob from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.marketplace_product import MarketplaceProduct from models.database.marketplace_product import MarketplaceProduct

View File

@@ -10,7 +10,7 @@ import uuid
import pytest import pytest
from models.database.content_page import ContentPage from app.modules.cms.models import ContentPage
@pytest.fixture @pytest.fixture

View File

@@ -5,12 +5,12 @@ import uuid
import pytest import pytest
from app.exceptions.content_page import ( from app.modules.cms.exceptions import (
ContentPageNotFoundException, ContentPageNotFoundException,
UnauthorizedContentPageAccessException, UnauthorizedContentPageAccessException,
) )
from app.services.content_page_service import ContentPageService, content_page_service from app.modules.cms.models import ContentPage
from models.database.content_page import ContentPage from app.modules.cms.services import ContentPageService, content_page_service
@pytest.mark.unit @pytest.mark.unit