from fastapi import APIRouter, Depends, Query, UploadFile, File, HTTPException from fastapi.responses import FileResponse from typing import Optional import os import shutil from sqlalchemy.ext.asyncio import AsyncSession from auth.models import TokenPayload from auth.dependencies import require_permission from crm.models import ProductCreate, ProductUpdate, ProductInDB, ProductListResponse from crm import service from database.postgres import get_pg_session from shared.audit import log_action router = APIRouter(prefix="/api/crm/products", tags=["crm-products"]) PHOTO_DIR = os.path.join(os.path.dirname(__file__), "..", "storage", "product_images") os.makedirs(PHOTO_DIR, exist_ok=True) @router.get("", response_model=ProductListResponse) def list_products( search: Optional[str] = Query(None), category: Optional[str] = Query(None), active_only: bool = Query(False), _user: TokenPayload = Depends(require_permission("crm", "view")), ): products = service.list_products(search=search, category=category, active_only=active_only) return ProductListResponse(products=products, total=len(products)) @router.get("/{product_id}", response_model=ProductInDB) def get_product( product_id: str, _user: TokenPayload = Depends(require_permission("crm", "view")), ): return service.get_product(product_id) @router.post("", response_model=ProductInDB, status_code=201) async def create_product( body: ProductCreate, _user: TokenPayload = Depends(require_permission("crm", "edit")), db: AsyncSession = Depends(get_pg_session), ): product = service.create_product(body) await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "product", product.id, product.name) return product @router.put("/{product_id}", response_model=ProductInDB) async def update_product( product_id: str, body: ProductUpdate, _user: TokenPayload = Depends(require_permission("crm", "edit")), db: AsyncSession = Depends(get_pg_session), ): old = service.get_product(product_id) product = service.update_product(product_id, body) _SKIP = {"updated_at", "id", "photo_url"} changes = { k: {"old": getattr(old, k, None), "new": getattr(product, k, None)} for k in body.model_fields_set if k not in _SKIP and getattr(old, k, None) != getattr(product, k, None) } await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "product", product_id, product.name, changes=changes or None) return product @router.delete("/{product_id}", status_code=204) async def delete_product( product_id: str, _user: TokenPayload = Depends(require_permission("crm", "edit")), db: AsyncSession = Depends(get_pg_session), ): product = service.get_product(product_id) service.delete_product(product_id) await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "product", product_id, product.name) @router.post("/{product_id}/photo", response_model=ProductInDB) async def upload_product_photo( product_id: str, file: UploadFile = File(...), _user: TokenPayload = Depends(require_permission("crm", "edit")), ): """Upload a product photo. Accepts JPG or PNG, stored on disk.""" if file.content_type not in ("image/jpeg", "image/png", "image/webp"): raise HTTPException(status_code=400, detail="Only JPG, PNG, or WebP images are accepted.") ext = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp"}.get(file.content_type, "jpg") photo_path = os.path.join(PHOTO_DIR, f"{product_id}.{ext}") # Remove any old photo files for this product for old_ext in ("jpg", "png", "webp"): old_path = os.path.join(PHOTO_DIR, f"{product_id}.{old_ext}") if os.path.exists(old_path) and old_path != photo_path: os.remove(old_path) with open(photo_path, "wb") as f: shutil.copyfileobj(file.file, f) photo_url = f"/crm/products/{product_id}/photo" return service.update_product(product_id, ProductUpdate(photo_url=photo_url)) @router.get("/{product_id}/photo") def get_product_photo( product_id: str, ): """Serve a product photo from disk.""" for ext in ("jpg", "png", "webp"): photo_path = os.path.join(PHOTO_DIR, f"{product_id}.{ext}") if os.path.exists(photo_path): return FileResponse(photo_path) raise HTTPException(status_code=404, detail="No photo found for this product.")