diff --git a/brenger/client.py b/brenger/client.py index e65d4e0..d58ee6d 100644 --- a/brenger/client.py +++ b/brenger/client.py @@ -4,7 +4,10 @@ from requests import Response from .exceptions import APIClientError, APIServerError -from .models import ShipmentCreateRequest, ShipmentResponse +from .models import (ShipmentCreateRequest, ShipmentResponse, V2QuoteRequest, + V2QuoteResponse, V2RefundResponse, + V2ShipmentCreateRequest, V2ShipmentCreateResponse, + V2StatusResponse) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -58,3 +61,77 @@ def _handle_response_errors(self, response: Response) -> None: logger.error(f"Client Error: {error_message}") raise APIClientError(f"Client Error: {error_message}") response.raise_for_status() + + +V2_BASE_URL = "https://external-api.brenger.nl/v2/partners" + + +class BrengerV2APIClient: + def __init__(self, api_key: str) -> None: + self.api_key = api_key + self.session = requests.Session() + self.session.headers.update({"X-AUTH-TOKEN": self.api_key, **HEADERS}) + + def get_quote(self, quote_data: V2QuoteRequest) -> V2QuoteResponse: + url = f"{V2_BASE_URL}/quote" + response = self.session.post( + url, data=quote_data.model_dump_json(exclude_none=True).encode("utf-8") + ) + self._handle_response_errors(response) + logger.info("Quote retrieved successfully") + return V2QuoteResponse(**response.json()) + + def create_shipment( + self, shipment_data: V2ShipmentCreateRequest + ) -> V2ShipmentCreateResponse: + url = f"{V2_BASE_URL}/shipments" + response = self.session.post( + url, data=shipment_data.model_dump_json(exclude_none=True).encode("utf-8") + ) + self._handle_response_errors(response) + logger.info( + "V2 Shipment created successfully with ID: %s", + response.json().get("shipment_id"), + ) + return V2ShipmentCreateResponse(**response.json()) + + def get_shipment_status(self, shipment_id: str) -> V2StatusResponse: + url = f"{V2_BASE_URL}/shipments/{shipment_id}/status" + response = self.session.get(url) + self._handle_response_errors(response) + logger.info("Shipment status retrieved for ID: %s", shipment_id) + return V2StatusResponse(**response.json()) + + def cancel_shipment(self, shipment_id: str) -> None: + url = f"{V2_BASE_URL}/shipments/{shipment_id}/cancel" + response = self.session.post(url) + self._handle_response_errors(response) + logger.info("Shipment cancelled successfully for ID: %s", shipment_id) + + def get_refund(self, shipment_id: str) -> V2RefundResponse: + url = f"{V2_BASE_URL}/shipments/{shipment_id}/refunds" + response = self.session.get(url) + self._handle_response_errors(response) + logger.info("Refund retrieved for shipment ID: %s", shipment_id) + return V2RefundResponse(**response.json()) + + def _handle_response_errors(self, response: Response) -> None: + if response.status_code == 500: + logger.error("Server Error") + raise APIServerError("Internal Server Error") + elif response.status_code >= 400: + response_json = response.json() + error_description = response_json.get("description", "An error occurred") + error_hint = response_json.get("hint", "Hint not provided") + error_validation = response_json.get( + "validation_errors", "Validation not provided" + ) + error_message = ( + f" Status code: {response.status_code} - " + f"Error description: {error_description} -" + f" Error hint: {error_hint} - " + f" validation errors: {error_validation}" + ) + logger.error(f"Client Error: {error_message}") + raise APIClientError(f"Client Error: {error_message}") + response.raise_for_status() diff --git a/brenger/models.py b/brenger/models.py index 36a664e..2c49639 100644 --- a/brenger/models.py +++ b/brenger/models.py @@ -145,3 +145,147 @@ class ShipmentResponse(BaseModel): class Config: extra = "ignore" + + +# ============================================================================ +# V2 API Models +# ============================================================================ + + +class V2Address(BaseModel): + country: str + administrative_area: Optional[str] = None + locality: str + postal_code: str + line1: str + line2: Optional[str] = None + lat: Optional[float] = None + lng: Optional[float] = None + + _strip_whitespace = field_validator( + "line1", "line2", "postal_code", "locality", mode="before" + )(strip_whitespace) + + +class V2Stop(BaseModel): + email: str + phone_number: Optional[str] = None + instructions: Optional[str] = None + first_name: str + last_name: Optional[str] = None + company_name: Optional[str] = None + preferred_locale: Optional[str] = None + address: V2Address + + _strip_whitespace = field_validator( + "email", "first_name", "last_name", "company_name", mode="before" + )(strip_whitespace) + + +class V2Item(BaseModel): + title: str + category: str = "other" + images: Optional[List[str]] = None + width: int + height: int + length: int + count: int + + _strip_whitespace = field_validator("title", "category", mode="before")( + strip_whitespace + ) + + +class V2Amount(BaseModel): + currency: str + value: str + + +class V2Price(BaseModel): + vat: V2Amount + incl_vat: V2Amount + excl_vat: V2Amount + + +class V2Feasible(BaseModel): + value: bool + reasons: List[str] + + +class V2QuoteRequest(BaseModel): + pickup: Dict + delivery: Dict + external_reference: Optional[str] = None + items: List[V2Item] + + +class V2QuoteResponse(BaseModel): + price: V2Price + feasible: V2Feasible + pickup: Dict + delivery: Dict + external_reference: Optional[str] = None + items: List[V2Item] + + class Config: + extra = "ignore" + + +class V2ShipmentCreateRequest(BaseModel): + pickup: V2Stop + delivery: V2Stop + external_reference: str + external_listing_url: Optional[str] = None + external_private_url: Optional[str] = None + items: List[V2Item] + price: Optional[V2Price] = None + + +class V2ShipmentCreateResponse(BaseModel): + shipment_id: str + pickup: V2Stop + delivery: V2Stop + pickup_url: str + delivery_url: str + shipment_url: str + external_reference: str + external_listing_url: Optional[str] = None + external_private_url: Optional[str] = None + items: List[V2Item] + price: V2Price + + class Config: + extra = "ignore" + + +class V2Event(BaseModel): + id: str + timestamp: str + status: str + + +class V2StatusResponse(BaseModel): + shipment_id: str + external_reference: str + status: str + events: List[V2Event] + + class Config: + extra = "ignore" + + +class V2RefundResponse(BaseModel): + refund_id: str + amount: V2Price + code: str + + class Config: + extra = "ignore" + + +class V2WebhookPayload(BaseModel): + event_id: str + shipment_id: str + external_reference: str + timestamp: str + status: str diff --git a/pyproject.toml b/pyproject.toml index d265afb..e62eebf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "brenger" -version = "0.1.8" +version = "2.0.0" description = "" authors = ["Omer "] readme = "README.md"