240 lines
9 KiB
Python
240 lines
9 KiB
Python
"""
|
|
@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}
|