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.
- karrio/server/manager/serializers/shipment.py +151 -15
- karrio/server/manager/tests/test_errors.py +88 -0
- karrio/server/manager/tests/test_shipments.py +276 -11
- karrio/server/manager/views/manifests.py +11 -13
- karrio/server/manager/views/shipments.py +9 -3
- {karrio_server_manager-2025.5rc36.dist-info → karrio_server_manager-2025.5.2.dist-info}/METADATA +2 -2
- {karrio_server_manager-2025.5rc36.dist-info → karrio_server_manager-2025.5.2.dist-info}/RECORD +9 -8
- {karrio_server_manager-2025.5rc36.dist-info → karrio_server_manager-2025.5.2.dist-info}/WHEEL +0 -0
- {karrio_server_manager-2025.5rc36.dist-info → karrio_server_manager-2025.5.2.dist-info}/top_level.txt +0 -0
|
@@ -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
|
-
**({"
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
{
|
|
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.
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
return
|
|
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
|
|
{karrio_server_manager-2025.5rc36.dist-info → karrio_server_manager-2025.5.2.dist-info}/METADATA
RENAMED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: karrio_server_manager
|
|
3
|
-
Version: 2025.
|
|
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:
|
|
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
|
{karrio_server_manager-2025.5rc36.dist-info → karrio_server_manager-2025.5.2.dist-info}/RECORD
RENAMED
|
@@ -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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
104
|
-
karrio_server_manager-2025.
|
|
105
|
-
karrio_server_manager-2025.
|
|
106
|
-
karrio_server_manager-2025.
|
|
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,,
|
{karrio_server_manager-2025.5rc36.dist-info → karrio_server_manager-2025.5.2.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|