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:
@@ -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}")
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"]
|
||||
|
||||
10
app/api/v1/vendor/__init__.py
vendored
10
app/api/v1/vendor/__init__.py
vendored
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
15
app/modules/cms/routes/api/__init__.py
Normal file
15
app/modules/cms/routes/api/__init__.py
Normal 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"]
|
||||
@@ -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,
|
||||
@@ -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).
|
||||
@@ -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()
|
||||
13
app/modules/cms/routes/pages/__init__.py
Normal file
13
app/modules/cms/routes/pages/__init__.py
Normal 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"]
|
||||
104
app/modules/cms/routes/pages/admin.py
Normal file
104
app/modules/cms/routes/pages/admin.py
Normal 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,
|
||||
},
|
||||
)
|
||||
225
app/modules/cms/routes/pages/vendor.py
Normal file
225
app/modules/cms/routes/pages/vendor.py
Normal 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,
|
||||
},
|
||||
)
|
||||
36
app/modules/cms/schemas/__init__.py
Normal file
36
app/modules/cms/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
201
app/modules/cms/schemas/content_page.py
Normal file
201
app/modules/cms/schemas/content_page.py
Normal 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
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
<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 %}
|
||||
@@ -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 %}
|
||||
<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 %}
|
||||
@@ -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 %}
|
||||
<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 %}
|
||||
@@ -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 %}
|
||||
<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 %}
|
||||
@@ -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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
33
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
|
||||
|
||||
@@ -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/<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 (
|
||||
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/<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__ = [
|
||||
# Admin-specific models
|
||||
"AdminAuditLog",
|
||||
@@ -113,8 +177,6 @@ __all__ = [
|
||||
"Role",
|
||||
"VendorDomain",
|
||||
"VendorTheme",
|
||||
# Content
|
||||
"ContentPage",
|
||||
# Platform
|
||||
"Platform",
|
||||
"PlatformModule",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
2
tests/fixtures/content_page_fixtures.py
vendored
2
tests/fixtures/content_page_fixtures.py
vendored
@@ -10,7 +10,7 @@ import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from models.database.content_page import ContentPage
|
||||
from app.modules.cms.models import ContentPage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user