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