# app/services/letzshop/client_service.py """ GraphQL client for Letzshop marketplace API. Handles authentication, request formatting, and error handling for all Letzshop API operations. """ import logging import time from typing import Any, Callable import requests logger = logging.getLogger(__name__) # Default API endpoint DEFAULT_ENDPOINT = "https://letzshop.lu/graphql" class LetzshopClientError(Exception): """Base exception for Letzshop client errors.""" def __init__(self, message: str, response_data: dict | None = None): super().__init__(message) self.message = message self.response_data = response_data class LetzshopAuthError(LetzshopClientError): """Raised when authentication fails.""" class LetzshopAPIError(LetzshopClientError): """Raised when the API returns an error response.""" class LetzshopConnectionError(LetzshopClientError): """Raised when connection to the API fails.""" # ============================================================================ # GraphQL Queries # ============================================================================ QUERY_SHIPMENTS_UNCONFIRMED = """ query { shipments(state: unconfirmed) { nodes { id number state order { id number email total completedAt locale shipAddress { firstName lastName company streetName streetNumber city zipCode phone country { name { en fr de } iso } } billAddress { firstName lastName company streetName streetNumber city zipCode phone country { name { en fr de } iso } } } inventoryUnits { id state variant { id sku mpn price tradeId { number parser } product { name { en fr de } _brand { ... on Brand { name } } } } } tracking { code provider } data { __typename } } } } """ QUERY_SHIPMENTS_CONFIRMED = """ query { shipments(state: confirmed) { nodes { id number state order { id number email total completedAt locale shipAddress { firstName lastName company streetName streetNumber city zipCode phone country { name { en fr de } iso } } billAddress { firstName lastName company streetName streetNumber city zipCode phone country { name { en fr de } iso } } } inventoryUnits { id state variant { id sku mpn price tradeId { number parser } product { name { en fr de } _brand { ... on Brand { name } } } } } tracking { code provider } data { __typename } } } } """ QUERY_SHIPMENT_BY_ID = """ query GetShipment($id: ID!) { node(id: $id) { ... on Shipment { id number state order { id number email total completedAt locale shipAddress { firstName lastName company streetName streetNumber city zipCode phone country { name { en fr de } iso } } billAddress { firstName lastName company streetName streetNumber city zipCode phone country { name { en fr de } iso } } } inventoryUnits { id state variant { id sku mpn price tradeId { number parser } product { name { en fr de } _brand { ... on Brand { name } } } } } tracking { code provider } data { __typename } } } } """ # ============================================================================ # Paginated Queries (for historical import) # ============================================================================ # Note: Using string formatting for state since Letzshop has issues with enum variables # Note: tracking field removed - causes 'demodulize' server error on some shipments QUERY_SHIPMENTS_PAGINATED_TEMPLATE = """ query GetShipmentsPaginated($first: Int!, $after: String) {{ shipments(state: {state}, first: $first, after: $after) {{ pageInfo {{ hasNextPage endCursor }} nodes {{ id number state order {{ id number email total completedAt locale shipAddress {{ firstName lastName company streetName streetNumber city zipCode phone country {{ iso }} }} billAddress {{ firstName lastName company streetName streetNumber city zipCode phone country {{ iso }} }} }} inventoryUnits {{ id state variant {{ id sku mpn price tradeId {{ number parser }} product {{ name {{ en fr de }} }} }} }} data {{ __typename }} }} }} }} """ # ============================================================================ # GraphQL Mutations # ============================================================================ MUTATION_CONFIRM_INVENTORY_UNITS = """ mutation ConfirmInventoryUnits($input: ConfirmInventoryUnitsInput!) { confirmInventoryUnits(input: $input) { inventoryUnits { id state } errors { id code message } } } """ MUTATION_REJECT_INVENTORY_UNITS = """ mutation RejectInventoryUnits($input: RejectInventoryUnitsInput!) { returnInventoryUnits(input: $input) { inventoryUnits { id state } errors { id code message } } } """ MUTATION_SET_SHIPMENT_TRACKING = """ mutation SetShipmentTracking($input: SetShipmentTrackingInput!) { setShipmentTracking(input: $input) { shipment { id tracking { code provider } } errors { code message } } } """ class LetzshopClient: """ GraphQL client for Letzshop marketplace API. Usage: client = LetzshopClient(api_key="your-api-key") shipments = client.get_shipments(state="unconfirmed") """ def __init__( self, api_key: str, endpoint: str = DEFAULT_ENDPOINT, timeout: int = 30, ): """ Initialize the Letzshop client. Args: api_key: The Letzshop API key (Bearer token). endpoint: The GraphQL endpoint URL. timeout: Request timeout in seconds. """ self.api_key = api_key self.endpoint = endpoint self.timeout = timeout self._session: requests.Session | None = None @property def session(self) -> requests.Session: """Get or create a requests session.""" if self._session is None: self._session = requests.Session() self._session.headers.update( { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", } ) return self._session def close(self) -> None: """Close the HTTP session.""" if self._session is not None: self._session.close() self._session = None def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.close() return False def _execute( self, query: str, variables: dict[str, Any] | None = None, ) -> dict[str, Any]: """ Execute a GraphQL query or mutation. Args: query: The GraphQL query or mutation string. variables: Optional variables for the query. Returns: The response data from the API. Raises: LetzshopAuthError: If authentication fails. LetzshopAPIError: If the API returns an error. LetzshopConnectionError: If the request fails. """ payload = {"query": query} if variables: payload["variables"] = variables logger.debug(f"Executing GraphQL request to {self.endpoint}") try: response = self.session.post( self.endpoint, json=payload, timeout=self.timeout, ) except requests.exceptions.Timeout as e: raise LetzshopConnectionError(f"Request timed out: {e}") from e except requests.exceptions.ConnectionError as e: raise LetzshopConnectionError(f"Connection failed: {e}") from e except requests.exceptions.RequestException as e: raise LetzshopConnectionError(f"Request failed: {e}") from e # Handle HTTP-level errors if response.status_code == 401: raise LetzshopAuthError( "Authentication failed. Please check your API key.", response_data={"status_code": 401}, ) if response.status_code == 403: raise LetzshopAuthError( "Access forbidden. Your API key may not have the required permissions.", response_data={"status_code": 403}, ) if response.status_code >= 500: raise LetzshopAPIError( f"Letzshop server error (HTTP {response.status_code})", response_data={"status_code": response.status_code}, ) # Parse JSON response try: data = response.json() except ValueError as e: raise LetzshopAPIError( f"Invalid JSON response: {response.text[:200]}" ) from e logger.debug(f"GraphQL response: {data}") # Check for GraphQL errors if "errors" in data and data["errors"]: error_messages = [ err.get("message", "Unknown error") for err in data["errors"] ] logger.warning(f"GraphQL errors received: {data['errors']}") raise LetzshopAPIError( f"GraphQL errors: {'; '.join(error_messages)}", response_data=data, ) return data.get("data", {}) # ======================================================================== # Connection Testing # ======================================================================== def test_connection(self) -> tuple[bool, float, str | None]: """ Test the connection to Letzshop API. Returns: Tuple of (success, response_time_ms, error_message). """ test_query = """ query TestConnection { __typename } """ start_time = time.time() try: self._execute(test_query) elapsed_ms = (time.time() - start_time) * 1000 return True, elapsed_ms, None except LetzshopClientError as e: elapsed_ms = (time.time() - start_time) * 1000 return False, elapsed_ms, str(e) # ======================================================================== # Shipment Queries # ======================================================================== def get_shipments( self, state: str = "unconfirmed", ) -> list[dict[str, Any]]: """ Get shipments from Letzshop. Args: state: State filter ("unconfirmed" or "confirmed"). Returns: List of shipment data dictionaries. """ # Use pre-built queries with inline state values # (Letzshop's GraphQL has issues with enum variables) if state == "confirmed": query = QUERY_SHIPMENTS_CONFIRMED else: query = QUERY_SHIPMENTS_UNCONFIRMED logger.debug(f"Fetching shipments with state: {state}") data = self._execute(query) logger.debug(f"Shipments response data keys: {data.keys() if data else 'None'}") shipments_data = data.get("shipments", {}) nodes = shipments_data.get("nodes", []) logger.info(f"Got {len(nodes)} {state} shipments from Letzshop API") return nodes def get_unconfirmed_shipments(self) -> list[dict[str, Any]]: """Get all unconfirmed shipments.""" return self.get_shipments(state="unconfirmed") def get_shipment_by_id(self, shipment_id: str) -> dict[str, Any] | None: """ Get a single shipment by its ID. Args: shipment_id: The Letzshop shipment ID. Returns: Shipment data or None if not found. """ data = self._execute(QUERY_SHIPMENT_BY_ID, {"id": shipment_id}) return data.get("node") def get_all_shipments_paginated( self, state: str = "confirmed", page_size: int = 50, max_pages: int | None = None, progress_callback: Callable[[int, int], None] | None = None, ) -> list[dict[str, Any]]: """ Fetch all shipments with pagination support. Args: state: State filter ("unconfirmed" or "confirmed"). page_size: Number of shipments per page (default 50). max_pages: Maximum number of pages to fetch (None = all). progress_callback: Optional callback(page, total_fetched) for progress updates. Returns: List of all shipment data dictionaries. """ query = QUERY_SHIPMENTS_PAGINATED_TEMPLATE.format(state=state) all_shipments = [] cursor = None page = 0 while True: page += 1 variables = {"first": page_size} if cursor: variables["after"] = cursor logger.info(f"Fetching {state} shipments page {page} (cursor: {cursor})") try: data = self._execute(query, variables) except LetzshopAPIError as e: # Log error but return what we have so far logger.error(f"Error fetching page {page}: {e}") break shipments_data = data.get("shipments", {}) nodes = shipments_data.get("nodes", []) page_info = shipments_data.get("pageInfo", {}) all_shipments.extend(nodes) if progress_callback: progress_callback(page, len(all_shipments)) logger.info(f"Page {page}: fetched {len(nodes)} shipments, total: {len(all_shipments)}") # Check if there are more pages if not page_info.get("hasNextPage"): logger.info(f"Reached last page. Total shipments: {len(all_shipments)}") break cursor = page_info.get("endCursor") # Check max pages limit if max_pages and page >= max_pages: logger.info(f"Reached max pages limit ({max_pages}). Total shipments: {len(all_shipments)}") break return all_shipments # ======================================================================== # Fulfillment Mutations # ======================================================================== def confirm_inventory_units( self, inventory_unit_ids: list[str], ) -> dict[str, Any]: """ Confirm inventory units for fulfillment. Args: inventory_unit_ids: List of inventory unit IDs to confirm. Returns: Response data including confirmed units and any errors. """ variables = { "input": { "inventoryUnitIds": inventory_unit_ids, } } data = self._execute(MUTATION_CONFIRM_INVENTORY_UNITS, variables) return data.get("confirmInventoryUnits", {}) def reject_inventory_units( self, inventory_unit_ids: list[str], ) -> dict[str, Any]: """ Reject/return inventory units. Args: inventory_unit_ids: List of inventory unit IDs to reject. Returns: Response data including rejected units and any errors. """ variables = { "input": { "inventoryUnitIds": inventory_unit_ids, } } data = self._execute(MUTATION_REJECT_INVENTORY_UNITS, variables) return data.get("returnInventoryUnits", {}) def set_shipment_tracking( self, shipment_id: str, tracking_code: str, tracking_provider: str, ) -> dict[str, Any]: """ Set tracking information for a shipment. Args: shipment_id: The Letzshop shipment ID. tracking_code: The tracking number. tracking_provider: The carrier code (e.g., "dhl", "ups"). Returns: Response data including updated shipment and any errors. """ variables = { "input": { "shipmentId": shipment_id, "tracking": { "code": tracking_code, "provider": tracking_provider, }, } } data = self._execute(MUTATION_SET_SHIPMENT_TRACKING, variables) return data.get("setShipmentTracking", {})