""" @router Admin Storefront Router (Backend/app/api/v1/routers/admin_storefront.py) @purpose Admin CRM endpoints for creating, editing, reordering, bulk saving, and publishing StorefrontContent items. """ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from sqlalchemy.orm import Session from pydantic import BaseModel from typing import List, Dict, Any, Optional import ulid import os import shutil from app.core.database.db_session import get_db from app.models.StorefrontContentModel import StorefrontContent, ContentStatusEnum from app.services.StorefrontService import StorefrontService from app.core.permissions.RoleChecker import get_current_user from app.models.UserModel import User router = APIRouter(prefix="/api/v1/admin/storefront", tags=["Admin Storefront CMS Management"]) @router.post("/upload-image") async def upload_storefront_image( file: UploadFile = File(...), current_user: User = Depends(get_current_user) ): """ Accepts an uploaded image, converts it to WebP format asynchronously, generates optimized sizes (thumbnail, medium, large), and returns URLs. """ ext = os.path.splitext(file.filename or "")[1].lower() if ext not in [".jpg", ".jpeg", ".png", ".webp"]: raise HTTPException(status_code=400, detail="Only image files (.jpg, .jpeg, .png, .webp) are allowed.") file_bytes = await file.read() max_bytes = 20 * 1024 * 1024 # 20MB if len(file_bytes) > max_bytes: raise HTTPException(status_code=400, detail="File size exceeds the 20MB limit.") from pathlib import Path from starlette.concurrency import run_in_threadpool BACKEND_ROOT = Path(__file__).resolve().parents[4] storefront_uploads_dir = BACKEND_ROOT / "uploads" / "storefront" storefront_uploads_dir.mkdir(parents=True, exist_ok=True) base_id = str(ulid.ULID()) def process_and_save(): from PIL import Image as PILImage import io original_img = PILImage.open(io.BytesIO(file_bytes)) # Convert RGBA / P mode to RGB if necessary for JPEG/WEBP compatibility if original_img.mode in ("RGBA", "P"): original_img = original_img.convert("RGBA") elif original_img.mode != "RGB": original_img = original_img.convert("RGB") # Cap max dimension to 2560px for high performance and pristine 4K quality max_dim = 2560 if original_img.size[0] > max_dim or original_img.size[1] > max_dim: original_img.thumbnail((max_dim, max_dim), PILImage.Resampling.BILINEAR) # 1. Save main image as WebP orig_io = io.BytesIO() original_img.save(orig_io, format="WEBP", quality=88) with open(storefront_uploads_dir / f"{base_id}.webp", "wb") as f: f.write(orig_io.getvalue()) # Helper for resizing def save_resized(target_width: int, suffix: str): if original_img.size[0] > target_width: w_percent = (target_width / float(original_img.size[0])) h_size = int((float(original_img.size[1]) * float(w_percent))) resized_img = original_img.resize((target_width, h_size), PILImage.Resampling.BILINEAR) else: resized_img = original_img res_io = io.BytesIO() resized_img.save(res_io, format="WEBP", quality=80) with open(storefront_uploads_dir / f"{base_id}_{suffix}.webp", "wb") as f: f.write(res_io.getvalue()) # 2. Save size variants save_resized(300, "thumbnail") save_resized(800, "medium") save_resized(1500, "large") try: await run_in_threadpool(process_and_save) except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to process and compress image: {str(e)}") return { "image_url": f"/uploads/storefront/{base_id}.webp", "thumbnail_url": f"/uploads/storefront/{base_id}_thumbnail.webp", "medium_url": f"/uploads/storefront/{base_id}_medium.webp", "large_url": f"/uploads/storefront/{base_id}_large.webp" } class ContentCreatePayload(BaseModel): content_id: Optional[str] = None page: str = "home" region: str = "hero" type: str = "hero_banner" title: str subtitle: Optional[str] = None image_url: Optional[str] = None button_text: Optional[str] = None button_url: Optional[str] = None display_order: int = 0 metadata_json: Optional[Dict[str, Any]] = None class BulkSavePayload(BaseModel): items: List[ContentCreatePayload] @router.get("/content/all") def get_all_cms_content(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): contents = db.query(StorefrontContent).order_by(StorefrontContent.display_order.asc()).all() return contents @router.post("/content/create", status_code=status.HTTP_201_CREATED) def create_cms_content(data: ContentCreatePayload, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): content_id = data.content_id or str(ulid.ULID()) slug = f"{data.page}-{data.region}-{data.type}-{content_id[:8]}".lower() existing = db.query(StorefrontContent).filter(StorefrontContent.content_id == content_id).first() if existing: existing.page = data.page existing.region = data.region existing.type = data.type existing.title = data.title existing.subtitle = data.subtitle existing.image_url = data.image_url existing.button_text = data.button_text existing.button_url = data.button_url existing.display_order = data.display_order existing.metadata_json = data.metadata_json or {} db.commit() db.refresh(existing) content = existing else: content = StorefrontContent( content_id=content_id, page=data.page, region=data.region, type=data.type, slug=slug, title=data.title, subtitle=data.subtitle, image_url=data.image_url, button_text=data.button_text, button_url=data.button_url, status=ContentStatusEnum.PUBLISHED, display_order=data.display_order, metadata_json=data.metadata_json or {} ) db.add(content) db.commit() db.refresh(content) # Invalidate storefront cache for instant live update service = StorefrontService(db) service.invalidate_cache() return content @router.post("/content/bulk-save") def bulk_save_cms_content(payload: BulkSavePayload, db: Session = Depends(get_db)): saved_ids = [] for data in payload.items: content_id = data.content_id or str(ulid.ULID()) saved_ids.append(content_id) slug = f"{data.page}-{data.region}-{data.type}-{content_id[:8]}".lower() existing = db.query(StorefrontContent).filter(StorefrontContent.content_id == content_id).first() if existing: existing.page = data.page existing.region = data.region existing.type = data.type existing.title = data.title existing.subtitle = data.subtitle existing.image_url = data.image_url existing.button_text = data.button_text existing.button_url = data.button_url existing.display_order = data.display_order existing.status = ContentStatusEnum.PUBLISHED existing.metadata_json = data.metadata_json or {} else: content = StorefrontContent( content_id=content_id, page=data.page, region=data.region, type=data.type, slug=slug, title=data.title, subtitle=data.subtitle, image_url=data.image_url, button_text=data.button_text, button_url=data.button_url, status=ContentStatusEnum.PUBLISHED, display_order=data.display_order, metadata_json=data.metadata_json or {} ) db.add(content) # Clean up duplicate/obsolete records for the saved regions saved_regions = list(set([data.region for data in payload.items])) if saved_regions: db.query(StorefrontContent).filter( StorefrontContent.region.in_(saved_regions), ~StorefrontContent.content_id.in_(saved_ids) ).delete(synchronize_session=False) db.commit() # Invalidate cache service = StorefrontService(db) service.invalidate_cache() return {"message": "Bulk CMS content saved and storefront cache invalidated", "count": len(payload.items)} @router.delete("/content/{content_id}") def delete_cms_content(content_id: str, db: Session = Depends(get_db)): item = db.query(StorefrontContent).filter(StorefrontContent.content_id == content_id).first() if not item: raise HTTPException(status_code=404, detail="Content not found") db.delete(item) db.commit() service = StorefrontService(db) service.invalidate_cache() return {"message": "CMS content deleted", "content_id": content_id} # ========================================================================== # NEW: Storefront CMS Management Endpoints # ========================================================================== from app.services.StorefrontCmsService import StorefrontCmsService from app.schemas.StorefrontCmsSchema import ( FooterInfoUpdate, StorefrontSettingsUpdate, MegaMenuUpdate, CatalogFiltersUpdate, CategoryCmsUpdate, ReviewApprovalPayload, ) from typing import Any as _Any @router.get("/cms/footer-info") def admin_get_footer_info(db: Session = Depends(get_db)): """Admin: Fetch current footer info.""" return StorefrontCmsService(db).get_footer_info() @router.put("/cms/footer-info") def admin_update_footer_info( payload: FooterInfoUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Admin: Update footer contact info, social links, navigation columns, payment method icons.""" return StorefrontCmsService(db).update_footer_info(payload, updated_by=current_user.user_id) @router.get("/cms/settings") def admin_get_settings(db: Session = Depends(get_db)): """Admin: Fetch all storefront settings (branding, advance_percent, etc.).""" return StorefrontCmsService(db).get_settings() @router.put("/cms/settings") def admin_update_settings( payload: StorefrontSettingsUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Admin: Update store branding and repair advance percentage.""" return StorefrontCmsService(db).update_settings(payload, updated_by=current_user.user_id) @router.get("/cms/mega-menu") def admin_get_mega_menu(db: Session = Depends(get_db)): """Admin: Fetch all three mega menu configs (shop, deals, products).""" return StorefrontCmsService(db).get_mega_menu() @router.put("/cms/mega-menu") def admin_update_mega_menu( payload: MegaMenuUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Admin: Update a single nav menu (shop | deals | products).""" return StorefrontCmsService(db).update_mega_menu(payload, updated_by=current_user.user_id) @router.get("/cms/catalog-filters") def admin_get_catalog_filters(db: Session = Depends(get_db)): """Admin: Fetch catalog filter configuration.""" return StorefrontCmsService(db).get_catalog_filters() @router.put("/cms/catalog-filters") def admin_update_catalog_filters( payload: CatalogFiltersUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Admin: Update highlight tabs and price range buckets.""" return StorefrontCmsService(db).update_catalog_filters(payload) @router.put("/cms/categories/{category_id}") def admin_update_category_cms( category_id: str, payload: CategoryCmsUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Admin: Update CMS display metadata for a category (sidebar, mega_group, badge, etc.).""" return StorefrontCmsService(db).update_category_cms(category_id, payload) @router.get("/cms/reviews") def admin_list_reviews( approved: Optional[bool] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Admin: List all product reviews for moderation, optionally filtered by approval status.""" return StorefrontCmsService(db).list_reviews_for_moderation(approved) @router.put("/cms/reviews/{review_id}/approve") def admin_approve_review( review_id: str, payload: ReviewApprovalPayload = ReviewApprovalPayload(), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Admin: Approve a product review and optionally attach an admin reply.""" return StorefrontCmsService(db).approve_review(review_id, payload) @router.put("/cms/reviews/{review_id}/reject") def admin_reject_review( review_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Admin: Reject (unpublish) a product review.""" return StorefrontCmsService(db).reject_review(review_id)