2026-07-17 00:45:14 +02:00
|
|
|
"""OCR router: upload, get results, list results, apply to vehicle."""
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
2026-07-17 21:28:58 +02:00
|
|
|
from fastapi import (
|
|
|
|
|
APIRouter,
|
|
|
|
|
BackgroundTasks,
|
|
|
|
|
Depends,
|
|
|
|
|
File,
|
|
|
|
|
Form,
|
|
|
|
|
HTTPException,
|
|
|
|
|
Query,
|
|
|
|
|
UploadFile,
|
|
|
|
|
status,
|
|
|
|
|
)
|
2026-07-17 00:45:14 +02:00
|
|
|
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.ocr_result import OCRStatus
|
|
|
|
|
from app.schemas.ocr import (
|
|
|
|
|
OCRApplyResponse,
|
|
|
|
|
OCRResultListResponse,
|
|
|
|
|
OCRResultResponse,
|
|
|
|
|
OCRUploadResponse,
|
|
|
|
|
)
|
|
|
|
|
from app.services import ocr_service
|
|
|
|
|
from app.tasks.ocr_processing import run_ocr_processing
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/ocr", tags=["ocr"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/upload",
|
|
|
|
|
response_model=OCRUploadResponse,
|
|
|
|
|
status_code=status.HTTP_202_ACCEPTED,
|
|
|
|
|
)
|
|
|
|
|
async def upload_scan(
|
|
|
|
|
background_tasks: BackgroundTasks,
|
|
|
|
|
file: UploadFile = File(..., description="Image file to OCR"),
|
|
|
|
|
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 OCR processing.
|
|
|
|
|
|
|
|
|
|
Returns 202 with ocr_result_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 ocr_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.",
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Read file content
|
|
|
|
|
file_bytes = await file.read()
|
|
|
|
|
|
|
|
|
|
if not ocr_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
|
|
|
|
|
if vehicle_id:
|
|
|
|
|
try:
|
|
|
|
|
parsed_vehicle_id = uuid.UUID(vehicle_id)
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
2026-07-17 21:28:58 +02:00
|
|
|
detail={
|
|
|
|
|
"error": {
|
|
|
|
|
"code": "INVALID_VEHICLE_ID",
|
|
|
|
|
"message": "Invalid vehicle UUID",
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-07-17 00:45:14 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
ocr_result = await ocr_service.upload_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_ocr_processing, ocr_result.id)
|
|
|
|
|
|
|
|
|
|
return OCRUploadResponse(
|
|
|
|
|
message="OCR processing queued",
|
|
|
|
|
ocr_result_id=ocr_result.id,
|
2026-07-17 21:28:58 +02:00
|
|
|
status=ocr_result.status.value
|
|
|
|
|
if isinstance(ocr_result.status, OCRStatus)
|
|
|
|
|
else str(ocr_result.status),
|
2026-07-17 00:45:14 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/results/{result_id}",
|
|
|
|
|
response_model=OCRResultResponse,
|
|
|
|
|
status_code=status.HTTP_200_OK,
|
|
|
|
|
)
|
|
|
|
|
async def get_ocr_result(
|
|
|
|
|
result_id: uuid.UUID,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: User = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
"""Get a single OCR result by ID."""
|
|
|
|
|
ocr_result = await ocr_service.get_result(db, result_id)
|
|
|
|
|
if ocr_result is None:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
2026-07-17 21:28:58 +02:00
|
|
|
detail={
|
|
|
|
|
"error": {"code": "OCR_NOT_FOUND", "message": "OCR result not found"}
|
|
|
|
|
},
|
2026-07-17 00:45:14 +02:00
|
|
|
)
|
|
|
|
|
return OCRResultResponse.model_validate(ocr_result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/results",
|
|
|
|
|
response_model=OCRResultListResponse,
|
|
|
|
|
status_code=status.HTTP_200_OK,
|
|
|
|
|
)
|
|
|
|
|
async def list_ocr_results(
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
vehicle_id: uuid.UUID | None = Query(None, description="Filter by vehicle ID"),
|
|
|
|
|
page: int = Query(1, ge=1, description="Page number"),
|
|
|
|
|
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
|
|
|
|
|
current_user: User = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
"""List OCR results, optionally filtered by vehicle_id."""
|
|
|
|
|
items, total = await ocr_service.list_results(
|
|
|
|
|
db=db,
|
|
|
|
|
vehicle_id=vehicle_id,
|
|
|
|
|
page=page,
|
|
|
|
|
page_size=page_size,
|
|
|
|
|
)
|
|
|
|
|
return OCRResultListResponse(
|
|
|
|
|
items=[OCRResultResponse.model_validate(item) for item in items],
|
|
|
|
|
total=total,
|
|
|
|
|
page=page,
|
|
|
|
|
page_size=page_size,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/results/{result_id}/apply",
|
|
|
|
|
response_model=OCRApplyResponse,
|
|
|
|
|
status_code=status.HTTP_200_OK,
|
|
|
|
|
)
|
|
|
|
|
async def apply_ocr_to_vehicle(
|
|
|
|
|
result_id: uuid.UUID,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: User = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
"""Apply OCR structured data to the linked vehicle."""
|
|
|
|
|
try:
|
2026-07-17 21:28:58 +02:00
|
|
|
ocr_result, vehicle, updated_fields = await ocr_service.apply_to_vehicle(
|
|
|
|
|
db, result_id
|
|
|
|
|
)
|
2026-07-17 00:45:14 +02:00
|
|
|
except ValueError as exc:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail={"error": {"code": "APPLY_FAILED", "message": str(exc)}},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return OCRApplyResponse(
|
|
|
|
|
message="OCR data applied to vehicle",
|
|
|
|
|
ocr_result_id=ocr_result.id,
|
|
|
|
|
vehicle_id=vehicle.id,
|
|
|
|
|
updated_fields=updated_fields,
|
|
|
|
|
)
|