feat(T08): Bildretusche via Flux.1-Pro + Preisvergleich + Retouch UI with before/after slider
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
"""Image retouch router: process images, get results, price comparison."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.retouch import RetouchStatus
|
||||
from app.schemas.retouch import (
|
||||
PriceCompareRequest,
|
||||
PriceCompareResponse,
|
||||
RetouchProcessResponse,
|
||||
RetouchResultResponse,
|
||||
)
|
||||
from app.services import retouch_service, price_compare_service
|
||||
from app.services.vehicle_service import get_vehicle_by_id
|
||||
from app.tasks.retouch_processing import run_retouch_processing
|
||||
|
||||
router = APIRouter(prefix="/retouch", tags=["retouch"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/process",
|
||||
response_model=RetouchProcessResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def process_image(
|
||||
background_tasks: BackgroundTasks,
|
||||
file: UploadFile = File(..., description="Image file to retouch"),
|
||||
vehicle_id: str | None = Form(None, description="Optional vehicle ID to link"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Upload an image file for retouching via Flux.1-Pro.
|
||||
|
||||
Returns 202 with retouch_id. Processing happens asynchronously.
|
||||
"""
|
||||
if not file or not file.filename:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"error": {"code": "NO_FILE", "message": "No file provided"}},
|
||||
)
|
||||
|
||||
mime_type = file.content_type or ""
|
||||
if not retouch_service.validate_mime_type(mime_type):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "INVALID_MIME_TYPE",
|
||||
"message": f"Invalid MIME type: {mime_type}. Only image/* types are allowed.",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
file_bytes = await file.read()
|
||||
|
||||
if not retouch_service.validate_file_size(len(file_bytes)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "FILE_TOO_LARGE",
|
||||
"message": f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Parse optional vehicle_id
|
||||
parsed_vehicle_id: uuid.UUID | None = None
|
||||
vehicle_info = None
|
||||
if vehicle_id:
|
||||
try:
|
||||
parsed_vehicle_id = uuid.UUID(vehicle_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"error": {"code": "INVALID_VEHICLE_ID", "message": "Invalid vehicle UUID"}},
|
||||
)
|
||||
|
||||
# Fetch vehicle info for better retouch prompt
|
||||
vehicle = await get_vehicle_by_id(db, parsed_vehicle_id)
|
||||
if vehicle:
|
||||
vehicle_info = {
|
||||
"make": vehicle.make,
|
||||
"model": vehicle.model,
|
||||
"year": vehicle.year,
|
||||
"color": vehicle.color,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await retouch_service.upload_retouch_file(
|
||||
db=db,
|
||||
file_bytes=file_bytes,
|
||||
file_name=file.filename or "upload.png",
|
||||
mime_type=mime_type,
|
||||
vehicle_id=parsed_vehicle_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"error": {"code": "UPLOAD_FAILED", "message": str(exc)}},
|
||||
)
|
||||
|
||||
# Queue background processing
|
||||
background_tasks.add_task(run_retouch_processing, result.id, vehicle_info)
|
||||
|
||||
return RetouchProcessResponse(
|
||||
message="Retouch processing queued",
|
||||
retouch_id=result.id,
|
||||
status=result.status.value if isinstance(result.status, RetouchStatus) else str(result.status),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/results/{result_id}",
|
||||
response_model=RetouchResultResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def get_retouch_result(
|
||||
result_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single retouch result by ID.
|
||||
|
||||
Returns status, original and retouched file paths.
|
||||
If processing is still ongoing, status will be 'processing'.
|
||||
If processing failed, status will be 'failed' with error_message.
|
||||
"""
|
||||
result = await retouch_service.get_result(db, result_id)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "RETOUCH_NOT_FOUND", "message": "Retouch result not found"}},
|
||||
)
|
||||
return RetouchResultResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/price-compare",
|
||||
response_model=PriceCompareResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def price_compare(
|
||||
body: PriceCompareRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Compare prices for a vehicle by searching comparable listings.
|
||||
|
||||
Returns comparable listings and average price.
|
||||
If no comparable listings found, returns empty list with null average.
|
||||
"""
|
||||
try:
|
||||
result = await price_compare_service.compare_prices(db, body.vehicle_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": str(exc)}},
|
||||
)
|
||||
return result
|
||||
Reference in New Issue
Block a user