feat: complete CMS as fully autonomous self-contained module

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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