python-sendparcel-inpost 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_sendparcel_inpost-0.1.1.dist-info/METADATA +371 -0
- python_sendparcel_inpost-0.1.1.dist-info/RECORD +13 -0
- python_sendparcel_inpost-0.1.1.dist-info/WHEEL +4 -0
- python_sendparcel_inpost-0.1.1.dist-info/entry_points.txt +3 -0
- sendparcel_inpost/__init__.py +14 -0
- sendparcel_inpost/client.py +177 -0
- sendparcel_inpost/enums.py +18 -0
- sendparcel_inpost/exceptions.py +45 -0
- sendparcel_inpost/providers/__init__.py +6 -0
- sendparcel_inpost/providers/courier.py +305 -0
- sendparcel_inpost/providers/locker.py +320 -0
- sendparcel_inpost/status_mapping.py +46 -0
- sendparcel_inpost/types.py +72 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""InPost Courier provider."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import ipaddress
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, ClassVar, cast
|
|
7
|
+
|
|
8
|
+
from sendparcel.enums import ConfirmationMethod, LabelFormat
|
|
9
|
+
from sendparcel.exceptions import InvalidCallbackError
|
|
10
|
+
from sendparcel.provider import (
|
|
11
|
+
BaseProvider,
|
|
12
|
+
CancellableProvider,
|
|
13
|
+
LabelProvider,
|
|
14
|
+
PullStatusProvider,
|
|
15
|
+
PushCallbackProvider,
|
|
16
|
+
)
|
|
17
|
+
from sendparcel.types import (
|
|
18
|
+
AddressInfo,
|
|
19
|
+
LabelInfo,
|
|
20
|
+
ParcelInfo,
|
|
21
|
+
ShipmentCreateResult,
|
|
22
|
+
ShipmentStatusResponse,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from sendparcel_inpost.client import ShipXClient
|
|
26
|
+
from sendparcel_inpost.exceptions import ShipXAPIError
|
|
27
|
+
from sendparcel_inpost.status_mapping import map_shipx_status
|
|
28
|
+
from sendparcel_inpost.types import ShipXAddress, ShipXPeer
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
INPOST_WEBHOOK_NETWORK = ipaddress.ip_network("91.216.25.0/24")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InPostCourierProvider(
|
|
36
|
+
BaseProvider,
|
|
37
|
+
LabelProvider,
|
|
38
|
+
PushCallbackProvider,
|
|
39
|
+
PullStatusProvider,
|
|
40
|
+
CancellableProvider,
|
|
41
|
+
):
|
|
42
|
+
"""InPost courier delivery provider."""
|
|
43
|
+
|
|
44
|
+
slug: ClassVar[str] = "inpost_courier"
|
|
45
|
+
display_name: ClassVar[str] = "InPost Kurier"
|
|
46
|
+
supported_countries: ClassVar[list[str]] = ["PL"]
|
|
47
|
+
supported_services: ClassVar[list[str]] = [
|
|
48
|
+
"inpost_courier_standard",
|
|
49
|
+
]
|
|
50
|
+
confirmation_method: ClassVar[ConfirmationMethod] = ConfirmationMethod.PUSH
|
|
51
|
+
user_selectable: ClassVar[bool] = True
|
|
52
|
+
config_schema: ClassVar[dict[str, Any]] = {
|
|
53
|
+
"token": {
|
|
54
|
+
"type": "str",
|
|
55
|
+
"required": True,
|
|
56
|
+
"secret": True,
|
|
57
|
+
"description": "ShipX API bearer token",
|
|
58
|
+
},
|
|
59
|
+
"organization_id": {
|
|
60
|
+
"type": "int",
|
|
61
|
+
"required": True,
|
|
62
|
+
"secret": False,
|
|
63
|
+
"description": "ShipX organization ID",
|
|
64
|
+
},
|
|
65
|
+
"sandbox": {
|
|
66
|
+
"type": "bool",
|
|
67
|
+
"required": False,
|
|
68
|
+
"secret": False,
|
|
69
|
+
"description": "Use sandbox environment",
|
|
70
|
+
"default": False,
|
|
71
|
+
},
|
|
72
|
+
"base_url": {
|
|
73
|
+
"type": "str",
|
|
74
|
+
"required": False,
|
|
75
|
+
"secret": False,
|
|
76
|
+
"description": "Custom API base URL (overrides sandbox flag)",
|
|
77
|
+
},
|
|
78
|
+
"timeout": {
|
|
79
|
+
"type": "float",
|
|
80
|
+
"required": False,
|
|
81
|
+
"secret": False,
|
|
82
|
+
"description": "HTTP request timeout in seconds",
|
|
83
|
+
"default": 30.0,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def _get_client(self) -> ShipXClient:
|
|
88
|
+
"""Build a ShipXClient from provider config."""
|
|
89
|
+
return ShipXClient(
|
|
90
|
+
token=self.get_setting("token", ""),
|
|
91
|
+
organization_id=self.get_setting("organization_id", 0),
|
|
92
|
+
sandbox=self.get_setting("sandbox", False),
|
|
93
|
+
base_url=self.get_setting("base_url"),
|
|
94
|
+
timeout=self.get_setting("timeout", 30.0),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _address_to_peer(self, addr: AddressInfo) -> ShipXPeer:
|
|
98
|
+
"""Convert sendparcel AddressInfo to ShipX peer dict."""
|
|
99
|
+
first_name = addr.get("first_name", "")
|
|
100
|
+
last_name = addr.get("last_name", "")
|
|
101
|
+
|
|
102
|
+
if not first_name and not last_name:
|
|
103
|
+
name = addr.get("name", "")
|
|
104
|
+
parts = name.split(None, 1)
|
|
105
|
+
first_name = parts[0] if parts else ""
|
|
106
|
+
last_name = parts[1] if len(parts) > 1 else ""
|
|
107
|
+
|
|
108
|
+
peer: ShipXPeer = {}
|
|
109
|
+
if first_name:
|
|
110
|
+
peer["first_name"] = first_name
|
|
111
|
+
if last_name:
|
|
112
|
+
peer["last_name"] = last_name
|
|
113
|
+
|
|
114
|
+
company = addr.get("company", "")
|
|
115
|
+
if company:
|
|
116
|
+
peer["company_name"] = company
|
|
117
|
+
|
|
118
|
+
phone = addr.get("phone", "")
|
|
119
|
+
if phone:
|
|
120
|
+
peer["phone"] = phone
|
|
121
|
+
|
|
122
|
+
email = addr.get("email", "")
|
|
123
|
+
if email:
|
|
124
|
+
peer["email"] = email
|
|
125
|
+
|
|
126
|
+
street = addr.get("street", "") or addr.get("line1", "")
|
|
127
|
+
building_number = addr.get("building_number", "")
|
|
128
|
+
city = addr.get("city", "")
|
|
129
|
+
postal_code = addr.get("postal_code", "")
|
|
130
|
+
country_code = addr.get("country_code", "")
|
|
131
|
+
|
|
132
|
+
if street or city:
|
|
133
|
+
shipx_addr: ShipXAddress = {}
|
|
134
|
+
if street:
|
|
135
|
+
shipx_addr["street"] = street
|
|
136
|
+
if building_number:
|
|
137
|
+
shipx_addr["building_number"] = building_number
|
|
138
|
+
flat_number = addr.get("flat_number", "")
|
|
139
|
+
if flat_number:
|
|
140
|
+
shipx_addr["flat_number"] = flat_number
|
|
141
|
+
if city:
|
|
142
|
+
shipx_addr["city"] = city
|
|
143
|
+
if postal_code:
|
|
144
|
+
shipx_addr["post_code"] = postal_code
|
|
145
|
+
if country_code:
|
|
146
|
+
shipx_addr["country_code"] = country_code
|
|
147
|
+
peer["address"] = shipx_addr
|
|
148
|
+
|
|
149
|
+
return peer
|
|
150
|
+
|
|
151
|
+
def _parcels_to_shipx(
|
|
152
|
+
self, parcels: list[ParcelInfo]
|
|
153
|
+
) -> list[dict[str, Any]]:
|
|
154
|
+
"""Convert parcels to ShipX parcel dicts with dimensions."""
|
|
155
|
+
result = []
|
|
156
|
+
for parcel in parcels:
|
|
157
|
+
shipx_parcel: dict[str, Any] = {}
|
|
158
|
+
|
|
159
|
+
length = parcel.get("length_cm")
|
|
160
|
+
width = parcel.get("width_cm")
|
|
161
|
+
height = parcel.get("height_cm")
|
|
162
|
+
if length and width and height:
|
|
163
|
+
shipx_parcel["dimensions"] = {
|
|
164
|
+
"length": float(length) * 10, # cm -> mm
|
|
165
|
+
"width": float(width) * 10,
|
|
166
|
+
"height": float(height) * 10,
|
|
167
|
+
"unit": "mm",
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
weight = parcel.get("weight_kg")
|
|
171
|
+
if weight:
|
|
172
|
+
shipx_parcel["weight"] = {
|
|
173
|
+
"amount": float(weight),
|
|
174
|
+
"unit": "kg",
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
result.append(shipx_parcel)
|
|
178
|
+
|
|
179
|
+
return result or [{"weight": {"amount": 1.0, "unit": "kg"}}]
|
|
180
|
+
|
|
181
|
+
async def create_shipment(
|
|
182
|
+
self,
|
|
183
|
+
*,
|
|
184
|
+
sender_address: AddressInfo,
|
|
185
|
+
receiver_address: AddressInfo,
|
|
186
|
+
parcels: list[ParcelInfo],
|
|
187
|
+
**kwargs: Any,
|
|
188
|
+
) -> ShipmentCreateResult:
|
|
189
|
+
"""Create an InPost courier shipment."""
|
|
190
|
+
receiver_peer = self._address_to_peer(receiver_address)
|
|
191
|
+
|
|
192
|
+
payload: dict[str, Any] = {
|
|
193
|
+
"receiver": dict(receiver_peer),
|
|
194
|
+
"parcels": self._parcels_to_shipx(parcels),
|
|
195
|
+
"service": "inpost_courier_standard",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
sender_peer = self._address_to_peer(sender_address)
|
|
199
|
+
if sender_peer:
|
|
200
|
+
payload["sender"] = dict(sender_peer)
|
|
201
|
+
|
|
202
|
+
client = self._get_client()
|
|
203
|
+
try:
|
|
204
|
+
response = await client.create_shipment(payload=payload)
|
|
205
|
+
finally:
|
|
206
|
+
await client.close()
|
|
207
|
+
|
|
208
|
+
return ShipmentCreateResult(
|
|
209
|
+
external_id=str(response["id"]),
|
|
210
|
+
tracking_number=response.get("tracking_number", ""),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
async def create_label(self, **kwargs: Any) -> LabelInfo:
|
|
214
|
+
"""Fetch label PDF for the shipment."""
|
|
215
|
+
shipment_id = int(self.shipment.external_id)
|
|
216
|
+
label_format = kwargs.get("label_format", "Pdf")
|
|
217
|
+
|
|
218
|
+
client = self._get_client()
|
|
219
|
+
try:
|
|
220
|
+
content = await client.get_label(
|
|
221
|
+
shipment_id=shipment_id,
|
|
222
|
+
label_format=label_format,
|
|
223
|
+
)
|
|
224
|
+
finally:
|
|
225
|
+
await client.close()
|
|
226
|
+
|
|
227
|
+
return LabelInfo(
|
|
228
|
+
format=cast(
|
|
229
|
+
LabelFormat, "PDF" if label_format == "Pdf" else label_format
|
|
230
|
+
),
|
|
231
|
+
content_base64=base64.b64encode(content).decode("ascii"),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
async def verify_callback(
|
|
235
|
+
self,
|
|
236
|
+
data: dict[str, Any],
|
|
237
|
+
headers: dict[str, Any],
|
|
238
|
+
**kwargs: Any,
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Verify InPost webhook by source IP."""
|
|
241
|
+
ip_str = headers.get("x-forwarded-for", "").split(",")[0].strip()
|
|
242
|
+
if not ip_str:
|
|
243
|
+
raise InvalidCallbackError("Missing source IP in webhook request")
|
|
244
|
+
try:
|
|
245
|
+
ip_addr = ipaddress.ip_address(ip_str)
|
|
246
|
+
except ValueError as exc:
|
|
247
|
+
raise InvalidCallbackError(f"Invalid source IP: {ip_str}") from exc
|
|
248
|
+
|
|
249
|
+
if ip_addr not in INPOST_WEBHOOK_NETWORK:
|
|
250
|
+
raise InvalidCallbackError(
|
|
251
|
+
f"Source IP {ip_str} not in InPost webhook range"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
async def handle_callback(
|
|
255
|
+
self,
|
|
256
|
+
data: dict[str, Any],
|
|
257
|
+
headers: dict[str, Any],
|
|
258
|
+
**kwargs: Any,
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Process InPost webhook payload."""
|
|
261
|
+
payload = data.get("payload", {})
|
|
262
|
+
shipx_status = payload.get("status", "")
|
|
263
|
+
sendparcel_status = map_shipx_status(shipx_status)
|
|
264
|
+
if sendparcel_status:
|
|
265
|
+
logger.info(
|
|
266
|
+
"InPost webhook: %s -> %s (shipment %s)",
|
|
267
|
+
shipx_status,
|
|
268
|
+
sendparcel_status,
|
|
269
|
+
payload.get("shipment_id"),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
async def fetch_shipment_status(
|
|
273
|
+
self,
|
|
274
|
+
**kwargs: Any,
|
|
275
|
+
) -> ShipmentStatusResponse:
|
|
276
|
+
"""Fetch current status from ShipX API."""
|
|
277
|
+
shipment_id = int(self.shipment.external_id)
|
|
278
|
+
|
|
279
|
+
client = self._get_client()
|
|
280
|
+
try:
|
|
281
|
+
response = await client.get_shipment(
|
|
282
|
+
shipment_id=shipment_id,
|
|
283
|
+
)
|
|
284
|
+
finally:
|
|
285
|
+
await client.close()
|
|
286
|
+
|
|
287
|
+
shipx_status = response.get("status", "")
|
|
288
|
+
sendparcel_status = map_shipx_status(shipx_status)
|
|
289
|
+
|
|
290
|
+
return ShipmentStatusResponse(
|
|
291
|
+
status=sendparcel_status.value if sendparcel_status else None,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
async def cancel_shipment(self, **kwargs: Any) -> bool:
|
|
295
|
+
"""Cancel shipment via ShipX API."""
|
|
296
|
+
shipment_id = int(self.shipment.external_id)
|
|
297
|
+
|
|
298
|
+
client = self._get_client()
|
|
299
|
+
try:
|
|
300
|
+
await client.cancel_shipment(shipment_id=shipment_id)
|
|
301
|
+
return True
|
|
302
|
+
except ShipXAPIError:
|
|
303
|
+
return False
|
|
304
|
+
finally:
|
|
305
|
+
await client.close()
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""InPost Locker (Paczkomat) provider."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import ipaddress
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
from sendparcel.enums import ConfirmationMethod, LabelFormat
|
|
9
|
+
from sendparcel.exceptions import InvalidCallbackError
|
|
10
|
+
from sendparcel.provider import (
|
|
11
|
+
BaseProvider,
|
|
12
|
+
CancellableProvider,
|
|
13
|
+
LabelProvider,
|
|
14
|
+
PullStatusProvider,
|
|
15
|
+
PushCallbackProvider,
|
|
16
|
+
)
|
|
17
|
+
from sendparcel.types import (
|
|
18
|
+
AddressInfo,
|
|
19
|
+
LabelInfo,
|
|
20
|
+
ParcelInfo,
|
|
21
|
+
ShipmentCreateResult,
|
|
22
|
+
ShipmentStatusResponse,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from sendparcel_inpost.client import ShipXClient
|
|
26
|
+
from sendparcel_inpost.exceptions import ShipXAPIError
|
|
27
|
+
from sendparcel_inpost.status_mapping import map_shipx_status
|
|
28
|
+
from sendparcel_inpost.types import ShipXAddress, ShipXPeer
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
INPOST_WEBHOOK_NETWORK = ipaddress.ip_network("91.216.25.0/24")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InPostLockerProvider(
|
|
36
|
+
BaseProvider,
|
|
37
|
+
LabelProvider,
|
|
38
|
+
PushCallbackProvider,
|
|
39
|
+
PullStatusProvider,
|
|
40
|
+
CancellableProvider,
|
|
41
|
+
):
|
|
42
|
+
"""InPost Paczkomat locker delivery provider."""
|
|
43
|
+
|
|
44
|
+
slug: ClassVar[str] = "inpost_locker"
|
|
45
|
+
display_name: ClassVar[str] = "InPost Paczkomat"
|
|
46
|
+
supported_countries: ClassVar[list[str]] = ["PL"]
|
|
47
|
+
supported_services: ClassVar[list[str]] = [
|
|
48
|
+
"inpost_locker_standard",
|
|
49
|
+
]
|
|
50
|
+
confirmation_method: ClassVar[ConfirmationMethod] = ConfirmationMethod.PUSH
|
|
51
|
+
user_selectable: ClassVar[bool] = True
|
|
52
|
+
config_schema: ClassVar[dict[str, Any]] = {
|
|
53
|
+
"token": {
|
|
54
|
+
"type": "str",
|
|
55
|
+
"required": True,
|
|
56
|
+
"secret": True,
|
|
57
|
+
"description": "ShipX API bearer token",
|
|
58
|
+
},
|
|
59
|
+
"organization_id": {
|
|
60
|
+
"type": "int",
|
|
61
|
+
"required": True,
|
|
62
|
+
"secret": False,
|
|
63
|
+
"description": "ShipX organization ID",
|
|
64
|
+
},
|
|
65
|
+
"sandbox": {
|
|
66
|
+
"type": "bool",
|
|
67
|
+
"required": False,
|
|
68
|
+
"secret": False,
|
|
69
|
+
"description": "Use sandbox environment",
|
|
70
|
+
"default": False,
|
|
71
|
+
},
|
|
72
|
+
"base_url": {
|
|
73
|
+
"type": "str",
|
|
74
|
+
"required": False,
|
|
75
|
+
"secret": False,
|
|
76
|
+
"description": "Custom API base URL (overrides sandbox flag)",
|
|
77
|
+
},
|
|
78
|
+
"timeout": {
|
|
79
|
+
"type": "float",
|
|
80
|
+
"required": False,
|
|
81
|
+
"secret": False,
|
|
82
|
+
"description": "HTTP request timeout in seconds",
|
|
83
|
+
"default": 30.0,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def _get_client(self) -> ShipXClient:
|
|
88
|
+
"""Build a ShipXClient from provider config."""
|
|
89
|
+
return ShipXClient(
|
|
90
|
+
token=self.get_setting("token", ""),
|
|
91
|
+
organization_id=self.get_setting("organization_id", 0),
|
|
92
|
+
sandbox=self.get_setting("sandbox", False),
|
|
93
|
+
base_url=self.get_setting("base_url"),
|
|
94
|
+
timeout=self.get_setting("timeout", 30.0),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _address_to_peer(self, addr: AddressInfo) -> ShipXPeer:
|
|
98
|
+
"""Convert sendparcel AddressInfo to ShipX peer dict."""
|
|
99
|
+
first_name = addr.get("first_name", "")
|
|
100
|
+
last_name = addr.get("last_name", "")
|
|
101
|
+
|
|
102
|
+
if not first_name and not last_name:
|
|
103
|
+
name = addr.get("name", "")
|
|
104
|
+
parts = name.split(None, 1)
|
|
105
|
+
first_name = parts[0] if parts else ""
|
|
106
|
+
last_name = parts[1] if len(parts) > 1 else ""
|
|
107
|
+
|
|
108
|
+
peer: ShipXPeer = {}
|
|
109
|
+
if first_name:
|
|
110
|
+
peer["first_name"] = first_name
|
|
111
|
+
if last_name:
|
|
112
|
+
peer["last_name"] = last_name
|
|
113
|
+
|
|
114
|
+
company = addr.get("company", "")
|
|
115
|
+
if company:
|
|
116
|
+
peer["company_name"] = company
|
|
117
|
+
|
|
118
|
+
phone = addr.get("phone", "")
|
|
119
|
+
if phone:
|
|
120
|
+
peer["phone"] = phone
|
|
121
|
+
|
|
122
|
+
email = addr.get("email", "")
|
|
123
|
+
if email:
|
|
124
|
+
peer["email"] = email
|
|
125
|
+
|
|
126
|
+
street = addr.get("street", "") or addr.get("line1", "")
|
|
127
|
+
building_number = addr.get("building_number", "")
|
|
128
|
+
city = addr.get("city", "")
|
|
129
|
+
postal_code = addr.get("postal_code", "")
|
|
130
|
+
country_code = addr.get("country_code", "")
|
|
131
|
+
|
|
132
|
+
if street or city:
|
|
133
|
+
shipx_addr: ShipXAddress = {}
|
|
134
|
+
if street:
|
|
135
|
+
shipx_addr["street"] = street
|
|
136
|
+
if building_number:
|
|
137
|
+
shipx_addr["building_number"] = building_number
|
|
138
|
+
flat_number = addr.get("flat_number", "")
|
|
139
|
+
if flat_number:
|
|
140
|
+
shipx_addr["flat_number"] = flat_number
|
|
141
|
+
if city:
|
|
142
|
+
shipx_addr["city"] = city
|
|
143
|
+
if postal_code:
|
|
144
|
+
shipx_addr["post_code"] = postal_code
|
|
145
|
+
if country_code:
|
|
146
|
+
shipx_addr["country_code"] = country_code
|
|
147
|
+
peer["address"] = shipx_addr
|
|
148
|
+
|
|
149
|
+
return peer
|
|
150
|
+
|
|
151
|
+
def _parcel_template_from_parcels(self, parcels: list[ParcelInfo]) -> str:
|
|
152
|
+
"""Determine locker parcel template from parcels.
|
|
153
|
+
|
|
154
|
+
For locker shipments, defaults to 'small' if dimensions
|
|
155
|
+
don't clearly indicate a larger size.
|
|
156
|
+
"""
|
|
157
|
+
if not parcels:
|
|
158
|
+
return "small"
|
|
159
|
+
|
|
160
|
+
parcel = parcels[0]
|
|
161
|
+
height_cm = float(parcel.get("height_cm", 0))
|
|
162
|
+
|
|
163
|
+
if height_cm > 19:
|
|
164
|
+
return "large"
|
|
165
|
+
if height_cm > 8:
|
|
166
|
+
return "medium"
|
|
167
|
+
return "small"
|
|
168
|
+
|
|
169
|
+
async def create_shipment(
|
|
170
|
+
self,
|
|
171
|
+
*,
|
|
172
|
+
sender_address: AddressInfo,
|
|
173
|
+
receiver_address: AddressInfo,
|
|
174
|
+
parcels: list[ParcelInfo],
|
|
175
|
+
**kwargs: Any,
|
|
176
|
+
) -> ShipmentCreateResult:
|
|
177
|
+
"""Create an InPost locker shipment.
|
|
178
|
+
|
|
179
|
+
Required kwargs:
|
|
180
|
+
target_point: Locker machine ID (e.g. "KRA010")
|
|
181
|
+
|
|
182
|
+
Optional kwargs:
|
|
183
|
+
sending_method: How to dispatch (default "dispatch_order")
|
|
184
|
+
parcel_template: Override parcel size ("small"/"medium"/"large")
|
|
185
|
+
"""
|
|
186
|
+
target_point = kwargs.get("target_point")
|
|
187
|
+
if not target_point:
|
|
188
|
+
raise ValueError("target_point is required for locker shipments")
|
|
189
|
+
|
|
190
|
+
sending_method = kwargs.get("sending_method", "dispatch_order")
|
|
191
|
+
template = kwargs.get(
|
|
192
|
+
"parcel_template",
|
|
193
|
+
self._parcel_template_from_parcels(parcels),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
receiver_peer = self._address_to_peer(receiver_address)
|
|
197
|
+
|
|
198
|
+
payload = {
|
|
199
|
+
"receiver": dict(receiver_peer),
|
|
200
|
+
"parcels": [{"template": template}],
|
|
201
|
+
"service": "inpost_locker_standard",
|
|
202
|
+
"custom_attributes": {
|
|
203
|
+
"target_point": target_point,
|
|
204
|
+
"sending_method": sending_method,
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
sender_peer = self._address_to_peer(sender_address)
|
|
209
|
+
if sender_peer:
|
|
210
|
+
payload["sender"] = dict(sender_peer)
|
|
211
|
+
|
|
212
|
+
client = self._get_client()
|
|
213
|
+
try:
|
|
214
|
+
response = await client.create_shipment(payload=payload)
|
|
215
|
+
finally:
|
|
216
|
+
await client.close()
|
|
217
|
+
|
|
218
|
+
return ShipmentCreateResult(
|
|
219
|
+
external_id=str(response["id"]),
|
|
220
|
+
tracking_number=response.get("tracking_number", ""),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
async def create_label(self, **kwargs: Any) -> LabelInfo:
|
|
224
|
+
"""Fetch label PDF for the shipment."""
|
|
225
|
+
shipment_id = int(self.shipment.external_id)
|
|
226
|
+
label_format = kwargs.get("label_format", "Pdf")
|
|
227
|
+
|
|
228
|
+
client = self._get_client()
|
|
229
|
+
try:
|
|
230
|
+
content = await client.get_label(
|
|
231
|
+
shipment_id=shipment_id,
|
|
232
|
+
label_format=label_format,
|
|
233
|
+
)
|
|
234
|
+
finally:
|
|
235
|
+
await client.close()
|
|
236
|
+
|
|
237
|
+
format_value: LabelFormat = (
|
|
238
|
+
LabelFormat.PDF
|
|
239
|
+
if label_format == "Pdf"
|
|
240
|
+
else LabelFormat(label_format)
|
|
241
|
+
)
|
|
242
|
+
return LabelInfo(
|
|
243
|
+
format=format_value,
|
|
244
|
+
content_base64=base64.b64encode(content).decode("ascii"),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
async def verify_callback(
|
|
248
|
+
self,
|
|
249
|
+
data: dict[str, Any],
|
|
250
|
+
headers: dict[str, Any],
|
|
251
|
+
**kwargs: Any,
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Verify InPost webhook by source IP."""
|
|
254
|
+
ip_str = headers.get("x-forwarded-for", "").split(",")[0].strip()
|
|
255
|
+
if not ip_str:
|
|
256
|
+
raise InvalidCallbackError("Missing source IP in webhook request")
|
|
257
|
+
try:
|
|
258
|
+
ip_addr = ipaddress.ip_address(ip_str)
|
|
259
|
+
except ValueError as exc:
|
|
260
|
+
raise InvalidCallbackError(f"Invalid source IP: {ip_str}") from exc
|
|
261
|
+
|
|
262
|
+
if ip_addr not in INPOST_WEBHOOK_NETWORK:
|
|
263
|
+
raise InvalidCallbackError(
|
|
264
|
+
f"Source IP {ip_str} not in InPost webhook range"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
async def handle_callback(
|
|
268
|
+
self,
|
|
269
|
+
data: dict[str, Any],
|
|
270
|
+
headers: dict[str, Any],
|
|
271
|
+
**kwargs: Any,
|
|
272
|
+
) -> None:
|
|
273
|
+
"""Process InPost webhook payload.
|
|
274
|
+
|
|
275
|
+
The actual FSM transition is handled by ShipmentFlow.
|
|
276
|
+
This method extracts and normalizes the status.
|
|
277
|
+
"""
|
|
278
|
+
payload = data.get("payload", {})
|
|
279
|
+
shipx_status = payload.get("status", "")
|
|
280
|
+
sendparcel_status = map_shipx_status(shipx_status)
|
|
281
|
+
if sendparcel_status:
|
|
282
|
+
logger.info(
|
|
283
|
+
"InPost webhook: %s -> %s (shipment %s)",
|
|
284
|
+
shipx_status,
|
|
285
|
+
sendparcel_status,
|
|
286
|
+
payload.get("shipment_id"),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
async def fetch_shipment_status(
|
|
290
|
+
self,
|
|
291
|
+
**kwargs: Any,
|
|
292
|
+
) -> ShipmentStatusResponse:
|
|
293
|
+
"""Fetch current status from ShipX API."""
|
|
294
|
+
shipment_id = int(self.shipment.external_id)
|
|
295
|
+
|
|
296
|
+
client = self._get_client()
|
|
297
|
+
try:
|
|
298
|
+
response = await client.get_shipment(shipment_id=shipment_id)
|
|
299
|
+
finally:
|
|
300
|
+
await client.close()
|
|
301
|
+
|
|
302
|
+
shipx_status = response.get("status", "")
|
|
303
|
+
sendparcel_status = map_shipx_status(shipx_status)
|
|
304
|
+
|
|
305
|
+
return ShipmentStatusResponse(
|
|
306
|
+
status=sendparcel_status.value if sendparcel_status else None,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
async def cancel_shipment(self, **kwargs: Any) -> bool:
|
|
310
|
+
"""Cancel shipment via ShipX API."""
|
|
311
|
+
shipment_id = int(self.shipment.external_id)
|
|
312
|
+
|
|
313
|
+
client = self._get_client()
|
|
314
|
+
try:
|
|
315
|
+
await client.cancel_shipment(shipment_id=shipment_id)
|
|
316
|
+
return True
|
|
317
|
+
except ShipXAPIError:
|
|
318
|
+
return False
|
|
319
|
+
finally:
|
|
320
|
+
await client.close()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""ShipX status to sendparcel status mapping."""
|
|
2
|
+
|
|
3
|
+
from sendparcel.enums import ShipmentStatus
|
|
4
|
+
|
|
5
|
+
SHIPX_TO_SENDPARCEL_STATUS: dict[str, ShipmentStatus] = {
|
|
6
|
+
# CREATED
|
|
7
|
+
"created": ShipmentStatus.CREATED,
|
|
8
|
+
"offers_prepared": ShipmentStatus.CREATED,
|
|
9
|
+
"offer_selected": ShipmentStatus.CREATED,
|
|
10
|
+
# LABEL_READY
|
|
11
|
+
"confirmed": ShipmentStatus.LABEL_READY,
|
|
12
|
+
# IN_TRANSIT
|
|
13
|
+
"dispatched_by_sender": ShipmentStatus.IN_TRANSIT,
|
|
14
|
+
"collected_from_sender": ShipmentStatus.IN_TRANSIT,
|
|
15
|
+
"taken_by_courier": ShipmentStatus.IN_TRANSIT,
|
|
16
|
+
"adopted_at_source_branch": ShipmentStatus.IN_TRANSIT,
|
|
17
|
+
"sent_from_source_branch": ShipmentStatus.IN_TRANSIT,
|
|
18
|
+
"adopted_at_sorting_center": ShipmentStatus.IN_TRANSIT,
|
|
19
|
+
# OUT_FOR_DELIVERY
|
|
20
|
+
"out_for_delivery": ShipmentStatus.OUT_FOR_DELIVERY,
|
|
21
|
+
"ready_to_pickup": ShipmentStatus.OUT_FOR_DELIVERY,
|
|
22
|
+
"pickup_reminder_sent": ShipmentStatus.OUT_FOR_DELIVERY,
|
|
23
|
+
"avizo": ShipmentStatus.OUT_FOR_DELIVERY,
|
|
24
|
+
"stack_in_box_machine": ShipmentStatus.OUT_FOR_DELIVERY,
|
|
25
|
+
"stack_in_customer_service_point": ShipmentStatus.OUT_FOR_DELIVERY,
|
|
26
|
+
# DELIVERED
|
|
27
|
+
"delivered": ShipmentStatus.DELIVERED,
|
|
28
|
+
# CANCELLED
|
|
29
|
+
"canceled": ShipmentStatus.CANCELLED,
|
|
30
|
+
# RETURNED
|
|
31
|
+
"returned_to_sender": ShipmentStatus.RETURNED,
|
|
32
|
+
# FAILED
|
|
33
|
+
"rejected_by_receiver": ShipmentStatus.FAILED,
|
|
34
|
+
"undelivered": ShipmentStatus.FAILED,
|
|
35
|
+
"oversized": ShipmentStatus.FAILED,
|
|
36
|
+
"missing": ShipmentStatus.FAILED,
|
|
37
|
+
"claim_created": ShipmentStatus.FAILED,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def map_shipx_status(shipx_status: str) -> ShipmentStatus | None:
|
|
42
|
+
"""Map a ShipX status string to a sendparcel ShipmentStatus.
|
|
43
|
+
|
|
44
|
+
Returns None if the status is not recognized.
|
|
45
|
+
"""
|
|
46
|
+
return SHIPX_TO_SENDPARCEL_STATUS.get(shipx_status)
|