# 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 Queries - Vendor Directory (Public) # ============================================================================ QUERY_VENDORS_PAGINATED = """ query GetVendorsPaginated($first: Int!, $after: String) { vendors(first: $first, after: $after) { pageInfo { hasNextPage endCursor } totalCount nodes { id slug name active companyName legalName email phone fax homepage description { en fr de } location { street number city zipcode country { iso } } lat lng vendorCategories { name { en fr de } } backgroundImage { url } socialMediaLinks { url } openingHours { en fr de } representative representativeTitle } } } """ QUERY_VENDOR_BY_SLUG = """ query GetVendorBySlug($slug: String!) { vendor(slug: $slug) { id slug name active companyName legalName email phone fax homepage description { en fr de } location { street number city zipcode country { iso } } lat lng vendorCategories { name { en fr de } } backgroundImage { url } socialMediaLinks { url } openingHours { en fr de } representative representativeTitle } } """ # ============================================================================ # 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_public( self, query: str, variables: dict[str, Any] | None = None, ) -> dict[str, Any]: """ Execute a GraphQL query without authentication (for public queries). Args: query: The GraphQL query string. variables: Optional variables for the query. Returns: The response data from the API. Raises: LetzshopAPIError: If the API returns an error. LetzshopConnectionError: If the request fails. """ payload = {"query": query} if variables: payload["variables"] = variables logger.debug(f"Executing public GraphQL request to {self.endpoint}") try: # Use a simple request without Authorization header response = requests.post( self.endpoint, json=payload, headers={"Content-Type": "application/json"}, 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 >= 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}") # Handle GraphQL errors if "errors" in data: errors = data["errors"] error_messages = [e.get("message", str(e)) for e in errors] raise LetzshopAPIError( f"GraphQL errors: {'; '.join(error_messages)}", response_data=data, ) return data.get("data", {}) 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", {}) # ======================================================================== # Vendor Directory Queries (Public - No Auth Required) # ======================================================================== def get_all_vendors_paginated( self, page_size: int = 50, max_pages: int | None = None, progress_callback: Callable[[int, int, int], None] | None = None, ) -> list[dict[str, Any]]: """ Fetch all vendors from Letzshop marketplace directory. This uses the public GraphQL API (no authentication required). Args: page_size: Number of vendors per page (default 50). max_pages: Maximum number of pages to fetch (None = all). progress_callback: Optional callback(page, total_fetched, total_count) for progress updates. Returns: List of all vendor data dictionaries. """ all_vendors = [] cursor = None page = 0 total_count = None while True: page += 1 variables = {"first": page_size} if cursor: variables["after"] = cursor logger.info(f"Fetching vendors page {page} (cursor: {cursor})") try: # Use public endpoint (no authentication required) data = self._execute_public(QUERY_VENDORS_PAGINATED, variables) except LetzshopAPIError as e: logger.error(f"Error fetching vendors page {page}: {e}") break vendors_data = data.get("vendors", {}) nodes = vendors_data.get("nodes", []) page_info = vendors_data.get("pageInfo", {}) if total_count is None: total_count = vendors_data.get("totalCount", 0) logger.info(f"Total vendors in Letzshop: {total_count}") all_vendors.extend(nodes) if progress_callback: progress_callback(page, len(all_vendors), total_count) logger.info( f"Page {page}: fetched {len(nodes)} vendors, " f"total: {len(all_vendors)}/{total_count}" ) # Check if there are more pages if not page_info.get("hasNextPage"): logger.info(f"Reached last page. Total vendors: {len(all_vendors)}") 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}). " f"Total vendors: {len(all_vendors)}" ) break return all_vendors def get_vendor_by_slug(self, slug: str) -> dict[str, Any] | None: """ Get a single vendor by their URL slug. Args: slug: The vendor's URL slug (e.g., "nicks-diecast-corner"). Returns: Vendor data dictionary or None if not found. """ try: # Use public endpoint (no authentication required) data = self._execute_public(QUERY_VENDOR_BY_SLUG, {"slug": slug}) return data.get("vendor") except LetzshopAPIError as e: logger.warning(f"Vendor not found with slug '{slug}': {e}") return None