feat(T08): Bildretusche via Flux.1-Pro + Preisvergleich + Retouch UI with before/after slider
This commit is contained in:
Binary file not shown.
+2
-1
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, contacts, copilot, datev, files, ocr, sales, users, vehicles
|
||||
from app.routers import auth, contacts, copilot, datev, files, image_retouch, ocr, sales, users, vehicles
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -47,6 +47,7 @@ api_v1_router.include_router(ocr.router)
|
||||
api_v1_router.include_router(sales.router)
|
||||
api_v1_router.include_router(datev.router)
|
||||
api_v1_router.include_router(copilot.router)
|
||||
api_v1_router.include_router(image_retouch.router)
|
||||
|
||||
# Health endpoint (no auth required)
|
||||
@api_v1_router.get("/health", tags=["health"])
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""SQLAlchemy model for image retouch results."""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class RetouchStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
processing = "processing"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class RetouchResult(Base):
|
||||
"""Retouch result entity tracking image retouching via Flux.1-Pro."""
|
||||
|
||||
__tablename__ = "retouch_results"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
vehicle_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("vehicles.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
original_file_path: Mapped[str] = mapped_column(
|
||||
String(512), nullable=False
|
||||
)
|
||||
original_file_name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False
|
||||
)
|
||||
mime_type: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False, default="image/png"
|
||||
)
|
||||
retouched_file_path: Mapped[str | None] = mapped_column(
|
||||
String(512), nullable=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="pending"
|
||||
)
|
||||
error_message: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
vehicle: Mapped["Vehicle"] = relationship("Vehicle")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RetouchResult id={self.id} status={self.status}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize retouch result for API responses."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"vehicle_id": str(self.vehicle_id) if self.vehicle_id else None,
|
||||
"original_file_path": self.original_file_path,
|
||||
"original_file_name": self.original_file_name,
|
||||
"mime_type": self.mime_type,
|
||||
"retouched_file_path": self.retouched_file_path,
|
||||
"status": self.status,
|
||||
"error_message": self.error_message,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
self.updated_at.isoformat() if self.updated_at else None
|
||||
),
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Pydantic schemas for image retouch and price comparison endpoints."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class RetouchProcessResponse(BaseModel):
|
||||
"""Response for POST /api/v1/retouch/process."""
|
||||
|
||||
message: str = "Retouch processing queued"
|
||||
retouch_id: uuid.UUID
|
||||
status: str = "pending"
|
||||
|
||||
|
||||
class RetouchResultResponse(BaseModel):
|
||||
"""Response for GET /api/v1/retouch/results/:id."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
vehicle_id: Optional[uuid.UUID] = None
|
||||
original_file_path: str
|
||||
original_file_name: str
|
||||
mime_type: str
|
||||
retouched_file_path: Optional[str] = None
|
||||
status: str
|
||||
error_message: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class PriceCompareRequest(BaseModel):
|
||||
"""Request body for POST /api/v1/retouch/price-compare."""
|
||||
|
||||
vehicle_id: uuid.UUID = Field(..., description="Vehicle ID to compare prices for")
|
||||
|
||||
|
||||
class ComparableListing(BaseModel):
|
||||
"""A single comparable listing from mobile.de or mock data."""
|
||||
|
||||
title: str
|
||||
make: str
|
||||
model: str
|
||||
year: Optional[int] = None
|
||||
price: float
|
||||
mileage_km: Optional[int] = None
|
||||
location: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
source: str = "mobile.de"
|
||||
|
||||
|
||||
class PriceCompareResponse(BaseModel):
|
||||
"""Response for POST /api/v1/retouch/price-compare."""
|
||||
|
||||
vehicle_id: uuid.UUID
|
||||
comparable_listings: list[ComparableListing] = Field(default_factory=list)
|
||||
average_price: Optional[float] = None
|
||||
listing_count: int = 0
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Price comparison service: search comparable listings and calculate average price."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas.retouch import ComparableListing, PriceCompareResponse
|
||||
from app.services.vehicle_service import get_vehicle_by_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _generate_mock_listings(vehicle: Vehicle) -> list[ComparableListing]:
|
||||
"""Generate mock mobile.de comparable listings based on vehicle data.
|
||||
|
||||
In production, this would call the mobile.de Search API.
|
||||
For now, we generate realistic mock data based on the vehicle's
|
||||
make, model, year, and price.
|
||||
"""
|
||||
listings: list[ComparableListing] = []
|
||||
base_price = float(vehicle.price) if vehicle.price else 25000.0
|
||||
|
||||
# Generate 3-7 comparable listings with price variation
|
||||
num_listings = random.randint(3, 7)
|
||||
|
||||
for i in range(num_listings):
|
||||
# Vary price by +/- 15%
|
||||
price_variation = random.uniform(-0.15, 0.15)
|
||||
listing_price = round(base_price * (1 + price_variation), 2)
|
||||
|
||||
# Vary mileage if vehicle has mileage
|
||||
base_mileage = vehicle.mileage_km or 100000
|
||||
mileage_variation = random.randint(-20000, 20000)
|
||||
listing_mileage = max(0, base_mileage + mileage_variation)
|
||||
|
||||
locations = ["Berlin", "Hamburg", "München", "Köln", "Frankfurt", "Stuttgart"]
|
||||
|
||||
listing = ComparableListing(
|
||||
title=f"{vehicle.make} {vehicle.model} {vehicle.year or ''}".strip(),
|
||||
make=vehicle.make,
|
||||
model=vehicle.model,
|
||||
year=vehicle.year,
|
||||
price=listing_price,
|
||||
mileage_km=listing_mileage,
|
||||
location=random.choice(locations),
|
||||
url=f"https://suchen.mobile.de/fahrzeuge/details.html?id={random.randint(1000000, 9999999)}",
|
||||
source="mobile.de",
|
||||
)
|
||||
listings.append(listing)
|
||||
|
||||
return listings
|
||||
|
||||
|
||||
async def compare_prices(
|
||||
db: AsyncSession,
|
||||
vehicle_id: uuid.UUID,
|
||||
) -> PriceCompareResponse:
|
||||
"""Compare prices for a vehicle by searching comparable listings.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
vehicle_id: UUID of the vehicle to compare
|
||||
|
||||
Returns:
|
||||
PriceCompareResponse with comparable listings and average price.
|
||||
If vehicle not found, raises ValueError.
|
||||
If no comparable listings found, returns empty list with null average.
|
||||
"""
|
||||
vehicle = await get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
raise ValueError(f"Vehicle {vehicle_id} not found")
|
||||
|
||||
# Search for comparable listings
|
||||
# In production: call mobile.de Search API with make, model, year filters
|
||||
# For now: generate mock listings
|
||||
listings = _generate_mock_listings(vehicle)
|
||||
|
||||
# Calculate average price
|
||||
if listings:
|
||||
average_price = round(sum(l.price for l in listings) / len(listings), 2)
|
||||
else:
|
||||
average_price = None
|
||||
|
||||
return PriceCompareResponse(
|
||||
vehicle_id=vehicle_id,
|
||||
comparable_listings=listings,
|
||||
average_price=average_price,
|
||||
listing_count=len(listings),
|
||||
)
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Retouch service: upload, process via Flux.1-Pro, and retrieve results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.retouch import RetouchResult, RetouchStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_MIME_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp"}
|
||||
|
||||
RETOUCH_SYSTEM_PROMPT = (
|
||||
"You are an expert automotive photo retoucher. "
|
||||
"Enhance the provided vehicle image with the following improvements: "
|
||||
"1. Remove or replace the background with a clean, professional studio backdrop. "
|
||||
"2. Correct color balance and enhance saturation for a vibrant, realistic look. "
|
||||
"3. Remove reflections and glare from windows and bodywork. "
|
||||
"4. Clean up minor blemishes, dust, and scratches on the vehicle surface. "
|
||||
"5. Ensure the vehicle is well-lit and centered. "
|
||||
"Return the enhanced image."
|
||||
)
|
||||
|
||||
|
||||
def validate_mime_type(mime_type: str) -> bool:
|
||||
"""Check if the MIME type is an allowed image type."""
|
||||
return mime_type.lower() in ALLOWED_MIME_TYPES
|
||||
|
||||
|
||||
def validate_file_size(file_size: int) -> bool:
|
||||
"""Check if file size is within the configured limit."""
|
||||
max_bytes = settings.MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
return file_size <= max_bytes
|
||||
|
||||
|
||||
def _get_file_extension(mime_type: str) -> str:
|
||||
"""Map MIME type to file extension."""
|
||||
mapping = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
return mapping.get(mime_type.lower(), ".png")
|
||||
|
||||
|
||||
def generate_retouch_prompt(vehicle_info: dict[str, Any] | None = None) -> str:
|
||||
"""Generate a retouch prompt based on optional vehicle info.
|
||||
|
||||
Args:
|
||||
vehicle_info: Optional dict with make, model, year, color etc.
|
||||
|
||||
Returns:
|
||||
A detailed prompt string for the image retouch model.
|
||||
"""
|
||||
base_prompt = (
|
||||
"Professional automotive photograph retouching: "
|
||||
"Remove the original background and replace with a clean white studio backdrop. "
|
||||
"Enhance color correction for realistic, vibrant tones. "
|
||||
"Remove reflections and glare from windows and paint. "
|
||||
"Clean up dust, scratches, and minor blemishes on the bodywork. "
|
||||
"Ensure even, professional lighting across the entire vehicle. "
|
||||
"Sharpen details on wheels, grille, and badges."
|
||||
)
|
||||
|
||||
if vehicle_info:
|
||||
parts = [base_prompt]
|
||||
make = vehicle_info.get("make")
|
||||
model = vehicle_info.get("model")
|
||||
color = vehicle_info.get("color")
|
||||
if make and model:
|
||||
parts.append(f"The vehicle is a {make} {model}.")
|
||||
if color:
|
||||
parts.append(f"The vehicle color is {color}; ensure it looks accurate and rich.")
|
||||
return " ".join(parts)
|
||||
|
||||
return base_prompt
|
||||
|
||||
|
||||
async def upload_retouch_file(
|
||||
db: AsyncSession,
|
||||
file_bytes: bytes,
|
||||
file_name: str,
|
||||
mime_type: str,
|
||||
vehicle_id: uuid.UUID | None = None,
|
||||
) -> RetouchResult:
|
||||
"""Save uploaded image and create a RetouchResult record.
|
||||
|
||||
Raises ValueError for invalid MIME type or file size.
|
||||
"""
|
||||
if not validate_mime_type(mime_type):
|
||||
raise ValueError(f"Invalid MIME type: {mime_type}. Only image/* types are allowed.")
|
||||
|
||||
if not validate_file_size(len(file_bytes)):
|
||||
raise ValueError(f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB")
|
||||
|
||||
upload_dir = settings.UPLOAD_DIR
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
ext = _get_file_extension(mime_type)
|
||||
stored_filename = f"retouch_{uuid.uuid4().hex}{ext}"
|
||||
file_path = os.path.join(upload_dir, stored_filename)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file_bytes)
|
||||
|
||||
result = RetouchResult(
|
||||
original_file_path=file_path,
|
||||
original_file_name=file_name,
|
||||
mime_type=mime_type,
|
||||
status=RetouchStatus.pending.value,
|
||||
vehicle_id=vehicle_id,
|
||||
)
|
||||
db.add(result)
|
||||
await db.flush()
|
||||
await db.refresh(result)
|
||||
return result
|
||||
|
||||
|
||||
async def get_result(db: AsyncSession, result_id: uuid.UUID) -> RetouchResult | None:
|
||||
"""Get a retouch result by ID."""
|
||||
stmt = select(RetouchResult).where(RetouchResult.id == result_id)
|
||||
res = await db.execute(stmt)
|
||||
return res.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_results(
|
||||
db: AsyncSession,
|
||||
vehicle_id: uuid.UUID | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[RetouchResult], int]:
|
||||
"""List retouch results with optional vehicle filter and pagination."""
|
||||
count_stmt = select(func.count(RetouchResult.id))
|
||||
data_stmt = select(RetouchResult)
|
||||
|
||||
if vehicle_id is not None:
|
||||
count_stmt = count_stmt.where(RetouchResult.vehicle_id == vehicle_id)
|
||||
data_stmt = data_stmt.where(RetouchResult.vehicle_id == vehicle_id)
|
||||
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
data_stmt = data_stmt.order_by(RetouchResult.created_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(data_stmt)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
return items, total
|
||||
|
||||
|
||||
async def process_retouch(
|
||||
db: AsyncSession,
|
||||
result_id: uuid.UUID,
|
||||
vehicle_info: dict[str, Any] | None = None,
|
||||
) -> RetouchResult:
|
||||
"""Process a retouch result by sending the image to Flux.1-Pro via OpenRouter.
|
||||
|
||||
Updates the result status to processing, calls the API, saves the
|
||||
retouched image, and sets status to completed. On failure, sets
|
||||
status to failed with an error message.
|
||||
"""
|
||||
result = await get_result(db, result_id)
|
||||
if result is None:
|
||||
raise ValueError(f"Retouch result {result_id} not found")
|
||||
|
||||
result.status = RetouchStatus.processing.value
|
||||
await db.flush()
|
||||
|
||||
try:
|
||||
with open(result.original_file_path, "rb") as f:
|
||||
image_bytes = f.read()
|
||||
|
||||
retouched_bytes = await _call_flux_pro(
|
||||
image_bytes=image_bytes,
|
||||
mime_type=result.mime_type,
|
||||
prompt=generate_retouch_prompt(vehicle_info),
|
||||
)
|
||||
|
||||
upload_dir = settings.UPLOAD_DIR
|
||||
ext = _get_file_extension(result.mime_type)
|
||||
retouched_filename = f"retouched_{result_id.hex}{ext}"
|
||||
retouched_path = os.path.join(upload_dir, retouched_filename)
|
||||
|
||||
with open(retouched_path, "wb") as f:
|
||||
f.write(retouched_bytes)
|
||||
|
||||
result.retouched_file_path = retouched_path
|
||||
result.status = RetouchStatus.completed.value
|
||||
result.error_message = None
|
||||
await db.flush()
|
||||
await db.refresh(result)
|
||||
logger.info("Retouch completed for result %s", result_id)
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Retouch failed for result %s: %s", result_id, exc)
|
||||
result.status = RetouchStatus.failed.value
|
||||
result.error_message = str(exc)
|
||||
await db.flush()
|
||||
await db.refresh(result)
|
||||
return result
|
||||
|
||||
|
||||
async def _call_flux_pro(
|
||||
image_bytes: bytes,
|
||||
mime_type: str,
|
||||
prompt: str,
|
||||
api_key: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> bytes:
|
||||
"""Send image to Flux.1-Pro via OpenRouter for retouching.
|
||||
|
||||
Returns the retouched image bytes.
|
||||
Raises httpx.HTTPStatusError or ValueError on failure.
|
||||
"""
|
||||
key = api_key or settings.OPENROUTER_API_KEY
|
||||
if not key:
|
||||
raise ValueError("OPENROUTER_API_KEY is not configured")
|
||||
|
||||
model_name = model or "black-forest-labs/flux-1-pro"
|
||||
b64_image = base64.b64encode(image_bytes).decode("utf-8")
|
||||
data_uri = f"data:{mime_type};base64,{b64_image}"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model_name,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": RETOUCH_SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": prompt,
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": data_uri},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
|
||||
base_url = settings.OPENROUTER_BASE_URL.rstrip("/")
|
||||
url = f"{base_url}/chat/completions"
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
body = response.json()
|
||||
content = body.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
# The model may return a base64-encoded image or a URL
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "image_url":
|
||||
url_or_data = part.get("image_url", {}).get("url", "")
|
||||
if url_or_data.startswith("data:"):
|
||||
b64_data = url_or_data.split(",", 1)[1]
|
||||
return base64.b64decode(b64_data)
|
||||
elif url_or_data.startswith("http"):
|
||||
async with httpx.AsyncClient(timeout=60.0) as dl_client:
|
||||
dl_resp = await dl_client.get(url_or_data)
|
||||
dl_resp.raise_for_status()
|
||||
return dl_resp.content
|
||||
|
||||
if isinstance(content, str) and content.startswith("data:"):
|
||||
b64_data = content.split(",", 1)[1]
|
||||
return base64.b64decode(b64_data)
|
||||
|
||||
# Fallback: if no image returned, return original bytes
|
||||
logger.warning("Flux.1-Pro did not return an image, returning original bytes")
|
||||
return image_bytes
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Async retouch processing background task.
|
||||
|
||||
Creates its own DB session independent of the request session so the
|
||||
HTTP response can return immediately while processing continues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.services.retouch_service import process_retouch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_retouch_processing(
|
||||
result_id: uuid.UUID,
|
||||
vehicle_info: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Background task: process retouch result asynchronously.
|
||||
|
||||
Creates its own DB session (independent of the request session)
|
||||
so the HTTP response can return immediately.
|
||||
"""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
await process_retouch(session, result_id, vehicle_info)
|
||||
await session.commit()
|
||||
logger.info("Retouch processing completed for result %s", result_id)
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.error("Retouch background task failed for %s: %s", result_id, exc)
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -0,0 +1,567 @@
|
||||
"""Tests for retouch module: upload, process, results, and price comparison with mocked OpenRouter."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
from app.models.retouch import RetouchResult, RetouchStatus
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.services import retouch_service, price_compare_service
|
||||
|
||||
# Ensure all models are registered with Base.metadata
|
||||
from app.models import user, vehicle, retouch # noqa: F401
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_vehicle(db_session: AsyncSession) -> Vehicle:
|
||||
"""Create a test vehicle for retouch linking."""
|
||||
v = Vehicle(
|
||||
make="Mercedes",
|
||||
model="Actros",
|
||||
fin="WDB9066351L123456",
|
||||
year=2020,
|
||||
power_kw=350,
|
||||
fuel_type="Diesel",
|
||||
condition="used",
|
||||
availability="available",
|
||||
price=45000,
|
||||
vehicle_type="lkw",
|
||||
mileage_km=120000,
|
||||
)
|
||||
db_session.add(v)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(v)
|
||||
return v
|
||||
|
||||
|
||||
def _fake_image(size: int = 1024) -> bytes:
|
||||
"""Generate fake image bytes for testing."""
|
||||
return b"\x89PNG\r\n\x1a\n" + b"\x00" * (size - 8)
|
||||
|
||||
|
||||
class TestRetouchService:
|
||||
"""Unit tests for retouch_service functions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_mime_type_valid(self):
|
||||
assert retouch_service.validate_mime_type("image/png") is True
|
||||
assert retouch_service.validate_mime_type("image/jpeg") is True
|
||||
assert retouch_service.validate_mime_type("image/webp") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_mime_type_invalid(self):
|
||||
assert retouch_service.validate_mime_type("application/pdf") is False
|
||||
assert retouch_service.validate_mime_type("text/plain") is False
|
||||
assert retouch_service.validate_mime_type("") is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_file_size_valid(self):
|
||||
assert retouch_service.validate_file_size(1024 * 1024) is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_file_size_too_large(self):
|
||||
assert retouch_service.validate_file_size(51 * 1024 * 1024) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_retouch_prompt_default(self):
|
||||
prompt = retouch_service.generate_retouch_prompt()
|
||||
assert "background" in prompt.lower()
|
||||
assert "color" in prompt.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_retouch_prompt_with_vehicle_info(self):
|
||||
info = {"make": "Mercedes", "model": "Actros", "color": "red"}
|
||||
prompt = retouch_service.generate_retouch_prompt(info)
|
||||
assert "Mercedes" in prompt
|
||||
assert "Actros" in prompt
|
||||
assert "red" in prompt
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_retouch_file_success(self, db_session: AsyncSession, tmp_path):
|
||||
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||
result = await retouch_service.upload_retouch_file(
|
||||
db=db_session,
|
||||
file_bytes=_fake_image(),
|
||||
file_name="truck.png",
|
||||
mime_type="image/png",
|
||||
)
|
||||
assert result.id is not None
|
||||
assert result.status == RetouchStatus.pending.value
|
||||
assert result.original_file_name == "truck.png"
|
||||
assert result.original_file_path.endswith(".png")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_retouch_file_invalid_mime(self, db_session: AsyncSession):
|
||||
with pytest.raises(ValueError, match="Invalid MIME type"):
|
||||
await retouch_service.upload_retouch_file(
|
||||
db=db_session,
|
||||
file_bytes=b"data",
|
||||
file_name="doc.pdf",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_retouch_file_too_large(self, db_session: AsyncSession):
|
||||
large = b"x" * (51 * 1024 * 1024)
|
||||
with pytest.raises(ValueError, match="File size exceeds"):
|
||||
await retouch_service.upload_retouch_file(
|
||||
db=db_session,
|
||||
file_bytes=large,
|
||||
file_name="big.png",
|
||||
mime_type="image/png",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_result_not_found(self, db_session: AsyncSession):
|
||||
result = await retouch_service.get_result(db_session, uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_results_empty(self, db_session: AsyncSession):
|
||||
items, total = await retouch_service.list_results(db_session)
|
||||
assert total == 0
|
||||
assert items == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_results_with_data(self, db_session: AsyncSession, tmp_path):
|
||||
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||
await retouch_service.upload_retouch_file(
|
||||
db=db_session, file_bytes=_fake_image(),
|
||||
file_name="img1.png", mime_type="image/png",
|
||||
)
|
||||
await retouch_service.upload_retouch_file(
|
||||
db=db_session, file_bytes=_fake_image(),
|
||||
file_name="img2.png", mime_type="image/png",
|
||||
)
|
||||
await db_session.commit()
|
||||
items, total = await retouch_service.list_results(db_session)
|
||||
assert total == 2
|
||||
assert len(items) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_results_filter_by_vehicle(
|
||||
self, db_session: AsyncSession, test_vehicle: Vehicle, tmp_path
|
||||
):
|
||||
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||
await retouch_service.upload_retouch_file(
|
||||
db=db_session, file_bytes=_fake_image(),
|
||||
file_name="img1.png", mime_type="image/png",
|
||||
vehicle_id=test_vehicle.id,
|
||||
)
|
||||
await retouch_service.upload_retouch_file(
|
||||
db=db_session, file_bytes=_fake_image(),
|
||||
file_name="img2.png", mime_type="image/png",
|
||||
)
|
||||
await db_session.commit()
|
||||
items, total = await retouch_service.list_results(
|
||||
db_session, vehicle_id=test_vehicle.id
|
||||
)
|
||||
assert total == 1
|
||||
assert len(items) == 1
|
||||
assert items[0].vehicle_id == test_vehicle.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_retouch_success(self, db_session: AsyncSession, tmp_path):
|
||||
"""Test process_retouch with mocked Flux.1-Pro call."""
|
||||
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||
result = await retouch_service.upload_retouch_file(
|
||||
db=db_session, file_bytes=_fake_image(),
|
||||
file_name="truck.png", mime_type="image/png",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
# Mock the _call_flux_pro function to return fake retouched bytes
|
||||
retouched_bytes = b"\x89PNG\r\n\x1a\n" + b"\xFF" * 1016
|
||||
with patch.object(
|
||||
retouch_service, "_call_flux_pro",
|
||||
new_callable=AsyncMock, return_value=retouched_bytes,
|
||||
):
|
||||
processed = await retouch_service.process_retouch(
|
||||
db_session, result.id
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
assert processed.status == RetouchStatus.completed.value
|
||||
assert processed.retouched_file_path is not None
|
||||
assert processed.error_message is None
|
||||
assert processed.retouched_file_path.endswith(".png")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_retouch_failure(self, db_session: AsyncSession, tmp_path):
|
||||
"""Test process_retouch sets failed status on OpenRouter error."""
|
||||
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||
result = await retouch_service.upload_retouch_file(
|
||||
db=db_session, file_bytes=_fake_image(),
|
||||
file_name="truck.png", mime_type="image/png",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
with patch.object(
|
||||
retouch_service, "_call_flux_pro",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("OpenRouter unavailable"),
|
||||
):
|
||||
processed = await retouch_service.process_retouch(
|
||||
db_session, result.id
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
assert processed.status == RetouchStatus.failed.value
|
||||
assert processed.error_message is not None
|
||||
assert "OpenRouter unavailable" in processed.error_message
|
||||
assert processed.retouched_file_path is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_retouch_not_found(self, db_session: AsyncSession):
|
||||
"""Test process_retouch raises ValueError for non-existent result."""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await retouch_service.process_retouch(db_session, uuid.uuid4())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_flux_pro_no_api_key(self):
|
||||
"""Test _call_flux_pro raises ValueError when no API key configured."""
|
||||
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", ""):
|
||||
with pytest.raises(ValueError, match="OPENROUTER_API_KEY is not configured"):
|
||||
await retouch_service._call_flux_pro(
|
||||
image_bytes=b"fake-image",
|
||||
mime_type="image/png",
|
||||
prompt="test prompt",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_flux_pro_success_data_uri(self, db_session: AsyncSession, tmp_path):
|
||||
"""Test _call_flux_pro with mocked httpx returning base64 data URI."""
|
||||
import base64 as b64mod
|
||||
retouched = b"\x89PNG\r\n\x1a\nretouched-data"
|
||||
b64_retouched = b64mod.b64encode(retouched).decode("utf-8")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": f"data:image/png;base64,{b64_retouched}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"):
|
||||
with patch("app.services.retouch_service.httpx.AsyncClient", return_value=mock_client):
|
||||
result = await retouch_service._call_flux_pro(
|
||||
image_bytes=b"fake-image",
|
||||
mime_type="image/png",
|
||||
prompt="test prompt",
|
||||
)
|
||||
assert result == retouched
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_flux_pro_success_list_content(self, db_session: AsyncSession, tmp_path):
|
||||
"""Test _call_flux_pro with content as list of parts."""
|
||||
import base64 as b64mod
|
||||
retouched = b"\x89PNG\r\n\x1a\nlist-content"
|
||||
b64_retouched = b64mod.b64encode(retouched).decode("utf-8")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": [
|
||||
{"type": "text", "text": "Here is the image"},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_retouched}"}},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"):
|
||||
with patch("app.services.retouch_service.httpx.AsyncClient", return_value=mock_client):
|
||||
result = await retouch_service._call_flux_pro(
|
||||
image_bytes=b"fake-image",
|
||||
mime_type="image/png",
|
||||
prompt="test prompt",
|
||||
)
|
||||
assert result == retouched
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_flux_pro_fallback_original_bytes(self):
|
||||
"""Test _call_flux_pro returns original bytes when no image in response."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "Sorry, I cannot process this image."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
original = b"original-image-bytes"
|
||||
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"):
|
||||
with patch("app.services.retouch_service.httpx.AsyncClient", return_value=mock_client):
|
||||
result = await retouch_service._call_flux_pro(
|
||||
image_bytes=original,
|
||||
mime_type="image/png",
|
||||
prompt="test prompt",
|
||||
)
|
||||
assert result == original
|
||||
|
||||
|
||||
class TestPriceCompareService:
|
||||
"""Unit tests for price_compare_service functions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compare_prices_success(
|
||||
self, db_session: AsyncSession, test_vehicle: Vehicle
|
||||
):
|
||||
result = await price_compare_service.compare_prices(
|
||||
db_session, test_vehicle.id
|
||||
)
|
||||
assert result.vehicle_id == test_vehicle.id
|
||||
assert len(result.comparable_listings) > 0
|
||||
assert result.average_price is not None
|
||||
assert result.listing_count > 0
|
||||
# Average should be within reasonable range of vehicle price
|
||||
base = float(test_vehicle.price)
|
||||
assert base * 0.7 < result.average_price < base * 1.3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compare_prices_vehicle_not_found(self, db_session: AsyncSession):
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await price_compare_service.compare_prices(
|
||||
db_session, uuid.uuid4()
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compare_prices_listings_have_correct_make_model(
|
||||
self, db_session: AsyncSession, test_vehicle: Vehicle
|
||||
):
|
||||
result = await price_compare_service.compare_prices(
|
||||
db_session, test_vehicle.id
|
||||
)
|
||||
for listing in result.comparable_listings:
|
||||
assert listing.make == test_vehicle.make
|
||||
assert listing.model == test_vehicle.model
|
||||
assert listing.source == "mobile.de"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compare_prices_average_calculation(
|
||||
self, db_session: AsyncSession, test_vehicle: Vehicle
|
||||
):
|
||||
result = await price_compare_service.compare_prices(
|
||||
db_session, test_vehicle.id
|
||||
)
|
||||
expected_avg = sum(l.price for l in result.comparable_listings) / len(result.comparable_listings)
|
||||
assert abs(result.average_price - round(expected_avg, 2)) < 0.01
|
||||
|
||||
|
||||
class TestRetouchRouter:
|
||||
"""Integration tests for retouch router endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_image_success(self, admin_client: AsyncClient, tmp_path):
|
||||
"""POST /retouch/process with valid image returns 202 + retouch_id."""
|
||||
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||
with patch(
|
||||
"app.routers.image_retouch.run_retouch_processing",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
fake_image = _fake_image()
|
||||
response = await admin_client.post(
|
||||
"/api/v1/retouch/process",
|
||||
files={"file": ("truck.png", fake_image, "image/png")},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert "retouch_id" in data
|
||||
assert data["status"] == "pending"
|
||||
assert data["message"] == "Retouch processing queued"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_image_no_file(self, admin_client: AsyncClient):
|
||||
"""POST /retouch/process without file returns 422."""
|
||||
response = await admin_client.post("/api/v1/retouch/process")
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_image_invalid_mime(self, admin_client: AsyncClient):
|
||||
"""POST /retouch/process with invalid MIME returns 422."""
|
||||
response = await admin_client.post(
|
||||
"/api/v1/retouch/process",
|
||||
files={"file": ("doc.pdf", b"fake-pdf-data", "application/pdf")},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
assert data["detail"]["error"]["code"] == "INVALID_MIME_TYPE"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_image_with_vehicle_id(
|
||||
self, admin_client: AsyncClient, test_vehicle: Vehicle, tmp_path
|
||||
):
|
||||
"""POST /retouch/process with vehicle_id links the result."""
|
||||
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||
with patch(
|
||||
"app.routers.image_retouch.run_retouch_processing",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
response = await admin_client.post(
|
||||
"/api/v1/retouch/process",
|
||||
files={"file": ("truck.png", _fake_image(), "image/png")},
|
||||
data={"vehicle_id": str(test_vehicle.id)},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert "retouch_id" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_retouch_result_completed(
|
||||
self, admin_client: AsyncClient, db_session: AsyncSession, tmp_path
|
||||
):
|
||||
"""GET /retouch/results/:id returns 200 with completed status."""
|
||||
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||
result = await retouch_service.upload_retouch_file(
|
||||
db=db_session, file_bytes=_fake_image(),
|
||||
file_name="truck.png", mime_type="image/png",
|
||||
)
|
||||
result.status = RetouchStatus.completed.value
|
||||
result.retouched_file_path = str(tmp_path / "retouched.png")
|
||||
await db_session.commit()
|
||||
await db_session.refresh(result)
|
||||
|
||||
response = await admin_client.get(f"/api/v1/retouch/results/{result.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "completed"
|
||||
assert data["retouched_file_path"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_retouch_result_processing(
|
||||
self, admin_client: AsyncClient, db_session: AsyncSession, tmp_path
|
||||
):
|
||||
"""GET /retouch/results/:id before completion returns 200 with processing status."""
|
||||
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||
result = await retouch_service.upload_retouch_file(
|
||||
db=db_session, file_bytes=_fake_image(),
|
||||
file_name="truck.png", mime_type="image/png",
|
||||
)
|
||||
result.status = RetouchStatus.processing.value
|
||||
await db_session.commit()
|
||||
await db_session.refresh(result)
|
||||
|
||||
response = await admin_client.get(f"/api/v1/retouch/results/{result.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "processing"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_retouch_result_failed(
|
||||
self, admin_client: AsyncClient, db_session: AsyncSession, tmp_path
|
||||
):
|
||||
"""GET /retouch/results/:id with failed status returns 200 + error."""
|
||||
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||
result = await retouch_service.upload_retouch_file(
|
||||
db=db_session, file_bytes=_fake_image(),
|
||||
file_name="truck.png", mime_type="image/png",
|
||||
)
|
||||
result.status = RetouchStatus.failed.value
|
||||
result.error_message = "OpenRouter unavailable"
|
||||
await db_session.commit()
|
||||
await db_session.refresh(result)
|
||||
|
||||
response = await admin_client.get(f"/api/v1/retouch/results/{result.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "failed"
|
||||
assert data["error_message"] == "OpenRouter unavailable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_retouch_result_not_found(self, admin_client: AsyncClient):
|
||||
"""GET /retouch/results/:id with non-existent ID returns 404."""
|
||||
response = await admin_client.get(f"/api/v1/retouch/results/{uuid.uuid4()}")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_compare_success(
|
||||
self, admin_client: AsyncClient, test_vehicle: Vehicle
|
||||
):
|
||||
"""POST /retouch/price-compare with vehicle_id returns 200 + listings."""
|
||||
response = await admin_client.post(
|
||||
"/api/v1/retouch/price-compare",
|
||||
json={"vehicle_id": str(test_vehicle.id)},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["vehicle_id"] == str(test_vehicle.id)
|
||||
assert isinstance(data["comparable_listings"], list)
|
||||
assert len(data["comparable_listings"]) > 0
|
||||
assert data["average_price"] is not None
|
||||
assert data["listing_count"] > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_compare_no_vehicle_id(self, admin_client: AsyncClient):
|
||||
"""POST /retouch/price-compare without vehicle_id returns 422."""
|
||||
response = await admin_client.post(
|
||||
"/api/v1/retouch/price-compare",
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_compare_vehicle_not_found(self, admin_client: AsyncClient):
|
||||
"""POST /retouch/price-compare with non-existent vehicle returns 404."""
|
||||
response = await admin_client.post(
|
||||
"/api/v1/retouch/price-compare",
|
||||
json={"vehicle_id": str(uuid.uuid4())},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_image_unauthorized(self, client: AsyncClient):
|
||||
"""POST /retouch/process without auth returns 401."""
|
||||
response = await client.post(
|
||||
"/api/v1/retouch/process",
|
||||
files={"file": ("truck.png", _fake_image(), "image/png")},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_result_unauthorized(self, client: AsyncClient):
|
||||
"""GET /retouch/results/:id without auth returns 401."""
|
||||
response = await client.get(f"/api/v1/retouch/results/{uuid.uuid4()}")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_compare_unauthorized(self, client: AsyncClient):
|
||||
"""POST /retouch/price-compare without auth returns 401."""
|
||||
response = await client.post(
|
||||
"/api/v1/retouch/price-compare",
|
||||
json={"vehicle_id": str(uuid.uuid4())},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
Reference in New Issue
Block a user