341d0c6f38
Backend (ruff): - F821: Add TYPE_CHECKING imports for Vehicle, Contact, File in models (file.py, retouch.py, sale.py, vehicle.py) - E741: Rename ambiguous variable to / (copilot_service.py, price_compare_service.py, openrouter.py) - F841: Remove unused variable in sale_service.py Frontend (ESLint): - react/no-unescaped-entities: Escape quotes in ContractPreview.tsx - @next/next/no-img-element: Replace <img> with <Image> from next/image (FileGallery.tsx, FileList.tsx, FilePreview.tsx, BeforeAfterSlider.tsx) Tests: 392 backend passed, 112 frontend passed, next build successful
244 lines
7.4 KiB
Python
244 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
|
|
|
|
import httpx
|
|
from sqlalchemy import 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)
|