feat(T02): vehicle management + mobile.de push + vehicle UI
- Vehicle model: 25+ fields, 5 vehicle types, soft-delete - Vehicle CRUD: 7 API endpoints with JWT auth, filter/sort/paginate - mobile.de: push/update/delete listings, field mapping, retry logic - MobileDeListing model for sync status tracking - Frontend: VehicleList, VehicleForm, VehicleDetail, MobileDeStatus - 73 backend tests (82% coverage), 16 frontend tests
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"""Vehicles router: CRUD, filtering, sorting, and mobile.de integration endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, get_pagination
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas.vehicle import (
|
||||
MobileDePushResponse,
|
||||
MobileDeStatusResponse,
|
||||
VehicleCreate,
|
||||
VehicleListResponse,
|
||||
VehicleResponse,
|
||||
VehicleUpdate,
|
||||
)
|
||||
from app.services import vehicle_service
|
||||
from app.services import mobilede_service
|
||||
|
||||
router = APIRouter(prefix="/vehicles", tags=["vehicles"])
|
||||
|
||||
|
||||
@router.get("/", response_model=VehicleListResponse, status_code=status.HTTP_200_OK)
|
||||
async def list_vehicles(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
pagination: dict = Depends(get_pagination),
|
||||
vehicle_type: str | None = Query(None, description="Filter by vehicle type"),
|
||||
availability: str | None = Query(None, description="Filter by availability"),
|
||||
min_price: float | None = Query(None, ge=0, description="Minimum price"),
|
||||
max_price: float | None = Query(None, ge=0, description="Maximum price"),
|
||||
search: str | None = Query(None, description="Search in make, model, fin, location"),
|
||||
sort: str | None = Query(None, description="Sort field (prefix - for descending)"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List vehicles with pagination, filtering, and sorting."""
|
||||
vehicles, total = await vehicle_service.list_vehicles(
|
||||
db,
|
||||
page=pagination["page"],
|
||||
page_size=pagination["page_size"],
|
||||
vehicle_type=vehicle_type,
|
||||
availability=availability,
|
||||
min_price=min_price,
|
||||
max_price=max_price,
|
||||
search=search,
|
||||
sort=sort,
|
||||
)
|
||||
return VehicleListResponse(
|
||||
items=[VehicleResponse.model_validate(v) for v in vehicles],
|
||||
total=total,
|
||||
page=pagination["page"],
|
||||
page_size=pagination["page_size"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=VehicleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_vehicle(
|
||||
body: VehicleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new vehicle."""
|
||||
data = body.model_dump(exclude_unset=False)
|
||||
try:
|
||||
vehicle = await vehicle_service.create_vehicle(db, data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"error": {"code": "DUPLICATE_FIN", "message": str(exc)}},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.get("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||
async def get_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single vehicle by ID."""
|
||||
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.put("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||
async def update_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
body: VehicleUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update a vehicle's fields."""
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
|
||||
)
|
||||
try:
|
||||
vehicle = await vehicle_service.update_vehicle(db, vehicle_id, updates)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"error": {"code": "DUPLICATE_FIN", "message": str(exc)}},
|
||||
)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.delete("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||
async def delete_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Soft-delete a vehicle (sets deleted_at)."""
|
||||
vehicle = await vehicle_service.soft_delete_vehicle(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{vehicle_id}/mobile-de/push",
|
||||
response_model=MobileDePushResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def push_to_mobile_de(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Push a vehicle listing to mobile.de (async, returns 202)."""
|
||||
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
|
||||
listing = await mobilede_service.push_listing(db, vehicle)
|
||||
|
||||
return MobileDePushResponse(
|
||||
message="Push queued",
|
||||
vehicle_id=vehicle.id,
|
||||
listing_id=listing.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{vehicle_id}/mobile-de/status",
|
||||
response_model=MobileDeStatusResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def get_mobile_de_status(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get the mobile.de sync status for a vehicle."""
|
||||
# Verify vehicle exists
|
||||
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
|
||||
listing = await mobilede_service.get_listing_status(db, vehicle_id)
|
||||
if listing is None:
|
||||
return MobileDeStatusResponse(
|
||||
synced=False,
|
||||
ad_id=None,
|
||||
synced_at=None,
|
||||
sync_status="pending",
|
||||
error_log=None,
|
||||
)
|
||||
|
||||
return MobileDeStatusResponse(
|
||||
synced=(listing.sync_status == "synced"),
|
||||
ad_id=listing.ad_id,
|
||||
synced_at=listing.synced_at,
|
||||
sync_status=listing.sync_status,
|
||||
error_log=listing.error_log,
|
||||
)
|
||||
Reference in New Issue
Block a user