karrio-server-manager 2025.5.1__py3-none-any.whl → 2025.5.3__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.
@@ -1,3 +1,4 @@
1
+ import uuid
1
2
  import typing
2
3
  import rest_framework.status as status
3
4
  import django.db.transaction as transaction
@@ -129,20 +130,38 @@ class ShipmentSerializer(ShipmentData):
129
130
  def create(
130
131
  self, validated_data: dict, context: Context, **kwargs
131
132
  ) -> models.Shipment:
133
+ # fmt: off
134
+ # Apply shipping method if specified (HIGHEST PRIORITY - supersedes service)
135
+ apply_shipping_method_flag, validated_data = resolve_shipping_method(
136
+ validated_data, context
137
+ )
138
+ options = validated_data.get("options") or {}
139
+
132
140
  service = validated_data.get("service")
133
141
  carrier_ids = validated_data.get("carrier_ids") or []
134
142
  fetch_rates = validated_data.get("fetch_rates") is not False
135
143
  services = [service] if service is not None else validated_data.get("services")
144
+
145
+ # Check if we should skip rate fetching for has_alternative_services
146
+ skip_rate_fetching, resolved_carrier_name, _ = (
147
+ resolve_alternative_service_carrier(
148
+ service=service,
149
+ carrier_ids=carrier_ids,
150
+ carriers=[], # Pre-check before loading carriers
151
+ options=options,
152
+ context=context,
153
+ )
154
+ )
155
+
136
156
  carriers = gateway.Carriers.list(
137
157
  context=context,
138
158
  carrier_ids=carrier_ids,
139
- **({"services": services} if any(services) else {}),
159
+ **({"carrier_name": resolved_carrier_name} if resolved_carrier_name else {}),
160
+ **({"services": services} if any(services) and not skip_rate_fetching else {}),
140
161
  **{"raise_not_found": True, **DEFAULT_CARRIER_FILTER},
141
162
  )
142
163
  payment = validated_data.get("payment") or lib.to_dict(
143
- datatypes.Payment(
144
- currency=(validated_data.get("options") or {}).get("currency")
145
- )
164
+ datatypes.Payment(currency=options.get("currency"))
146
165
  )
147
166
  rating_data = {
148
167
  **validated_data,
@@ -152,11 +171,11 @@ class ShipmentSerializer(ShipmentData):
152
171
  messages = validated_data.get("messages") or []
153
172
  apply_shipping_rules = lib.identity(
154
173
  getattr(conf.settings, "SHIPPING_RULES", False)
155
- and (validated_data.get("options") or {}).get("apply_shipping_rules", False)
174
+ and options.get("apply_shipping_rules", False)
156
175
  )
157
176
 
158
- # Get live rates.
159
- if fetch_rates or apply_shipping_rules:
177
+ # Get live rates (skip if has_alternative_services is enabled)
178
+ if (fetch_rates or apply_shipping_rules) and not skip_rate_fetching:
160
179
  rate_response: datatypes.RateResponse = (
161
180
  RateSerializer.map(data=rating_data, context=context)
162
181
  .save(carriers=carriers)
@@ -165,6 +184,16 @@ class ShipmentSerializer(ShipmentData):
165
184
  rates = lib.to_dict(rate_response.rates)
166
185
  messages = lib.to_dict(rate_response.messages)
167
186
 
187
+ # Create synthetic rate when skipping rate fetching
188
+ if skip_rate_fetching:
189
+ _, _, rates = resolve_alternative_service_carrier(
190
+ service=service,
191
+ carrier_ids=carrier_ids,
192
+ carriers=carriers,
193
+ options=options,
194
+ context=context,
195
+ )
196
+
168
197
  shipment = models.Shipment.objects.create(
169
198
  **{
170
199
  **{
@@ -220,14 +249,14 @@ class ShipmentSerializer(ShipmentData):
220
249
  context=context,
221
250
  )
222
251
 
223
- # Buy label if preferred service is selected or shipping rules should applied.
224
- if (service and fetch_rates) or apply_shipping_rules:
252
+ # Buy label if preferred service is selected, shipping method applied, shipping rules applied, or skip rate fetching
253
+ if (service and fetch_rates) or apply_shipping_method_flag or apply_shipping_rules or skip_rate_fetching:
225
254
  return buy_shipment_label(
226
255
  shipment,
227
256
  context=context,
228
257
  service=service,
229
258
  )
230
-
259
+ # fmt: on
231
260
  return shipment
232
261
 
233
262
  @transaction.atomic
@@ -649,16 +678,23 @@ def buy_shipment_label(
649
678
  docs={**lib.to_dict(response.docs), **invoice},
650
679
  )
651
680
 
652
- # Update shipment state
681
+ # Update shipment state - preserve original meta and merge with response meta
682
+ response_details = ShipmentDetails(response).data
683
+ merged_meta = {
684
+ **(shipment.meta or {}),
685
+ **(response_details.get("meta") or {}),
686
+ **({"rule_activity": kwargs.get("rule_activity")} if kwargs.get("rule_activity") else {}),
687
+ }
688
+
653
689
  purchased_shipment = lib.identity(
654
690
  ShipmentSerializer.map(
655
691
  shipment,
656
692
  context=context,
657
693
  data={
658
694
  **payload,
659
- **ShipmentDetails(response).data,
695
+ **response_details,
660
696
  **extra,
661
- # "meta": {**(response.meta or {}), "rule_activity": kwargs.get("rule_activity", None)},
697
+ "meta": merged_meta,
662
698
  },
663
699
  )
664
700
  .save()
@@ -896,3 +932,121 @@ def upload_customs_forms(shipment: models.Shipment, document: dict, context=None
896
932
  .save(shipment=shipment)
897
933
  .instance
898
934
  )
935
+
936
+
937
+ def resolve_alternative_service_carrier(
938
+ service: str,
939
+ carrier_ids: list,
940
+ carriers: list,
941
+ options: dict,
942
+ context: Context,
943
+ ) -> typing.Tuple[bool, typing.Optional[str], typing.List[dict]]:
944
+ """
945
+ Resolve carrier and create synthetic rate for has_alternative_services flow.
946
+
947
+ When has_alternative_services=True and a service is specified, this function:
948
+ 1. Determines if rate fetching should be skipped
949
+ 2. Resolves the carrier from the service name
950
+ 3. Creates a synthetic rate for direct label purchase
951
+
952
+ Returns:
953
+ Tuple of (skip_rate_fetching, resolved_carrier_name, synthetic_rates)
954
+ """
955
+ has_alternative_services = options.get("has_alternative_services", False)
956
+ skip_rate_fetching = service is not None and has_alternative_services
957
+
958
+ if not skip_rate_fetching:
959
+ return False, None, []
960
+
961
+ # Resolve carrier from service when no explicit carrier_ids provided
962
+ resolved_carrier_name = None
963
+ if not any(carrier_ids):
964
+ resolved_carrier_name = utils._get_carrier_for_service(service, context=context)
965
+ if resolved_carrier_name is None:
966
+ raise exceptions.APIException(
967
+ f"Could not resolve carrier for service '{service}'",
968
+ code="validation_error",
969
+ status_code=status.HTTP_400_BAD_REQUEST,
970
+ )
971
+
972
+ if len(carriers) == 0:
973
+ return skip_rate_fetching, resolved_carrier_name, []
974
+
975
+ # Find carrier connection matching the service's carrier
976
+ carrier_name = resolved_carrier_name or utils._get_carrier_for_service(
977
+ service, context=context
978
+ )
979
+ carrier = lib.identity(
980
+ next(
981
+ (c for c in carriers if c.carrier_name == carrier_name),
982
+ carriers[0] if carrier_name is None else None,
983
+ )
984
+ )
985
+
986
+ if carrier is None:
987
+ raise exceptions.APIException(
988
+ f"No carrier connection found for service '{service}'",
989
+ code="validation_error",
990
+ status_code=status.HTTP_400_BAD_REQUEST,
991
+ )
992
+
993
+ # Create synthetic rate for direct label purchase
994
+ synthetic_rates = [
995
+ {
996
+ "id": f"rat_{uuid.uuid4().hex}",
997
+ "carrier_id": carrier.carrier_id,
998
+ "carrier_name": carrier.carrier_name,
999
+ "service": service,
1000
+ "currency": options.get("currency") or "USD",
1001
+ "total_charge": 0,
1002
+ "meta": {
1003
+ "carrier_connection_id": carrier.pk,
1004
+ "has_alternative_services": True,
1005
+ "rate_provider": carrier.carrier_name,
1006
+ "service_name": service.upper().replace("_", " "),
1007
+ },
1008
+ "test_mode": context.test_mode,
1009
+ }
1010
+ ]
1011
+
1012
+ return skip_rate_fetching, resolved_carrier_name, synthetic_rates
1013
+
1014
+
1015
+ def resolve_shipping_method(
1016
+ validated_data: dict,
1017
+ context: Context,
1018
+ ) -> typing.Tuple[bool, dict]:
1019
+ """
1020
+ Resolve and apply shipping method configuration if specified.
1021
+
1022
+ When options.shipping_method is provided, this function:
1023
+ 1. Validates the SHIPPING_METHODS feature is enabled
1024
+ 2. Loads and applies the shipping method configuration
1025
+
1026
+ Returns:
1027
+ Tuple of (apply_shipping_method_flag, modified_validated_data)
1028
+ """
1029
+ if not getattr(conf.settings, "SHIPPING_METHODS", False):
1030
+ options = validated_data.get("options") or {}
1031
+ shipping_method_id = options.get("shipping_method")
1032
+
1033
+ if shipping_method_id is not None:
1034
+ raise exceptions.APIException(
1035
+ "Shipping methods feature is not enabled.",
1036
+ code="feature_disabled",
1037
+ status_code=status.HTTP_400_BAD_REQUEST,
1038
+ )
1039
+
1040
+ return False, validated_data
1041
+
1042
+ options = validated_data.get("options") or {}
1043
+ shipping_method_id = options.get("shipping_method")
1044
+
1045
+ if shipping_method_id is None:
1046
+ return False, validated_data
1047
+
1048
+ modified_data = utils.load_and_apply_shipping_method(
1049
+ validated_data, shipping_method_id, context
1050
+ )
1051
+
1052
+ return True, modified_data
@@ -16,7 +16,7 @@ class TestNotFoundErrors(APITestCase):
16
16
  self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
17
17
  self.assertDictEqual(
18
18
  response_data,
19
- {"errors": [{"code": "not_found", "message": "Address not found"}]},
19
+ {"errors": [{"code": "not_found", "message": "Address not found", "level": "warning"}]},
20
20
  )
21
21
 
22
22
  def test_parcel_not_found_returns_resource_name(self):
@@ -30,7 +30,7 @@ class TestNotFoundErrors(APITestCase):
30
30
  self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
31
31
  self.assertDictEqual(
32
32
  response_data,
33
- {"errors": [{"code": "not_found", "message": "Parcel not found"}]},
33
+ {"errors": [{"code": "not_found", "message": "Parcel not found", "level": "warning"}]},
34
34
  )
35
35
 
36
36
  def test_shipment_not_found_returns_resource_name(self):
@@ -44,7 +44,7 @@ class TestNotFoundErrors(APITestCase):
44
44
  self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
45
45
  self.assertDictEqual(
46
46
  response_data,
47
- {"errors": [{"code": "not_found", "message": "Shipment not found"}]},
47
+ {"errors": [{"code": "not_found", "message": "Shipment not found", "level": "warning"}]},
48
48
  )
49
49
 
50
50
  def test_customs_not_found_returns_resource_name(self):
@@ -58,7 +58,7 @@ class TestNotFoundErrors(APITestCase):
58
58
  self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
59
59
  self.assertDictEqual(
60
60
  response_data,
61
- {"errors": [{"code": "not_found", "message": "Customs not found"}]},
61
+ {"errors": [{"code": "not_found", "message": "Customs not found", "level": "warning"}]},
62
62
  )
63
63
 
64
64
 
@@ -76,6 +76,9 @@ class TestValidationErrors(APITestCase):
76
76
  self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
77
77
  self.assertIn("errors", response_data)
78
78
  self.assertTrue(len(response_data["errors"]) > 0)
79
+ # Validation errors should have level="error"
80
+ for error in response_data["errors"]:
81
+ self.assertEqual(error.get("level"), "error")
79
82
 
80
83
  def test_address_validation_error_format(self):
81
84
  url = reverse("karrio.server.manager:address-list")
@@ -86,3 +89,6 @@ class TestValidationErrors(APITestCase):
86
89
  self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
87
90
  self.assertIn("errors", response_data)
88
91
  self.assertTrue(len(response_data["errors"]) > 0)
92
+ # Validation errors should have level="error"
93
+ for error in response_data["errors"]:
94
+ self.assertEqual(error.get("level"), "error")
@@ -0,0 +1,83 @@
1
+ import json
2
+ from unittest.mock import patch, ANY
3
+ from django.urls import reverse
4
+ from rest_framework import status
5
+ from karrio.core.models import ManifestDetails as ManifestDetailsModel
6
+ from karrio.server.manager.tests.test_shipments import (
7
+ TestShipmentFixture,
8
+ RETURNED_RATES_VALUE,
9
+ CREATED_SHIPMENT_RESPONSE,
10
+ SINGLE_CALL_LABEL_DATA,
11
+ )
12
+
13
+
14
+ class TestManifestDocumentDownload(TestShipmentFixture):
15
+ """Test manifest document download POST API."""
16
+
17
+ def create_manifest(self):
18
+ """Create a manifest via API with a purchased shipment."""
19
+ # First create and purchase a shipment
20
+ shipment_url = reverse("karrio.server.manager:shipment-list")
21
+
22
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
23
+ mock.side_effect = [RETURNED_RATES_VALUE, CREATED_SHIPMENT_RESPONSE]
24
+ response = self.client.post(shipment_url, SINGLE_CALL_LABEL_DATA)
25
+ shipment = json.loads(response.content)
26
+
27
+ # Create manifest via API
28
+ manifest_url = reverse("karrio.server.manager:manifest-list")
29
+ manifest_data = {
30
+ "carrier_name": "canadapost",
31
+ "shipment_ids": [shipment["id"]],
32
+ "address": {
33
+ "address_line1": "125 Church St",
34
+ "city": "Moncton",
35
+ "country_code": "CA",
36
+ "postal_code": "E1C4Z8",
37
+ "state_code": "NB",
38
+ },
39
+ }
40
+
41
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
42
+ mock.return_value = MANIFEST_RESPONSE
43
+ response = self.client.post(manifest_url, manifest_data)
44
+ return json.loads(response.content)
45
+
46
+ def test_download_manifest_document(self):
47
+ manifest = self.create_manifest()
48
+
49
+ url = reverse(
50
+ "karrio.server.manager:manifest-document-download",
51
+ kwargs=dict(pk=manifest["id"]),
52
+ )
53
+ response = self.client.post(url)
54
+ response_data = json.loads(response.content)
55
+
56
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
57
+ self.assertDictEqual(response_data, MANIFEST_DOCUMENT_RESPONSE)
58
+
59
+ def test_download_manifest_not_found(self):
60
+ url = reverse(
61
+ "karrio.server.manager:manifest-document-download",
62
+ kwargs=dict(pk="manf_non_existent_id"),
63
+ )
64
+ response = self.client.post(url)
65
+
66
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
67
+
68
+
69
+ MANIFEST_RESPONSE = (
70
+ ManifestDetailsModel(
71
+ carrier_id="canadapost",
72
+ carrier_name="canadapost",
73
+ doc=dict(manifest="JVBERi0xLjQK"),
74
+ ),
75
+ [],
76
+ )
77
+
78
+ MANIFEST_DOCUMENT_RESPONSE = {
79
+ "category": "manifest",
80
+ "format": "PDF",
81
+ "base64": "JVBERi0xLjQK",
82
+ "url": ANY,
83
+ }
@@ -212,6 +212,144 @@ class TestShipmentPurchase(TestShipmentFixture):
212
212
  ).exists()
213
213
  )
214
214
 
215
+ def test_purchase_shipment_with_has_alternative_services(self):
216
+ """
217
+ Test that when has_alternative_services is enabled and service is requested
218
+ but not in rates, the purchase proceeds by delegating service resolution to the carrier.
219
+ """
220
+ url = reverse(
221
+ "karrio.server.manager:shipment-purchase",
222
+ kwargs=dict(pk=self.shipment.pk),
223
+ )
224
+ self.shipment.options = {"has_alternative_services": True}
225
+ self.shipment.save()
226
+ data = {"service": "canadapost_expedited_parcel"}
227
+
228
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
229
+ mock.return_value = CREATED_SHIPMENT_RESPONSE
230
+ response = self.client.post(url, data)
231
+ response_data = json.loads(response.content)
232
+
233
+ self.assertResponseNoErrors(response) # type: ignore
234
+ self.assertDictEqual(
235
+ dict(status=response_data["status"], service=response_data["service"]),
236
+ dict(status="purchased", service="canadapost_expedited_parcel"),
237
+ )
238
+
239
+
240
+ class TestSingleCallLabelPurchase(APITestCase):
241
+ """Test single call label purchase via POST to shipment-list with a service specified."""
242
+
243
+ def test_single_call_label_purchase(self):
244
+ """
245
+ Test that when a shipment is created with a service specified,
246
+ the label is purchased in a single call after fetching rates.
247
+ """
248
+ url = reverse("karrio.server.manager:shipment-list")
249
+ data = SINGLE_CALL_LABEL_DATA
250
+
251
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
252
+ mock.side_effect = [RETURNED_RATES_VALUE, CREATED_SHIPMENT_RESPONSE]
253
+ response = self.client.post(url, data)
254
+ response_data = json.loads(response.content)
255
+
256
+ self.assertResponseNoErrors(response) # type: ignore
257
+ self.assertDictEqual(
258
+ {
259
+ "status": response_data["status"],
260
+ "carrier_name": response_data["carrier_name"],
261
+ "service": response_data["service"],
262
+ "tracking_number": response_data["tracking_number"],
263
+ "services": response_data["services"],
264
+ "rates_count": len(response_data["rates"]),
265
+ },
266
+ {
267
+ "status": "purchased",
268
+ "carrier_name": "canadapost",
269
+ "service": "canadapost_priority",
270
+ "tracking_number": "123456789012",
271
+ "services": ["canadapost_priority"],
272
+ "rates_count": 1,
273
+ },
274
+ )
275
+
276
+
277
+ class TestSingleCallWithAlternativeServices(APITestCase):
278
+ """Test single call label purchase with has_alternative_services flag (skip rate fetching)."""
279
+
280
+ def test_single_call_label_purchase_skip_rates(self):
281
+ """
282
+ Test that when has_alternative_services=True and service is specified,
283
+ rate fetching is skipped and label is purchased directly.
284
+ Carrier is resolved from the service name.
285
+ """
286
+ url = reverse("karrio.server.manager:shipment-list")
287
+ data = SINGLE_CALL_SKIP_RATES_DATA
288
+
289
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
290
+ mock.return_value = CREATED_SHIPMENT_RESPONSE
291
+ response = self.client.post(url, data)
292
+ response_data = json.loads(response.content)
293
+
294
+ # Verify only 1 call was made (rates were skipped)
295
+ self.assertEqual(mock.call_count, 1)
296
+
297
+ self.assertResponseNoErrors(response) # type: ignore
298
+ self.assertDictEqual(
299
+ {
300
+ "status": response_data["status"],
301
+ "carrier_name": response_data["carrier_name"],
302
+ "service": response_data["service"],
303
+ "tracking_number": response_data["tracking_number"],
304
+ "has_alternative_services": response_data["selected_rate"]["meta"].get(
305
+ "has_alternative_services"
306
+ ),
307
+ },
308
+ {
309
+ "status": "purchased",
310
+ "carrier_name": "canadapost",
311
+ "service": "canadapost_priority",
312
+ "tracking_number": "123456789012",
313
+ "has_alternative_services": True,
314
+ },
315
+ )
316
+
317
+ def test_single_call_label_purchase_skip_rates_with_carrier_ids(self):
318
+ """
319
+ Test that when has_alternative_services=True, service, and carrier_ids are specified,
320
+ rate fetching is skipped and label is purchased directly.
321
+ """
322
+ url = reverse("karrio.server.manager:shipment-list")
323
+ data = {**SINGLE_CALL_SKIP_RATES_DATA, "carrier_ids": ["canadapost"]}
324
+
325
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
326
+ mock.return_value = CREATED_SHIPMENT_RESPONSE
327
+ response = self.client.post(url, data)
328
+ response_data = json.loads(response.content)
329
+
330
+ # Verify only 1 call was made (rates were skipped)
331
+ self.assertEqual(mock.call_count, 1)
332
+
333
+ self.assertResponseNoErrors(response) # type: ignore
334
+ self.assertDictEqual(
335
+ {
336
+ "status": response_data["status"],
337
+ "carrier_name": response_data["carrier_name"],
338
+ "service": response_data["service"],
339
+ "tracking_number": response_data["tracking_number"],
340
+ "has_alternative_services": response_data["selected_rate"]["meta"].get(
341
+ "has_alternative_services"
342
+ ),
343
+ },
344
+ {
345
+ "status": "purchased",
346
+ "carrier_name": "canadapost",
347
+ "service": "canadapost_priority",
348
+ "tracking_number": "123456789012",
349
+ "has_alternative_services": True,
350
+ },
351
+ )
352
+
215
353
 
216
354
  SHIPMENT_DATA = {
217
355
  "recipient": {
@@ -260,11 +398,21 @@ SHIPMENT_RATES = {
260
398
  "total_charge": 106.71,
261
399
  "transit_days": 2,
262
400
  "extra_charges": [
263
- {"name": "Duty and taxes", "amount": 13.92, "currency": "CAD", "id": ANY},
401
+ {
402
+ "name": "Duty and taxes",
403
+ "amount": 13.92,
404
+ "currency": "CAD",
405
+ "id": ANY,
406
+ },
264
407
  {"name": "Fuel surcharge", "amount": 2.7, "currency": "CAD", "id": ANY},
265
408
  {"name": "SMB Savings", "amount": -11.74, "currency": "CAD", "id": ANY},
266
409
  {"name": "Discount", "amount": -9.04, "currency": "CAD", "id": ANY},
267
- {"name": "Base surcharge", "amount": 101.83, "currency": "CAD", "id": ANY},
410
+ {
411
+ "name": "Base surcharge",
412
+ "amount": 101.83,
413
+ "currency": "CAD",
414
+ "id": ANY,
415
+ },
268
416
  ],
269
417
  "meta": {
270
418
  "ext": "canadapost",
@@ -535,7 +683,12 @@ PURCHASED_SHIPMENT = {
535
683
  {"name": "Fuel surcharge", "amount": 2.7, "currency": "CAD", "id": ANY},
536
684
  {"name": "SMB Savings", "amount": -11.74, "currency": "CAD", "id": ANY},
537
685
  {"name": "Discount", "amount": -9.04, "currency": "CAD", "id": ANY},
538
- {"name": "Duties and taxes", "amount": 13.92, "currency": "CAD", "id": ANY},
686
+ {
687
+ "name": "Duties and taxes",
688
+ "amount": 13.92,
689
+ "currency": "CAD",
690
+ "id": ANY,
691
+ },
539
692
  ],
540
693
  "meta": {
541
694
  "service_name": "CANADAPOST PRIORITY",
@@ -679,11 +832,31 @@ CANCEL_RESPONSE = {
679
832
  "total_charge": 106.71,
680
833
  "transit_days": 2,
681
834
  "extra_charges": [
682
- {"name": "Base charge", "amount": 101.83, "currency": "CAD", "id": None},
683
- {"name": "Fuel surcharge", "amount": 2.7, "currency": "CAD", "id": None},
684
- {"name": "SMB Savings", "amount": -11.74, "currency": "CAD", "id": None},
835
+ {
836
+ "name": "Base charge",
837
+ "amount": 101.83,
838
+ "currency": "CAD",
839
+ "id": None,
840
+ },
841
+ {
842
+ "name": "Fuel surcharge",
843
+ "amount": 2.7,
844
+ "currency": "CAD",
845
+ "id": None,
846
+ },
847
+ {
848
+ "name": "SMB Savings",
849
+ "amount": -11.74,
850
+ "currency": "CAD",
851
+ "id": None,
852
+ },
685
853
  {"name": "Discount", "amount": -9.04, "currency": "CAD", "id": None},
686
- {"name": "Duties and taxes", "amount": 13.92, "currency": "CAD", "id": None},
854
+ {
855
+ "name": "Duties and taxes",
856
+ "amount": 13.92,
857
+ "currency": "CAD",
858
+ "id": None,
859
+ },
687
860
  ],
688
861
  "meta": {
689
862
  "carrier_connection_id": ANY,
@@ -797,11 +970,31 @@ CANCEL_PURCHASED_RESPONSE = {
797
970
  "total_charge": 106.71,
798
971
  "transit_days": 2,
799
972
  "extra_charges": [
800
- {"name": "Base charge", "amount": 101.83, "currency": "CAD", "id": None},
801
- {"name": "Fuel surcharge", "amount": 2.7, "currency": "CAD", "id": None},
802
- {"name": "SMB Savings", "amount": -11.74, "currency": "CAD", "id": None},
973
+ {
974
+ "name": "Base charge",
975
+ "amount": 101.83,
976
+ "currency": "CAD",
977
+ "id": None,
978
+ },
979
+ {
980
+ "name": "Fuel surcharge",
981
+ "amount": 2.7,
982
+ "currency": "CAD",
983
+ "id": None,
984
+ },
985
+ {
986
+ "name": "SMB Savings",
987
+ "amount": -11.74,
988
+ "currency": "CAD",
989
+ "id": None,
990
+ },
803
991
  {"name": "Discount", "amount": -9.04, "currency": "CAD", "id": None},
804
- {"name": "Duties and taxes", "amount": 13.92, "currency": "CAD", "id": None},
992
+ {
993
+ "name": "Duties and taxes",
994
+ "amount": 13.92,
995
+ "currency": "CAD",
996
+ "id": None,
997
+ },
805
998
  ],
806
999
  "meta": {
807
1000
  "carrier_connection_id": ANY,
@@ -832,194 +1025,175 @@ CANCEL_PURCHASED_RESPONSE = {
832
1025
  "invoice_url": None,
833
1026
  }
834
1027
 
835
-
836
- class TestShipmentPurchaseWithAlternativeServices(TestShipmentFixture):
837
- def setUp(self) -> None:
838
- super().setUp()
839
- carrier = providers.Carrier.objects.get(carrier_id="canadapost")
840
- # Rates have "canadapost_regular_parcel" but we'll request "canadapost_priority"
841
- self.shipment.rates = [
842
- {
843
- "id": "rat_alt_service_test",
844
- "carrier_id": "canadapost",
845
- "carrier_name": "canadapost",
846
- "currency": "CAD",
847
- "estimated_delivery": None,
848
- "extra_charges": [
849
- {"amount": 50.00, "currency": "CAD", "name": "Base charge"},
850
- ],
851
- "service": "canadapost_regular_parcel",
852
- "total_charge": 50.00,
853
- "transit_days": 5,
854
- "test_mode": True,
855
- "meta": {
856
- "rate_provider": "canadapost",
857
- "service_name": "CANADAPOST REGULAR PARCEL",
858
- "carrier_connection_id": carrier.pk,
859
- },
860
- }
861
- ]
862
- self.shipment.options = {"has_alternative_services": True}
863
- self.shipment.save()
864
-
865
- def test_purchase_with_alternative_service(self):
866
- """
867
- Test that when canadapost_priority is requested but only canadapost_regular_parcel
868
- is in rates, the purchase proceeds with has_alternative_services=True,
869
- delegating service resolution to the carrier.
870
- """
871
- url = reverse(
872
- "karrio.server.manager:shipment-purchase",
873
- kwargs=dict(pk=self.shipment.pk),
874
- )
875
- data = {"service": "canadapost_priority"}
876
-
877
- with patch("karrio.server.core.gateway.utils.identity") as mock:
878
- mock.return_value = CREATED_SHIPMENT_RESPONSE
879
- response = self.client.post(url, data)
880
- response_data = json.loads(response.content)
881
-
882
- self.assertEqual(response.status_code, status.HTTP_200_OK)
883
- self.assertDictEqual(response_data, ALTERNATIVE_SERVICE_PURCHASED_SHIPMENT)
884
-
885
-
886
- ALTERNATIVE_SERVICE_PURCHASED_SHIPMENT = {
887
- "id": ANY,
888
- "object_type": "shipment",
889
- "tracking_url": "/v1/trackers/canadapost/123456789012",
890
- "shipper": {
891
- "id": ANY,
892
- "postal_code": "E1C4Z8",
893
- "city": "Moncton",
894
- "federal_tax_id": None,
895
- "state_tax_id": None,
1028
+ SINGLE_CALL_LABEL_DATA = {
1029
+ "recipient": {
1030
+ "address_line1": "125 Church St",
896
1031
  "person_name": "John Poop",
897
1032
  "company_name": "A corp.",
898
- "country_code": "CA",
899
- "email": None,
900
1033
  "phone_number": "514 000 0000",
901
- "state_code": "NB",
902
- "street_number": None,
1034
+ "city": "Moncton",
1035
+ "country_code": "CA",
1036
+ "postal_code": "E1C4Z8",
903
1037
  "residential": False,
904
- "address_line1": "125 Church St",
905
- "address_line2": None,
906
- "validate_location": False,
907
- "object_type": "address",
908
- "validation": None,
1038
+ "state_code": "NB",
909
1039
  },
910
- "recipient": {
911
- "id": ANY,
912
- "postal_code": "V6M2V9",
913
- "city": "Vancouver",
914
- "federal_tax_id": None,
915
- "state_tax_id": None,
1040
+ "shipper": {
1041
+ "address_line1": "5840 Oak St",
916
1042
  "person_name": "Jane Doe",
917
1043
  "company_name": "B corp.",
918
- "country_code": "CA",
919
- "email": None,
920
1044
  "phone_number": "514 000 9999",
921
- "state_code": "BC",
922
- "street_number": None,
1045
+ "city": "Vancouver",
1046
+ "country_code": "CA",
1047
+ "postal_code": "V6M2V9",
923
1048
  "residential": False,
924
- "address_line1": "5840 Oak St",
925
- "address_line2": None,
926
- "validate_location": False,
927
- "object_type": "address",
928
- "validation": None,
1049
+ "state_code": "BC",
929
1050
  },
930
1051
  "parcels": [
931
1052
  {
932
- "id": ANY,
933
- "weight": 1.0,
934
- "width": None,
935
- "height": None,
936
- "length": None,
937
- "packaging_type": None,
938
- "package_preset": "canadapost_corrugated_small_box",
939
- "description": None,
940
- "content": None,
941
- "is_document": False,
1053
+ "weight": 1,
942
1054
  "weight_unit": "KG",
943
- "dimension_unit": None,
944
- "items": [],
945
- "freight_class": None,
946
- "reference_number": ANY,
947
- "object_type": "parcel",
948
- "options": {},
1055
+ "package_preset": "canadapost_corrugated_small_box",
949
1056
  }
950
1057
  ],
951
- "services": [],
952
- "options": {"has_alternative_services": True, "shipping_date": ANY, "shipment_date": ANY},
953
- "payment": {"paid_by": "sender", "currency": "CAD", "account_number": None},
954
- "return_address": None,
955
- "billing_address": None,
956
- "customs": None,
957
- "rates": [
1058
+ "payment": {"currency": "CAD", "paid_by": "sender"},
1059
+ "service": "canadapost_priority",
1060
+ "carrier_ids": ["canadapost"],
1061
+ "options": {"insurance": 100},
1062
+ }
1063
+
1064
+ SINGLE_CALL_SKIP_RATES_DATA = {
1065
+ # Note: No carrier_ids provided - carrier is resolved from service name
1066
+ "recipient": {
1067
+ "address_line1": "125 Church St",
1068
+ "person_name": "John Poop",
1069
+ "company_name": "A corp.",
1070
+ "phone_number": "514 000 0000",
1071
+ "city": "Moncton",
1072
+ "country_code": "CA",
1073
+ "postal_code": "E1C4Z8",
1074
+ "residential": False,
1075
+ "state_code": "NB",
1076
+ },
1077
+ "shipper": {
1078
+ "address_line1": "5840 Oak St",
1079
+ "person_name": "Jane Doe",
1080
+ "company_name": "B corp.",
1081
+ "phone_number": "514 000 9999",
1082
+ "city": "Vancouver",
1083
+ "country_code": "CA",
1084
+ "postal_code": "V6M2V9",
1085
+ "residential": False,
1086
+ "state_code": "BC",
1087
+ },
1088
+ "parcels": [
958
1089
  {
959
- "id": ANY,
960
- "object_type": "rate",
961
- "carrier_name": "canadapost",
962
- "carrier_id": "canadapost",
963
- "currency": "CAD",
964
- "estimated_delivery": ANY,
965
- "service": "canadapost_regular_parcel",
966
- "total_charge": 50.00,
967
- "transit_days": 5,
968
- "extra_charges": [
969
- {"name": "Base charge", "amount": 50.00, "currency": "CAD", "id": None},
970
- ],
971
- "meta": {
972
- "service_name": "CANADAPOST REGULAR PARCEL",
973
- "rate_provider": "canadapost",
974
- "carrier_connection_id": ANY,
975
- },
976
- "test_mode": True,
1090
+ "weight": 1,
1091
+ "weight_unit": "KG",
1092
+ "package_preset": "canadapost_corrugated_small_box",
977
1093
  }
978
1094
  ],
979
- "reference": None,
980
- "label_type": "PDF",
981
- "carrier_ids": [],
982
- "tracker_id": ANY,
983
- "created_at": ANY,
984
- "metadata": {},
985
- "messages": [],
986
- "status": "purchased",
987
- "carrier_name": "canadapost",
988
- "carrier_id": "canadapost",
989
- "tracking_number": "123456789012",
990
- "shipment_identifier": "123456789012",
991
- "selected_rate": {
992
- "id": ANY,
993
- "object_type": "rate",
994
- "carrier_name": "canadapost",
995
- "carrier_id": "canadapost",
996
- "currency": "CAD",
997
- "estimated_delivery": ANY,
998
- "service": "canadapost_priority",
999
- "total_charge": 50.00,
1000
- "transit_days": 5,
1001
- "extra_charges": [
1002
- {"name": "Base charge", "amount": 50.00, "currency": "CAD", "id": None},
1003
- ],
1004
- "meta": {
1005
- "ext": "canadapost",
1006
- "carrier": "canadapost",
1007
- "service_name": "CANADAPOST REGULAR PARCEL",
1008
- "rate_provider": "canadapost",
1009
- "carrier_connection_id": ANY,
1010
- "has_alternative_services": True,
1011
- },
1012
- "test_mode": True,
1013
- },
1014
- "meta": {
1015
- "ext": "canadapost",
1016
- "carrier": "canadapost",
1017
- "rate_provider": "canadapost",
1018
- "service_name": "CANADAPOST PRIORITY",
1019
- },
1095
+ "payment": {"currency": "CAD", "paid_by": "sender"},
1020
1096
  "service": "canadapost_priority",
1021
- "selected_rate_id": ANY,
1022
- "test_mode": True,
1023
- "label_url": ANY,
1024
- "invoice_url": None,
1097
+ "options": {"has_alternative_services": True},
1098
+ }
1099
+
1100
+
1101
+ class TestShipmentDocumentDownload(APITestCase):
1102
+ """Test shipment document download POST API."""
1103
+
1104
+ def create_purchased_shipment(self):
1105
+ """Create and purchase a shipment via API."""
1106
+ url = reverse("karrio.server.manager:shipment-list")
1107
+
1108
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
1109
+ mock.side_effect = [RETURNED_RATES_VALUE, CREATED_SHIPMENT_RESPONSE]
1110
+ response = self.client.post(url, SINGLE_CALL_LABEL_DATA)
1111
+ return json.loads(response.content)
1112
+
1113
+ def test_download_label_document(self):
1114
+ shipment = self.create_purchased_shipment()
1115
+
1116
+ url = reverse(
1117
+ "karrio.server.manager:shipment-document-download",
1118
+ kwargs=dict(pk=shipment["id"], doc="label"),
1119
+ )
1120
+ response = self.client.post(url)
1121
+ response_data = json.loads(response.content)
1122
+
1123
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
1124
+ self.assertDictEqual(
1125
+ response_data,
1126
+ LABEL_DOCUMENT_RESPONSE,
1127
+ )
1128
+
1129
+ def test_download_document_not_found(self):
1130
+ # Create a draft shipment (no label)
1131
+ url = reverse("karrio.server.manager:shipment-list")
1132
+
1133
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
1134
+ mock.return_value = RETURNED_RATES_VALUE
1135
+ response = self.client.post(url, SHIPMENT_DATA)
1136
+ shipment = json.loads(response.content)
1137
+
1138
+ url = reverse(
1139
+ "karrio.server.manager:shipment-document-download",
1140
+ kwargs=dict(pk=shipment["id"], doc="label"),
1141
+ )
1142
+ response = self.client.post(url)
1143
+
1144
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
1145
+
1146
+ def test_download_invalid_document_type(self):
1147
+ shipment = self.create_purchased_shipment()
1148
+
1149
+ url = reverse(
1150
+ "karrio.server.manager:shipment-document-download",
1151
+ kwargs=dict(pk=shipment["id"], doc="invalid"),
1152
+ )
1153
+ response = self.client.post(url)
1154
+
1155
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
1156
+
1157
+ def test_download_shipment_not_found(self):
1158
+ url = reverse(
1159
+ "karrio.server.manager:shipment-document-download",
1160
+ kwargs=dict(pk="shp_non_existent_id", doc="label"),
1161
+ )
1162
+ response = self.client.post(url)
1163
+
1164
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
1165
+
1166
+
1167
+ class TestShipmentCancelIdempotent(APITestCase):
1168
+ """Test shipment cancel idempotency."""
1169
+
1170
+ def test_cancel_already_cancelled_shipment_returns_202(self):
1171
+ # Create a shipment via API
1172
+ url = reverse("karrio.server.manager:shipment-list")
1173
+
1174
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
1175
+ mock.return_value = RETURNED_RATES_VALUE
1176
+ response = self.client.post(url, SHIPMENT_DATA)
1177
+ shipment = json.loads(response.content)
1178
+
1179
+ # Cancel the shipment first time
1180
+ cancel_url = reverse(
1181
+ "karrio.server.manager:shipment-cancel",
1182
+ kwargs=dict(pk=shipment["id"]),
1183
+ )
1184
+ self.client.post(cancel_url)
1185
+
1186
+ # Cancel again - should return 202
1187
+ response = self.client.post(cancel_url)
1188
+ response_data = json.loads(response.content)
1189
+
1190
+ self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
1191
+ self.assertEqual(response_data["status"], "cancelled")
1192
+
1193
+
1194
+ LABEL_DOCUMENT_RESPONSE = {
1195
+ "category": "label",
1196
+ "format": "PDF",
1197
+ "base64": "==apodifjoefr",
1198
+ "url": ANY,
1025
1199
  }
@@ -17,6 +17,7 @@ import karrio.server.core.filters as filters
17
17
  import karrio.server.manager.models as models
18
18
  import karrio.server.manager.router as router
19
19
  import karrio.server.manager.serializers as serializers
20
+ from karrio.server.core.serializers import ShippingDocument
20
21
 
21
22
  ENDPOINT_ID = "$$$$&&" # This endpoint id is used to make operation ids unique make sure not to duplicate
22
23
  Manifests = serializers.PaginatedResult("ManifestList", serializers.Manifest)
@@ -115,7 +116,14 @@ class ManifestDoc(django_downloadview.VirtualDownloadView):
115
116
 
116
117
  query_params = req.GET.dict()
117
118
 
118
- self.manifest = models.Manifest.objects.get(pk=pk, manifest__isnull=False)
119
+ self.manifest = models.Manifest.objects.filter(pk=pk, manifest__isnull=False).first()
120
+
121
+ if self.manifest is None:
122
+ return response.Response(
123
+ {"errors": [{"message": f"Manifest '{pk}' not found or has no document"}]},
124
+ status=status.HTTP_404_NOT_FOUND,
125
+ )
126
+
119
127
  self.document = getattr(self.manifest, doc, None)
120
128
  self.name = f"{doc}_{self.manifest.id}.{format}"
121
129
 
@@ -134,6 +142,47 @@ class ManifestDoc(django_downloadview.VirtualDownloadView):
134
142
  return base.ContentFile(buffer.getvalue(), name=self.name)
135
143
 
136
144
 
145
+ class ManifestDocumentDownload(api.APIView):
146
+
147
+ @openapi.extend_schema(
148
+ tags=["Manifests"],
149
+ operation_id=f"{ENDPOINT_ID}document",
150
+ extensions={"x-operationId": "retrieveManifestDocument"},
151
+ summary="Retrieve a manifest document",
152
+ request=None,
153
+ responses={
154
+ 200: ShippingDocument(),
155
+ 404: serializers.ErrorResponse(),
156
+ 500: serializers.ErrorResponse(),
157
+ },
158
+ )
159
+ def post(self, req: request.Request, pk: str):
160
+ """
161
+ Retrieve a manifest document as base64 encoded content.
162
+ """
163
+ manifest = models.Manifest.access_by(req).filter(pk=pk, manifest__isnull=False).first()
164
+
165
+ if manifest is None:
166
+ return response.Response(
167
+ {"errors": [{"message": f"Manifest '{pk}' not found or has no document"}]},
168
+ status=status.HTTP_404_NOT_FOUND,
169
+ )
170
+
171
+ # Build the GET URL for the document
172
+ doc_url = f"/v1/manifests/{pk}/manifest.pdf"
173
+
174
+ return response.Response(
175
+ ShippingDocument(
176
+ {
177
+ "category": "manifest",
178
+ "format": "PDF",
179
+ "base64": manifest.manifest,
180
+ "url": doc_url,
181
+ }
182
+ ).data
183
+ )
184
+
185
+
137
186
  router.router.urls.append(
138
187
  urls.path(
139
188
  "manifests",
@@ -148,9 +197,16 @@ router.router.urls.append(
148
197
  name="manifest-details",
149
198
  )
150
199
  )
200
+ router.router.urls.append(
201
+ urls.path(
202
+ "manifests/<str:pk>/document",
203
+ ManifestDocumentDownload.as_view(),
204
+ name="manifest-document-download",
205
+ )
206
+ )
151
207
  router.router.urls.append(
152
208
  urls.re_path(
153
- r"^manifests/(?P<pk>\w+)/(?P<doc>[a-z0-9]+).(?P<format>[a-z0-9]+)",
209
+ r"^manifests/(?P<pk>\w+)/(?P<doc>[a-z0-9]+)\.(?P<format>[a-z0-9]+)",
154
210
  ManifestDoc.as_view(),
155
211
  name="manifest-docs",
156
212
  )
@@ -25,6 +25,7 @@ from karrio.server.manager.serializers import (
25
25
  ErrorMessages,
26
26
  Shipment,
27
27
  ShipmentData,
28
+ ShipmentStatus,
28
29
  buy_shipment_label,
29
30
  can_mutate_shipment,
30
31
  ShipmentSerializer,
@@ -32,6 +33,7 @@ from karrio.server.manager.serializers import (
32
33
  ShipmentUpdateData,
33
34
  ShipmentPurchaseData,
34
35
  ShipmentCancelSerializer,
36
+ ShippingDocument,
35
37
  )
36
38
 
37
39
  ENDPOINT_ID = "$$$$$" # This endpoint id is used to make operation ids unique make sure not to duplicate
@@ -165,6 +167,7 @@ class ShipmentCancel(APIView):
165
167
  request=None,
166
168
  responses={
167
169
  200: Shipment(),
170
+ 202: Shipment(),
168
171
  404: ErrorResponse(),
169
172
  400: ErrorResponse(),
170
173
  409: ErrorResponse(),
@@ -177,6 +180,11 @@ class ShipmentCancel(APIView):
177
180
  Void a shipment with the associated label.
178
181
  """
179
182
  shipment = models.Shipment.access_by(request).get(pk=pk)
183
+
184
+ # Return 202 if already cancelled (idempotent)
185
+ if shipment.status == ShipmentStatus.cancelled.value:
186
+ return Response(Shipment(shipment).data, status=status.HTTP_202_ACCEPTED)
187
+
180
188
  can_mutate_shipment(shipment, delete=True)
181
189
 
182
190
  update = ShipmentCancelSerializer.map(shipment, context=request).save().instance
@@ -275,7 +283,14 @@ class ShipmentDocs(VirtualDownloadView):
275
283
 
276
284
  query_params = request.GET.dict()
277
285
 
278
- self.shipment = models.Shipment.objects.get(pk=pk, label__isnull=False)
286
+ self.shipment = models.Shipment.objects.filter(pk=pk, label__isnull=False).first()
287
+
288
+ if self.shipment is None:
289
+ return Response(
290
+ {"errors": [{"message": f"Shipment '{pk}' not found or has no label"}]},
291
+ status=status.HTTP_404_NOT_FOUND,
292
+ )
293
+
279
294
  self.document = getattr(self.shipment, doc, None)
280
295
  self.name = f"{doc}_{self.shipment.tracking_number}.{format}"
281
296
 
@@ -314,6 +329,64 @@ class ShipmentDocs(VirtualDownloadView):
314
329
  return ContentFile(buffer.getvalue(), name=self.name)
315
330
 
316
331
 
332
+ class ShipmentDocumentDownload(APIView):
333
+ throttle_scope = "carrier_request"
334
+
335
+ @openapi.extend_schema(
336
+ tags=["Shipments"],
337
+ operation_id=f"{ENDPOINT_ID}document",
338
+ extensions={"x-operationId": "retrieveShipmentDocument"},
339
+ summary="Retrieve a shipment document",
340
+ request=None,
341
+ responses={
342
+ 200: ShippingDocument(),
343
+ 404: ErrorResponse(),
344
+ 500: ErrorResponse(),
345
+ },
346
+ )
347
+ def post(self, request: Request, pk: str, doc: str = "label"):
348
+ """
349
+ Retrieve a shipment document (label or invoice) as base64 encoded content.
350
+ """
351
+ if doc not in ["label", "invoice"]:
352
+ return Response(
353
+ {"errors": [{"message": f"Invalid document type: {doc}"}]},
354
+ status=status.HTTP_400_BAD_REQUEST,
355
+ )
356
+
357
+ # Build filter based on document type
358
+ filter_kwargs = {"pk": pk, f"{doc}__isnull": False}
359
+ shipment = models.Shipment.access_by(request).filter(**filter_kwargs).first()
360
+
361
+ if shipment is None:
362
+ return Response(
363
+ {"errors": [{"message": f"Shipment '{pk}' not found or has no {doc}"}]},
364
+ status=status.HTTP_404_NOT_FOUND,
365
+ )
366
+
367
+ document = getattr(shipment, doc)
368
+
369
+ # Determine format based on label_type for label, always PDF for invoice
370
+ if doc == "label":
371
+ doc_format = shipment.label_type or "PDF"
372
+ else:
373
+ doc_format = "PDF"
374
+
375
+ # Build the GET URL for the document
376
+ doc_url = f"/v1/shipments/{pk}/{doc}.{doc_format.lower()}"
377
+
378
+ return Response(
379
+ ShippingDocument(
380
+ {
381
+ "category": doc,
382
+ "format": doc_format,
383
+ "base64": document,
384
+ "url": doc_url,
385
+ }
386
+ ).data
387
+ )
388
+
389
+
317
390
  router.urls.append(path("shipments", ShipmentList.as_view(), name="shipment-list"))
318
391
  router.urls.append(
319
392
  path("shipments/<str:pk>", ShipmentDetails.as_view(), name="shipment-details")
@@ -331,9 +404,16 @@ router.urls.append(
331
404
  name="shipment-purchase",
332
405
  )
333
406
  )
407
+ router.urls.append(
408
+ path(
409
+ "shipments/<str:pk>/documents/<str:doc>",
410
+ ShipmentDocumentDownload.as_view(),
411
+ name="shipment-document-download",
412
+ )
413
+ )
334
414
  router.urls.append(
335
415
  re_path(
336
- r"^shipments/(?P<pk>\w+)/(?P<doc>[a-z0-9]+).(?P<format>[a-z0-9]+)",
416
+ r"^shipments/(?P<pk>\w+)/(?P<doc>[a-z0-9]+)\.(?P<format>[a-z0-9]+)",
337
417
  ShipmentDocs.as_view(),
338
418
  name="shipment-docs",
339
419
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karrio_server_manager
3
- Version: 2025.5.1
3
+ Version: 2025.5.3
4
4
  Summary: Multi-carrier shipping API Shipments manager module
5
5
  Author-email: karrio <hello@karrio.io>
6
6
  License-Expression: LGPL-3.0
@@ -82,26 +82,27 @@ karrio/server/manager/serializers/manifest.py,sha256=mSneCk_7HMXpi64_7hggWvkR7Ma
82
82
  karrio/server/manager/serializers/parcel.py,sha256=733Bg26lVbEkoWtAVM5Qt2IRBS2QDuVxhG40Hiqh3bw,2621
83
83
  karrio/server/manager/serializers/pickup.py,sha256=sX0VmcQxGkXn3IEosMuFwdXh4HhdkPcuBOp79O8PoDQ,9233
84
84
  karrio/server/manager/serializers/rate.py,sha256=7vYK_v8iWEDnswqYHG2Lir16_UhHTOxW5rdC6lw3lzA,652
85
- karrio/server/manager/serializers/shipment.py,sha256=gkDFTDaKXA8Iz3yjS6fxRi46AzZPw40RhJEOlnBZy6o,31143
85
+ karrio/server/manager/serializers/shipment.py,sha256=8joRuh_F6V6NwFXWekdhsn0eCSvubAAjUbgwbujns6Y,36674
86
86
  karrio/server/manager/serializers/tracking.py,sha256=ixrAjIiZQsvSt4y0qtisGkt6TFOJ3ORNkJAQVt6YQrA,12483
87
87
  karrio/server/manager/tests/__init__.py,sha256=Y1UNteEE60vWdUAkjbldu_r_-h4u0He8-UoiBgTjKcU,391
88
88
  karrio/server/manager/tests/test_addresses.py,sha256=pNkZC_yJyb29ZlEOtOAs4blcEYiOarw0zhZIZC5uj1w,3111
89
89
  karrio/server/manager/tests/test_custom_infos.py,sha256=iv2cLdZVoVWFZK_mDUEnrZssncAnQcn87Rn2sAk8UQI,2731
90
- karrio/server/manager/tests/test_errors.py,sha256=QYsGLUtwMvrHeX1XSCpdteTKbug7-y1-Xgvbl96aN9g,3220
90
+ karrio/server/manager/tests/test_errors.py,sha256=x2-mSsXknHkE4V7TajEu8d3rpqV38T_xyAaYJU7xcGQ,3616
91
+ karrio/server/manager/tests/test_manifests.py,sha256=X35ZTXTFEM4Gxdjz598yiNNkOOKZGpILjHWRC0oM5U4,2764
91
92
  karrio/server/manager/tests/test_parcels.py,sha256=lVLBOsHzXgXQvYjHIUy5oiPvrMfxYpueVvvhtuhstWk,2559
92
93
  karrio/server/manager/tests/test_pickups.py,sha256=8jxddwTnBvBM9FOyWxW9TtZ-GOVYUje7HQ2EZjsbtD8,10681
93
- karrio/server/manager/tests/test_shipments.py,sha256=N1eVH-n0BTANmzehKL0cp917goskU5X8l_ftXzcVSmw,33349
94
+ karrio/server/manager/tests/test_shipments.py,sha256=wvF_ZBK3QDI1id51bVh9FARuLQR18jkxP7n9tub7loo,39455
94
95
  karrio/server/manager/tests/test_trackers.py,sha256=KvmWkplokNDZ0dzB16mFl0WcMJ0OYp_ErZeWJPGW_NA,7151
95
96
  karrio/server/manager/views/__init__.py,sha256=kDFUaORRQ3Xh0ZPm-Jk88Ss8dgGYM57iUFXb9TPMzh0,401
96
97
  karrio/server/manager/views/addresses.py,sha256=7YCAs2ZYgd1icYwMcGGWfX7A7vZEL4BEAbU4eIxhiMY,4620
97
98
  karrio/server/manager/views/customs.py,sha256=-ZreiKyJ1xeLeNVG53nMfRQFeURduWr1QkDItdLPnE8,4875
98
99
  karrio/server/manager/views/documents.py,sha256=znW54qJ_k7WInIut5FBZFDT93CioozXTOYFKRSUTBhA,4005
99
- karrio/server/manager/views/manifests.py,sha256=_Dd83YxVJOgWhAhD745Kr4tcLWnKaU1dxnT5xB8opvk,5227
100
+ karrio/server/manager/views/manifests.py,sha256=bk-8XoGLVqgjDfpTZbTKjXW7r8DYNDp2ce2xGG73sbI,7012
100
101
  karrio/server/manager/views/parcels.py,sha256=hZY45rg6SrTWfQqyJ38MGKSor1yqgPUEVHtu16aG37g,4594
101
102
  karrio/server/manager/views/pickups.py,sha256=gmpxz9ot1OR-BP1qh-0MXU3kUJi1ht_74hfaLJzJ42w,5503
102
- karrio/server/manager/views/shipments.py,sha256=TqLpBH5Jf-rI3enJwvNptRwGzfo7co9R1VSP_oqhB3o,10419
103
+ karrio/server/manager/views/shipments.py,sha256=YCP0A8U-wFFwMgEfMZ3egWb4PwJ-aPLijILjwtH7aHo,12998
103
104
  karrio/server/manager/views/trackers.py,sha256=3oGn2qDpHgk8GZvuz-Cb93Fc0j_h_HbXQR692Zhfiok,12363
104
- karrio_server_manager-2025.5.1.dist-info/METADATA,sha256=7G3zIzLe_6ux6VkjCj1ub1xQc4b9Cz-uiyzwvBpSDDs,730
105
- karrio_server_manager-2025.5.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
106
- karrio_server_manager-2025.5.1.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
107
- karrio_server_manager-2025.5.1.dist-info/RECORD,,
105
+ karrio_server_manager-2025.5.3.dist-info/METADATA,sha256=LpqgZqS3merjsArSi0Hz_yZKBjNSksZ40YXNMhe5IVY,730
106
+ karrio_server_manager-2025.5.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
107
+ karrio_server_manager-2025.5.3.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
108
+ karrio_server_manager-2025.5.3.dist-info/RECORD,,