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