feat(T08): Bildretusche via Flux.1-Pro + Preisvergleich + Retouch UI with before/after slider

This commit is contained in:
2026-07-17 09:31:28 +02:00
parent 5cc9f8e9a9
commit f1d6de0e47
16 changed files with 2278 additions and 30 deletions
+166
View File
@@ -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