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}")
|
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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
10
app/api/v1/vendor/__init__.py
vendored
10
app/api/v1/vendor/__init__.py
vendored
@@ -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"])
|
||||||
|
|||||||
@@ -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
|
- 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
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
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
|
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,
|
||||||
@@ -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).
|
||||||
@@ -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()
|
|
||||||
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,
|
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__)
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
# 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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
2
tests/fixtures/content_page_fixtures.py
vendored
2
tests/fixtures/content_page_fixtures.py
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user