Files
erp-nutzfahrzeuge/backend/app/services/mobilede_service.py
T
Leopoldadmin 74b5e6afb8 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
2026-07-14 12:03:19 +02:00

245 lines
7.4 KiB
Python

"""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)