karrio-server-manager 2025.5rc36__py3-none-any.whl → 2025.5.2__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
@@ -48,6 +49,7 @@ from karrio.server.manager.serializers.customs import CustomsSerializer
48
49
  from karrio.server.manager.serializers.parcel import ParcelSerializer
49
50
  from karrio.server.manager.serializers.rate import RateSerializer
50
51
  import karrio.server.manager.models as models
52
+
51
53
  DEFAULT_CARRIER_FILTER: typing.Any = dict(active=True, capability="shipping")
52
54
 
53
55
 
@@ -128,20 +130,33 @@ class ShipmentSerializer(ShipmentData):
128
130
  def create(
129
131
  self, validated_data: dict, context: Context, **kwargs
130
132
  ) -> models.Shipment:
133
+ # fmt: off
131
134
  service = validated_data.get("service")
132
135
  carrier_ids = validated_data.get("carrier_ids") or []
133
136
  fetch_rates = validated_data.get("fetch_rates") is not False
134
137
  services = [service] if service is not None else validated_data.get("services")
138
+ options = validated_data.get("options") or {}
139
+
140
+ # Check if we should skip rate fetching for has_alternative_services
141
+ skip_rate_fetching, resolved_carrier_name, _ = (
142
+ resolve_alternative_service_carrier(
143
+ service=service,
144
+ carrier_ids=carrier_ids,
145
+ carriers=[], # Pre-check before loading carriers
146
+ options=options,
147
+ context=context,
148
+ )
149
+ )
150
+
135
151
  carriers = gateway.Carriers.list(
136
152
  context=context,
137
153
  carrier_ids=carrier_ids,
138
- **({"services": services} if any(services) else {}),
154
+ **({"carrier_name": resolved_carrier_name} if resolved_carrier_name else {}),
155
+ **({"services": services} if any(services) and not skip_rate_fetching else {}),
139
156
  **{"raise_not_found": True, **DEFAULT_CARRIER_FILTER},
140
157
  )
141
158
  payment = validated_data.get("payment") or lib.to_dict(
142
- datatypes.Payment(
143
- currency=(validated_data.get("options") or {}).get("currency")
144
- )
159
+ datatypes.Payment(currency=options.get("currency"))
145
160
  )
146
161
  rating_data = {
147
162
  **validated_data,
@@ -151,11 +166,11 @@ class ShipmentSerializer(ShipmentData):
151
166
  messages = validated_data.get("messages") or []
152
167
  apply_shipping_rules = lib.identity(
153
168
  getattr(conf.settings, "SHIPPING_RULES", False)
154
- and (validated_data.get("options") or {}).get("apply_shipping_rules", False)
169
+ and options.get("apply_shipping_rules", False)
155
170
  )
156
171
 
157
- # Get live rates.
158
- if fetch_rates or apply_shipping_rules:
172
+ # Get live rates (skip if has_alternative_services is enabled)
173
+ if (fetch_rates or apply_shipping_rules) and not skip_rate_fetching:
159
174
  rate_response: datatypes.RateResponse = (
160
175
  RateSerializer.map(data=rating_data, context=context)
161
176
  .save(carriers=carriers)
@@ -164,6 +179,16 @@ class ShipmentSerializer(ShipmentData):
164
179
  rates = lib.to_dict(rate_response.rates)
165
180
  messages = lib.to_dict(rate_response.messages)
166
181
 
182
+ # Create synthetic rate when skipping rate fetching
183
+ if skip_rate_fetching:
184
+ _, _, rates = resolve_alternative_service_carrier(
185
+ service=service,
186
+ carrier_ids=carrier_ids,
187
+ carriers=carriers,
188
+ options=options,
189
+ context=context,
190
+ )
191
+
167
192
  shipment = models.Shipment.objects.create(
168
193
  **{
169
194
  **{
@@ -219,14 +244,14 @@ class ShipmentSerializer(ShipmentData):
219
244
  context=context,
220
245
  )
221
246
 
222
- # Buy label if preferred service is selected or shipping rules should applied.
223
- if (service and fetch_rates) or apply_shipping_rules:
247
+ # Buy label if preferred service is selected, shipping rules applied, or skip rate fetching
248
+ if (service and fetch_rates) or apply_shipping_rules or skip_rate_fetching:
224
249
  return buy_shipment_label(
225
250
  shipment,
226
251
  context=context,
227
252
  service=service,
228
253
  )
229
-
254
+ # fmt: on
230
255
  return shipment
231
256
 
232
257
  @transaction.atomic
@@ -288,7 +313,16 @@ class ShipmentSerializer(ShipmentData):
288
313
 
289
314
 
290
315
  class ShipmentPurchaseData(Serializer):
291
- selected_rate_id = CharField(required=True, help_text="The shipment selected rate.")
316
+ selected_rate_id = CharField(
317
+ required=False,
318
+ allow_null=True,
319
+ help_text="The shipment selected rate.",
320
+ )
321
+ service = CharField(
322
+ required=False,
323
+ allow_null=True,
324
+ help_text="The carrier service to use for the shipment (alternative to selected_rate_id).",
325
+ )
292
326
  label_type = ChoiceField(
293
327
  required=False,
294
328
  choices=LABEL_TYPES,
@@ -306,6 +340,15 @@ class ShipmentPurchaseData(Serializer):
306
340
  required=False, help_text="User metadata for the shipment"
307
341
  )
308
342
 
343
+ def validate(self, data):
344
+ if not data.get("selected_rate_id") and not data.get("service"):
345
+ raise exceptions.APIException(
346
+ "Either 'selected_rate_id' or 'service' must be provided.",
347
+ code="validation_error",
348
+ status_code=status.HTTP_400_BAD_REQUEST,
349
+ )
350
+ return data
351
+
309
352
 
310
353
  class ShipmentUpdateData(validators.OptionDefaultSerializer):
311
354
  label_type = ChoiceField(
@@ -792,9 +835,15 @@ def create_shipment_tracker(shipment: typing.Optional[models.Shipment], context)
792
835
  )
793
836
  tracker.save()
794
837
  link_org(tracker, context)
795
- logger.info("Successfully added a tracker to the shipment", shipment_id=shipment.id)
838
+ logger.info(
839
+ "Successfully added a tracker to the shipment", shipment_id=shipment.id
840
+ )
796
841
  except Exception as e:
797
- logger.exception("Failed to create new label tracker", error=str(e), shipment_id=shipment.id)
842
+ logger.exception(
843
+ "Failed to create new label tracker",
844
+ error=str(e),
845
+ shipment_id=shipment.id,
846
+ )
798
847
 
799
848
  # Update shipment tracking url if different from the current one
800
849
  try:
@@ -815,7 +864,12 @@ def create_shipment_tracker(shipment: typing.Optional[models.Shipment], context)
815
864
  shipment.tracking_url = tracking_url
816
865
  shipment.save(update_fields=["tracking_url"])
817
866
  except Exception as e:
818
- logger.exception("Failed to update shipment tracking url", error=str(e), shipment_id=shipment.id, tracking_number=shipment.tracking_number)
867
+ logger.exception(
868
+ "Failed to update shipment tracking url",
869
+ error=str(e),
870
+ shipment_id=shipment.id,
871
+ tracking_number=shipment.tracking_number,
872
+ )
819
873
 
820
874
 
821
875
  def generate_custom_invoice(template: str, shipment: models.Shipment, **kwargs):
@@ -843,7 +897,11 @@ def generate_custom_invoice(template: str, shipment: models.Shipment, **kwargs):
843
897
  shipment.invoice = document["doc_file"]
844
898
  shipment.save(update_fields=["invoice"])
845
899
 
846
- logger.info("Custom document successfully generated", shipment_id=shipment.id, template=template)
900
+ logger.info(
901
+ "Custom document successfully generated",
902
+ shipment_id=shipment.id,
903
+ template=template,
904
+ )
847
905
 
848
906
  return document
849
907
 
@@ -862,3 +920,81 @@ def upload_customs_forms(shipment: models.Shipment, document: dict, context=None
862
920
  .save(shipment=shipment)
863
921
  .instance
864
922
  )
923
+
924
+
925
+ def resolve_alternative_service_carrier(
926
+ service: str,
927
+ carrier_ids: list,
928
+ carriers: list,
929
+ options: dict,
930
+ context: Context,
931
+ ) -> typing.Tuple[bool, typing.Optional[str], typing.List[dict]]:
932
+ """
933
+ Resolve carrier and create synthetic rate for has_alternative_services flow.
934
+
935
+ When has_alternative_services=True and a service is specified, this function:
936
+ 1. Determines if rate fetching should be skipped
937
+ 2. Resolves the carrier from the service name
938
+ 3. Creates a synthetic rate for direct label purchase
939
+
940
+ Returns:
941
+ Tuple of (skip_rate_fetching, resolved_carrier_name, synthetic_rates)
942
+ """
943
+ has_alternative_services = options.get("has_alternative_services", False)
944
+ skip_rate_fetching = service is not None and has_alternative_services
945
+
946
+ if not skip_rate_fetching:
947
+ return False, None, []
948
+
949
+ # Resolve carrier from service when no explicit carrier_ids provided
950
+ resolved_carrier_name = None
951
+ if not any(carrier_ids):
952
+ resolved_carrier_name = utils._get_carrier_for_service(service, context=context)
953
+ if resolved_carrier_name is None:
954
+ raise exceptions.APIException(
955
+ f"Could not resolve carrier for service '{service}'",
956
+ code="validation_error",
957
+ status_code=status.HTTP_400_BAD_REQUEST,
958
+ )
959
+
960
+ if len(carriers) == 0:
961
+ return skip_rate_fetching, resolved_carrier_name, []
962
+
963
+ # Find carrier connection matching the service's carrier
964
+ carrier_name = resolved_carrier_name or utils._get_carrier_for_service(
965
+ service, context=context
966
+ )
967
+ carrier = lib.identity(
968
+ next(
969
+ (c for c in carriers if c.carrier_name == carrier_name),
970
+ carriers[0] if carrier_name is None else None,
971
+ )
972
+ )
973
+
974
+ if carrier is None:
975
+ raise exceptions.APIException(
976
+ f"No carrier connection found for service '{service}'",
977
+ code="validation_error",
978
+ status_code=status.HTTP_400_BAD_REQUEST,
979
+ )
980
+
981
+ # Create synthetic rate for direct label purchase
982
+ synthetic_rates = [
983
+ {
984
+ "id": f"rat_{uuid.uuid4().hex}",
985
+ "carrier_id": carrier.carrier_id,
986
+ "carrier_name": carrier.carrier_name,
987
+ "service": service,
988
+ "currency": options.get("currency") or "USD",
989
+ "total_charge": 0,
990
+ "meta": {
991
+ "carrier_connection_id": carrier.pk,
992
+ "has_alternative_services": True,
993
+ "rate_provider": carrier.carrier_name,
994
+ "service_name": service.upper().replace("_", " "),
995
+ },
996
+ "test_mode": context.test_mode,
997
+ }
998
+ ]
999
+
1000
+ return skip_rate_fetching, resolved_carrier_name, synthetic_rates
@@ -0,0 +1,88 @@
1
+ import json
2
+ from django.urls import reverse
3
+ from rest_framework import status
4
+ from karrio.server.core.tests import APITestCase
5
+
6
+
7
+ class TestNotFoundErrors(APITestCase):
8
+ def test_address_not_found_returns_resource_name(self):
9
+ url = reverse(
10
+ "karrio.server.manager:address-details",
11
+ kwargs=dict(pk="nonexistent_id"),
12
+ )
13
+ response = self.client.get(url)
14
+ response_data = json.loads(response.content)
15
+
16
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
17
+ self.assertDictEqual(
18
+ response_data,
19
+ {"errors": [{"code": "not_found", "message": "Address not found"}]},
20
+ )
21
+
22
+ def test_parcel_not_found_returns_resource_name(self):
23
+ url = reverse(
24
+ "karrio.server.manager:parcel-details",
25
+ kwargs=dict(pk="nonexistent_id"),
26
+ )
27
+ response = self.client.get(url)
28
+ response_data = json.loads(response.content)
29
+
30
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
31
+ self.assertDictEqual(
32
+ response_data,
33
+ {"errors": [{"code": "not_found", "message": "Parcel not found"}]},
34
+ )
35
+
36
+ def test_shipment_not_found_returns_resource_name(self):
37
+ url = reverse(
38
+ "karrio.server.manager:shipment-details",
39
+ kwargs=dict(pk="nonexistent_id"),
40
+ )
41
+ response = self.client.get(url)
42
+ response_data = json.loads(response.content)
43
+
44
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
45
+ self.assertDictEqual(
46
+ response_data,
47
+ {"errors": [{"code": "not_found", "message": "Shipment not found"}]},
48
+ )
49
+
50
+ def test_customs_not_found_returns_resource_name(self):
51
+ url = reverse(
52
+ "karrio.server.manager:customs-details",
53
+ kwargs=dict(pk="nonexistent_id"),
54
+ )
55
+ response = self.client.get(url)
56
+ response_data = json.loads(response.content)
57
+
58
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
59
+ self.assertDictEqual(
60
+ response_data,
61
+ {"errors": [{"code": "not_found", "message": "Customs not found"}]},
62
+ )
63
+
64
+
65
+ class TestValidationErrors(APITestCase):
66
+ def test_shipment_validation_error_format(self):
67
+ url = reverse("karrio.server.manager:shipment-list")
68
+ data = {
69
+ "shipper": {},
70
+ "recipient": {},
71
+ "parcels": [],
72
+ }
73
+ response = self.client.post(url, data, format="json")
74
+ response_data = json.loads(response.content)
75
+
76
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
77
+ self.assertIn("errors", response_data)
78
+ self.assertTrue(len(response_data["errors"]) > 0)
79
+
80
+ def test_address_validation_error_format(self):
81
+ url = reverse("karrio.server.manager:address-list")
82
+ data = {"country_code": "INVALID"}
83
+ response = self.client.post(url, data, format="json")
84
+ response_data = json.loads(response.content)
85
+
86
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
87
+ self.assertIn("errors", response_data)
88
+ self.assertTrue(len(response_data["errors"]) > 0)
@@ -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,
@@ -831,3 +1024,75 @@ CANCEL_PURCHASED_RESPONSE = {
831
1024
  "label_url": None,
832
1025
  "invoice_url": None,
833
1026
  }
1027
+
1028
+ SINGLE_CALL_LABEL_DATA = {
1029
+ "recipient": {
1030
+ "address_line1": "125 Church St",
1031
+ "person_name": "John Poop",
1032
+ "company_name": "A corp.",
1033
+ "phone_number": "514 000 0000",
1034
+ "city": "Moncton",
1035
+ "country_code": "CA",
1036
+ "postal_code": "E1C4Z8",
1037
+ "residential": False,
1038
+ "state_code": "NB",
1039
+ },
1040
+ "shipper": {
1041
+ "address_line1": "5840 Oak St",
1042
+ "person_name": "Jane Doe",
1043
+ "company_name": "B corp.",
1044
+ "phone_number": "514 000 9999",
1045
+ "city": "Vancouver",
1046
+ "country_code": "CA",
1047
+ "postal_code": "V6M2V9",
1048
+ "residential": False,
1049
+ "state_code": "BC",
1050
+ },
1051
+ "parcels": [
1052
+ {
1053
+ "weight": 1,
1054
+ "weight_unit": "KG",
1055
+ "package_preset": "canadapost_corrugated_small_box",
1056
+ }
1057
+ ],
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": [
1089
+ {
1090
+ "weight": 1,
1091
+ "weight_unit": "KG",
1092
+ "package_preset": "canadapost_corrugated_small_box",
1093
+ }
1094
+ ],
1095
+ "payment": {"currency": "CAD", "paid_by": "sender"},
1096
+ "service": "canadapost_priority",
1097
+ "options": {"has_alternative_services": True},
1098
+ }
@@ -10,7 +10,7 @@ import rest_framework.pagination as pagination
10
10
  import rest_framework.throttling as throttling
11
11
  import django_filters.rest_framework as django_filters
12
12
 
13
- from karrio.server.core.logging import logger
13
+ from karrio.server.core.utils import validate_resource_token
14
14
  import karrio.server.openapi as openapi
15
15
  import karrio.server.core.views.api as api
16
16
  import karrio.server.core.filters as filters
@@ -107,26 +107,24 @@ class ManifestDetails(api.APIView):
107
107
 
108
108
  class ManifestDoc(django_downloadview.VirtualDownloadView):
109
109
  @openapi.extend_schema(exclude=True)
110
- def get(
111
- self,
112
- request: request.Request,
113
- pk: str,
114
- doc: str = "manifest",
115
- format: str = "pdf",
116
- **kwargs,
117
- ):
110
+ def get(self, req: request.Request, pk: str, doc: str = "manifest", format: str = "pdf", **kwargs):
118
111
  """Retrieve a manifest file."""
112
+ error = validate_resource_token(req, "manifest", [pk], "manifest")
113
+ if error:
114
+ return error
115
+
116
+ query_params = req.GET.dict()
117
+
119
118
  self.manifest = models.Manifest.objects.get(pk=pk, manifest__isnull=False)
120
119
  self.document = getattr(self.manifest, doc, None)
121
120
  self.name = f"{doc}_{self.manifest.id}.{format}"
122
121
 
123
- query_params = request.GET.dict()
124
122
  self.preview = "preview" in query_params
125
123
  self.attachment = "download" in query_params
126
124
 
127
- response = super(ManifestDoc, self).get(request, pk, doc, format, **kwargs)
128
- response["X-Frame-Options"] = "ALLOWALL"
129
- return response
125
+ resp = super(ManifestDoc, self).get(req, pk, doc, format, **kwargs)
126
+ resp["X-Frame-Options"] = "ALLOWALL"
127
+ return resp
130
128
 
131
129
  def get_file(self):
132
130
  content = base64.b64decode(self.document or "")
@@ -14,7 +14,6 @@ import karrio.lib as lib
14
14
  import karrio.server.openapi as openapi
15
15
  import karrio.server.core.filters as filters
16
16
  import karrio.server.manager.models as models
17
- from karrio.server.core.logging import logger
18
17
  from karrio.server.core.views.api import GenericAPIView, APIView
19
18
  from karrio.server.core.filters import ShipmentFilters
20
19
  from karrio.server.manager.router import router
@@ -267,12 +266,19 @@ class ShipmentDocs(VirtualDownloadView):
267
266
  format: str = "pdf",
268
267
  **kwargs,
269
268
  ):
270
- """Retrieve a shipment label."""
269
+ """Retrieve a shipment label or invoice."""
270
+ from karrio.server.core.utils import validate_resource_token
271
+
272
+ error = validate_resource_token(request, "shipment", [pk], doc)
273
+ if error:
274
+ return error
275
+
276
+ query_params = request.GET.dict()
277
+
271
278
  self.shipment = models.Shipment.objects.get(pk=pk, label__isnull=False)
272
279
  self.document = getattr(self.shipment, doc, None)
273
280
  self.name = f"{doc}_{self.shipment.tracking_number}.{format}"
274
281
 
275
- query_params = request.GET.dict()
276
282
  self.preview = "preview" in query_params
277
283
  self.attachment = "download" in query_params
278
284
 
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karrio_server_manager
3
- Version: 2025.5rc36
3
+ Version: 2025.5.2
4
4
  Summary: Multi-carrier shipping API Shipments manager module
5
5
  Author-email: karrio <hello@karrio.io>
6
- License-Expression: Apache-2.0
6
+ License-Expression: LGPL-3.0
7
7
  Project-URL: Homepage, https://github.com/karrioapi/karrio
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Requires-Python: >=3.11
@@ -82,25 +82,26 @@ 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=G4vA51UFHvBVNNR9z3Vtac-Y7GexjGadPOCRxSUVqRE,30355
85
+ karrio/server/manager/serializers/shipment.py,sha256=N4mld4eIM1HQ6NdsQ7gDt73aDv4j0wHM4AAMfgChnMc,34919
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
91
  karrio/server/manager/tests/test_parcels.py,sha256=lVLBOsHzXgXQvYjHIUy5oiPvrMfxYpueVvvhtuhstWk,2559
91
92
  karrio/server/manager/tests/test_pickups.py,sha256=8jxddwTnBvBM9FOyWxW9TtZ-GOVYUje7HQ2EZjsbtD8,10681
92
- karrio/server/manager/tests/test_shipments.py,sha256=9QlsvAZK0owvDwK8lVAdVVUYAtFOkr7cIKoU7W-wRls,26946
93
+ karrio/server/manager/tests/test_shipments.py,sha256=LBblskbeJyUvWtJdy5hZPvumuOT2PE__ikIK3YlvcnY,35914
93
94
  karrio/server/manager/tests/test_trackers.py,sha256=KvmWkplokNDZ0dzB16mFl0WcMJ0OYp_ErZeWJPGW_NA,7151
94
95
  karrio/server/manager/views/__init__.py,sha256=kDFUaORRQ3Xh0ZPm-Jk88Ss8dgGYM57iUFXb9TPMzh0,401
95
96
  karrio/server/manager/views/addresses.py,sha256=7YCAs2ZYgd1icYwMcGGWfX7A7vZEL4BEAbU4eIxhiMY,4620
96
97
  karrio/server/manager/views/customs.py,sha256=-ZreiKyJ1xeLeNVG53nMfRQFeURduWr1QkDItdLPnE8,4875
97
98
  karrio/server/manager/views/documents.py,sha256=znW54qJ_k7WInIut5FBZFDT93CioozXTOYFKRSUTBhA,4005
98
- karrio/server/manager/views/manifests.py,sha256=4VyeiFaeKkWRvKmZZcfw7qjHynsR03t4M_fwwiZklZQ,5171
99
+ karrio/server/manager/views/manifests.py,sha256=_Dd83YxVJOgWhAhD745Kr4tcLWnKaU1dxnT5xB8opvk,5227
99
100
  karrio/server/manager/views/parcels.py,sha256=hZY45rg6SrTWfQqyJ38MGKSor1yqgPUEVHtu16aG37g,4594
100
101
  karrio/server/manager/views/pickups.py,sha256=gmpxz9ot1OR-BP1qh-0MXU3kUJi1ht_74hfaLJzJ42w,5503
101
- karrio/server/manager/views/shipments.py,sha256=_5EJfgxEO6H2EdQQbaSwILgnim7slqxMKDkEk_97F9c,10267
102
+ karrio/server/manager/views/shipments.py,sha256=TqLpBH5Jf-rI3enJwvNptRwGzfo7co9R1VSP_oqhB3o,10419
102
103
  karrio/server/manager/views/trackers.py,sha256=3oGn2qDpHgk8GZvuz-Cb93Fc0j_h_HbXQR692Zhfiok,12363
103
- karrio_server_manager-2025.5rc36.dist-info/METADATA,sha256=gqVdjXCp3F4TIfvQWnxEJ76Js6deZo0n6BRct9pJC9I,734
104
- karrio_server_manager-2025.5rc36.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
105
- karrio_server_manager-2025.5rc36.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
106
- karrio_server_manager-2025.5rc36.dist-info/RECORD,,
104
+ karrio_server_manager-2025.5.2.dist-info/METADATA,sha256=WZxA5Br3FwwK7PO0Au-FKvQPHFPUcXevyuoaK596rWQ,730
105
+ karrio_server_manager-2025.5.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
106
+ karrio_server_manager-2025.5.2.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
107
+ karrio_server_manager-2025.5.2.dist-info/RECORD,,