ifixkart-backend/app/api/v1/routers/admin_storefront.py

363 lines
13 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), 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)