from typing import Annotated from fastapi import APIRouter, Depends, Query, UploadFile, File import asyncpg from app.core.pagination import pagination_params from app.core.responses import ok, paginated from app.dependencies import get_db, require_admin from app.exceptions import ValidationError from app.models.products import ProductCreate, ProductUpdate, BulkStockUpdateRequest from app.services import product_service, storage_service router = APIRouter(prefix="/products", tags=["Admin — Products"]) @router.get("") async def list_products( pagination: Annotated[tuple, Depends(pagination_params)], search: str | None = Query(None), category: str | None = Query(None), bestseller: bool = Query(False), db: asyncpg.Connection = Depends(get_db), _: dict = Depends(require_admin), ): page, per_page, offset = pagination products, total = await product_service.list_products( db, page, per_page, offset, include_hidden=True, category=category, bestseller=bestseller, search=search, ) return paginated(products, total, page, per_page) @router.post("", status_code=201) async def create_product( body: ProductCreate, db: asyncpg.Connection = Depends(get_db), admin: dict = Depends(require_admin), ): product = await product_service.create_product(db, body) await _log(db, admin, "product.created", str(product["id"])) return ok(product) @router.put("/{product_id}") async def update_product( product_id: str, body: ProductUpdate, db: asyncpg.Connection = Depends(get_db), admin: dict = Depends(require_admin), ): product = await product_service.update_product(db, product_id, body) await _log(db, admin, "product.updated", product_id) return ok(product) @router.delete("/{product_id}", status_code=204) async def delete_product( product_id: str, db: asyncpg.Connection = Depends(get_db), admin: dict = Depends(require_admin), ): await product_service.delete_product(db, product_id) await _log(db, admin, "product.deleted", product_id) @router.post("/{product_id}/images", status_code=201) async def upload_image( product_id: str, file: UploadFile = File(...), db: asyncpg.Connection = Depends(get_db), admin: dict = Depends(require_admin), ): if not file.content_type or not file.content_type.startswith("image/"): raise ValidationError("Only image files are allowed") content = await file.read() if len(content) > 10 * 1024 * 1024: raise ValidationError("Image must be under 10MB") image_data = await storage_service.upload_product_image(product_id, content, file.content_type) product = await product_service.add_image(db, product_id, image_data["url"]) return ok(product) @router.delete("/{product_id}/images") async def delete_image( product_id: str, url: str = Query(..., description="Full image URL to remove"), storage_path: str = Query(..., description="Storage path for deletion from bucket"), db: asyncpg.Connection = Depends(get_db), admin: dict = Depends(require_admin), ): await storage_service.delete_product_image(storage_path) product = await product_service.remove_image(db, product_id, url) return ok(product) @router.post("/bulk-stock") async def bulk_stock( body: BulkStockUpdateRequest, db: asyncpg.Connection = Depends(get_db), admin: dict = Depends(require_admin), ): updates = [u.model_dump() for u in body.updates] await product_service.bulk_stock_update(db, updates) await _log(db, admin, "product.bulk_stock_updated", None) return ok({"updated": len(updates)}) async def _log(db, admin, action, entity_id, metadata=None): await db.execute( "INSERT INTO activity_log (actor_id, action, entity_type, entity_id, metadata) VALUES ($1, $2, 'product', $3, $4)", str(admin["id"]), action, entity_id, metadata, )