""" @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)): 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)): 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}