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