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,244 @@
|
||||
"""mobile.de integration service: push, update, delete listings and check status.
|
||||
|
||||
Uses httpx for async HTTP calls to the mobile.de seller listings API.
|
||||
API key and seller ID are loaded from environment variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.vehicle import MobileDeListing, Vehicle
|
||||
from app.utils.mobilede_mapping import map_fields
|
||||
|
||||
# mobile.de API base URL
|
||||
_MOBILE_DE_API_BASE = "https://api.mobile.de"
|
||||
|
||||
# Maximum retry attempts for failed pushes
|
||||
MAX_RETRIES = 3
|
||||
|
||||
|
||||
def _get_api_headers() -> dict[str, str]:
|
||||
"""Build authorization headers for mobile.de API."""
|
||||
api_key = settings.MOBILE_DE_API_KEY
|
||||
return {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-Mobile-DE-Seller-ID": settings.MOBILE_DE_SELLER_ID,
|
||||
}
|
||||
|
||||
|
||||
def _get_listing_url(listing_id: str | None = None) -> str:
|
||||
"""Build the mobile.de listings URL."""
|
||||
base = f"{_MOBILE_DE_API_BASE}/api/seller/listings"
|
||||
if listing_id:
|
||||
return f"{base}/{listing_id}"
|
||||
return base
|
||||
|
||||
|
||||
def _get_status_url(listing_id: str) -> str:
|
||||
"""Build the mobile.de listing status URL."""
|
||||
return f"{_MOBILE_DE_API_BASE}/api/seller/listings/{listing_id}/status"
|
||||
|
||||
|
||||
async def push_listing(
|
||||
db: AsyncSession, vehicle: Vehicle
|
||||
) -> MobileDeListing:
|
||||
"""Push a vehicle listing to mobile.de.
|
||||
|
||||
Creates a MobileDeListing record with status 'pending',
|
||||
sends the mapped ad data to mobile.de POST /api/seller/listings,
|
||||
and updates the listing with the returned ad_id and 'synced' status.
|
||||
|
||||
On failure, sets sync_status to 'fehler' with error_log.
|
||||
"""
|
||||
# Create listing record
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
sync_status="pending",
|
||||
)
|
||||
db.add(listing)
|
||||
await db.flush()
|
||||
|
||||
# Map vehicle fields to mobile.de ad format
|
||||
ad_data = map_fields(vehicle)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
_get_listing_url(),
|
||||
json=ad_data,
|
||||
headers=_get_api_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
ad_id = result.get("id") or result.get("listingId")
|
||||
|
||||
listing.ad_id = str(ad_id) if ad_id else None
|
||||
listing.sync_status = "synced"
|
||||
listing.synced_at = datetime.now(timezone.utc)
|
||||
listing.error_log = None
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = (
|
||||
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||
)
|
||||
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||
except Exception as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
|
||||
async def update_listing(
|
||||
db: AsyncSession, vehicle: Vehicle, listing: MobileDeListing
|
||||
) -> MobileDeListing:
|
||||
"""Update an existing mobile.de listing.
|
||||
|
||||
Sends PUT /api/seller/listings/{id} with updated ad data.
|
||||
"""
|
||||
if not listing.ad_id:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = "Cannot update listing without ad_id"
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
ad_data = map_fields(vehicle)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.put(
|
||||
_get_listing_url(listing.ad_id),
|
||||
json=ad_data,
|
||||
headers=_get_api_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
listing.sync_status = "synced"
|
||||
listing.synced_at = datetime.now(timezone.utc)
|
||||
listing.error_log = None
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = (
|
||||
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||
)
|
||||
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||
except Exception as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
|
||||
async def delete_listing(
|
||||
db: AsyncSession, listing: MobileDeListing
|
||||
) -> MobileDeListing:
|
||||
"""Delete a listing from mobile.de.
|
||||
|
||||
Sends DELETE /api/seller/listings/{id}.
|
||||
"""
|
||||
if not listing.ad_id:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = "Cannot delete listing without ad_id"
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.delete(
|
||||
_get_listing_url(listing.ad_id),
|
||||
headers=_get_api_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
listing.sync_status = "deleted"
|
||||
listing.error_log = None
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = (
|
||||
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||
)
|
||||
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||
except Exception as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
|
||||
async def get_listing_status(
|
||||
db: AsyncSession, vehicle_id: uuid.UUID
|
||||
) -> MobileDeListing | None:
|
||||
"""Get the latest mobile.de listing status for a vehicle.
|
||||
|
||||
Returns the most recent MobileDeListing record, or None if no listing exists.
|
||||
"""
|
||||
stmt = (
|
||||
select(MobileDeListing)
|
||||
.where(MobileDeListing.vehicle_id == vehicle_id)
|
||||
.order_by(MobileDeListing.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def retry_failed_listing(
|
||||
db: AsyncSession, listing: MobileDeListing, vehicle: Vehicle
|
||||
) -> MobileDeListing:
|
||||
"""Retry a failed listing push.
|
||||
|
||||
Increments retry count (tracked via error_log prefix) and re-attempts push.
|
||||
After MAX_RETRIES, marks as permanently failed.
|
||||
"""
|
||||
# Count existing retries from error_log
|
||||
retry_count = 0
|
||||
if listing.error_log and listing.error_log.startswith("[retry"):
|
||||
try:
|
||||
retry_count = int(listing.error_log.split("]")[0].split("retry ")[1])
|
||||
except (IndexError, ValueError):
|
||||
retry_count = 0
|
||||
|
||||
if retry_count >= MAX_RETRIES:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = (
|
||||
f"Max retries ({MAX_RETRIES}) exceeded. "
|
||||
f"Last error: {listing.error_log}"
|
||||
)
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
# Attempt re-push
|
||||
listing.sync_status = "pending"
|
||||
listing.error_log = f"[retry {retry_count + 1}] Retrying push"
|
||||
await db.flush()
|
||||
|
||||
return await push_listing(db, vehicle)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Vehicle service: CRUD, filtering, sorting, and soft-delete operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
|
||||
# Fields that are safe to sort by
|
||||
_SORTABLE_FIELDS: set[str] = {
|
||||
"make",
|
||||
"model",
|
||||
"fin",
|
||||
"year",
|
||||
"price",
|
||||
"vehicle_type",
|
||||
"availability",
|
||||
"condition",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"power_kw",
|
||||
"mileage_km",
|
||||
}
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: select,
|
||||
vehicle_type: str | None = None,
|
||||
availability: str | None = None,
|
||||
min_price: float | None = None,
|
||||
max_price: float | None = None,
|
||||
search: str | None = None,
|
||||
) -> select:
|
||||
"""Apply WHERE filters to a select statement (always excludes soft-deleted)."""
|
||||
conditions = [Vehicle.deleted_at.is_(None)]
|
||||
|
||||
if vehicle_type:
|
||||
conditions.append(Vehicle.vehicle_type == vehicle_type)
|
||||
if availability:
|
||||
conditions.append(Vehicle.availability == availability)
|
||||
if min_price is not None:
|
||||
conditions.append(Vehicle.price >= min_price)
|
||||
if max_price is not None:
|
||||
conditions.append(Vehicle.price <= max_price)
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
conditions.append(
|
||||
or_(
|
||||
Vehicle.make.ilike(search_pattern),
|
||||
Vehicle.model.ilike(search_pattern),
|
||||
Vehicle.fin.ilike(search_pattern),
|
||||
Vehicle.location.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
return stmt.where(and_(*conditions))
|
||||
|
||||
|
||||
def _apply_sort(stmt: select, sort: str | None = None) -> select:
|
||||
"""Apply ORDER BY to a select statement based on sort param.
|
||||
|
||||
Format: 'field' for ascending, '-field' for descending.
|
||||
"""
|
||||
if not sort:
|
||||
return stmt.order_by(Vehicle.created_at.desc())
|
||||
|
||||
descending = sort.startswith("-")
|
||||
field_name = sort.lstrip("-")
|
||||
|
||||
if field_name not in _SORTABLE_FIELDS:
|
||||
return stmt.order_by(Vehicle.created_at.desc())
|
||||
|
||||
column = getattr(Vehicle, field_name)
|
||||
if descending:
|
||||
return stmt.order_by(column.desc())
|
||||
return stmt.order_by(column.asc())
|
||||
|
||||
|
||||
async def list_vehicles(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
vehicle_type: str | None = None,
|
||||
availability: str | None = None,
|
||||
min_price: float | None = None,
|
||||
max_price: float | None = None,
|
||||
search: str | None = None,
|
||||
sort: str | None = None,
|
||||
) -> tuple[list[Vehicle], int]:
|
||||
"""List vehicles with pagination, filtering, and sorting.
|
||||
|
||||
Returns (vehicles, total_count).
|
||||
"""
|
||||
# Build count query
|
||||
count_stmt = select(func.count(Vehicle.id))
|
||||
count_stmt = _apply_filters(
|
||||
count_stmt,
|
||||
vehicle_type=vehicle_type,
|
||||
availability=availability,
|
||||
min_price=min_price,
|
||||
max_price=max_price,
|
||||
search=search,
|
||||
)
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
# Build data query
|
||||
data_stmt = select(Vehicle)
|
||||
data_stmt = _apply_filters(
|
||||
data_stmt,
|
||||
vehicle_type=vehicle_type,
|
||||
availability=availability,
|
||||
min_price=min_price,
|
||||
max_price=max_price,
|
||||
search=search,
|
||||
)
|
||||
data_stmt = _apply_sort(data_stmt, sort)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
data_stmt = data_stmt.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(data_stmt)
|
||||
vehicles = list(result.scalars().all())
|
||||
|
||||
return vehicles, total
|
||||
|
||||
|
||||
async def get_vehicle_by_id(
|
||||
db: AsyncSession, vehicle_id: uuid.UUID
|
||||
) -> Vehicle | None:
|
||||
"""Get a single vehicle by ID, excluding soft-deleted."""
|
||||
stmt = select(Vehicle).where(
|
||||
and_(Vehicle.id == vehicle_id, Vehicle.deleted_at.is_(None))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_vehicle_by_fin(
|
||||
db: AsyncSession, fin: str
|
||||
) -> Vehicle | None:
|
||||
"""Get a single vehicle by FIN, excluding soft-deleted."""
|
||||
stmt = select(Vehicle).where(
|
||||
and_(Vehicle.fin == fin, Vehicle.deleted_at.is_(None))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_vehicle(
|
||||
db: AsyncSession, data: dict[str, Any]
|
||||
) -> Vehicle:
|
||||
"""Create a new vehicle.
|
||||
|
||||
Raises ValueError if FIN already exists.
|
||||
"""
|
||||
existing = await get_vehicle_by_fin(db, data["fin"])
|
||||
if existing is not None:
|
||||
raise ValueError(f"Vehicle with FIN '{data['fin']}' already exists")
|
||||
|
||||
vehicle = Vehicle(**data)
|
||||
db.add(vehicle)
|
||||
await db.flush()
|
||||
await db.refresh(vehicle)
|
||||
return vehicle
|
||||
|
||||
|
||||
async def update_vehicle(
|
||||
db: AsyncSession, vehicle_id: uuid.UUID, updates: dict[str, Any]
|
||||
) -> Vehicle | None:
|
||||
"""Update a vehicle's fields. Returns None if not found or deleted."""
|
||||
vehicle = await get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
return None
|
||||
|
||||
# If FIN is being updated, check for duplicates
|
||||
if "fin" in updates and updates["fin"] != vehicle.fin:
|
||||
existing = await get_vehicle_by_fin(db, updates["fin"])
|
||||
if existing is not None:
|
||||
raise ValueError(f"Vehicle with FIN '{updates['fin']}' already exists")
|
||||
|
||||
for key, value in updates.items():
|
||||
if hasattr(vehicle, key):
|
||||
setattr(vehicle, key, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(vehicle)
|
||||
return vehicle
|
||||
|
||||
|
||||
async def soft_delete_vehicle(
|
||||
db: AsyncSession, vehicle_id: uuid.UUID
|
||||
) -> Vehicle | None:
|
||||
"""Soft-delete a vehicle by setting deleted_at. Returns None if not found."""
|
||||
vehicle = await get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
return None
|
||||
|
||||
vehicle.deleted_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
await db.refresh(vehicle)
|
||||
return vehicle
|
||||
Reference in New Issue
Block a user