karrio-server-manager 2025.5.2__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.
- karrio/server/manager/serializers/shipment.py +58 -6
- karrio/server/manager/tests/test_errors.py +10 -4
- karrio/server/manager/tests/test_manifests.py +83 -0
- karrio/server/manager/tests/test_shipments.py +101 -0
- karrio/server/manager/views/manifests.py +58 -2
- karrio/server/manager/views/shipments.py +82 -2
- {karrio_server_manager-2025.5.2.dist-info → karrio_server_manager-2025.5.3.dist-info}/METADATA +1 -1
- {karrio_server_manager-2025.5.2.dist-info → karrio_server_manager-2025.5.3.dist-info}/RECORD +10 -9
- {karrio_server_manager-2025.5.2.dist-info → karrio_server_manager-2025.5.3.dist-info}/WHEEL +0 -0
- {karrio_server_manager-2025.5.2.dist-info → karrio_server_manager-2025.5.3.dist-info}/top_level.txt +0 -0
|
@@ -131,11 +131,16 @@ class ShipmentSerializer(ShipmentData):
|
|
|
131
131
|
self, validated_data: dict, context: Context, **kwargs
|
|
132
132
|
) -> models.Shipment:
|
|
133
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
|
+
|
|
134
140
|
service = validated_data.get("service")
|
|
135
141
|
carrier_ids = validated_data.get("carrier_ids") or []
|
|
136
142
|
fetch_rates = validated_data.get("fetch_rates") is not False
|
|
137
143
|
services = [service] if service is not None else validated_data.get("services")
|
|
138
|
-
options = validated_data.get("options") or {}
|
|
139
144
|
|
|
140
145
|
# Check if we should skip rate fetching for has_alternative_services
|
|
141
146
|
skip_rate_fetching, resolved_carrier_name, _ = (
|
|
@@ -244,8 +249,8 @@ class ShipmentSerializer(ShipmentData):
|
|
|
244
249
|
context=context,
|
|
245
250
|
)
|
|
246
251
|
|
|
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:
|
|
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:
|
|
249
254
|
return buy_shipment_label(
|
|
250
255
|
shipment,
|
|
251
256
|
context=context,
|
|
@@ -673,16 +678,23 @@ def buy_shipment_label(
|
|
|
673
678
|
docs={**lib.to_dict(response.docs), **invoice},
|
|
674
679
|
)
|
|
675
680
|
|
|
676
|
-
# 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
|
+
|
|
677
689
|
purchased_shipment = lib.identity(
|
|
678
690
|
ShipmentSerializer.map(
|
|
679
691
|
shipment,
|
|
680
692
|
context=context,
|
|
681
693
|
data={
|
|
682
694
|
**payload,
|
|
683
|
-
**
|
|
695
|
+
**response_details,
|
|
684
696
|
**extra,
|
|
685
|
-
|
|
697
|
+
"meta": merged_meta,
|
|
686
698
|
},
|
|
687
699
|
)
|
|
688
700
|
.save()
|
|
@@ -998,3 +1010,43 @@ def resolve_alternative_service_carrier(
|
|
|
998
1010
|
]
|
|
999
1011
|
|
|
1000
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
|
+
}
|
|
@@ -1096,3 +1096,104 @@ SINGLE_CALL_SKIP_RATES_DATA = {
|
|
|
1096
1096
|
"service": "canadapost_priority",
|
|
1097
1097
|
"options": {"has_alternative_services": True},
|
|
1098
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,
|
|
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.
|
|
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]+)
|
|
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.
|
|
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]+)
|
|
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
|
)
|
{karrio_server_manager-2025.5.2.dist-info → karrio_server_manager-2025.5.3.dist-info}/RECORD
RENAMED
|
@@ -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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
105
|
-
karrio_server_manager-2025.5.
|
|
106
|
-
karrio_server_manager-2025.5.
|
|
107
|
-
karrio_server_manager-2025.5.
|
|
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,,
|
|
File without changes
|
{karrio_server_manager-2025.5.2.dist-info → karrio_server_manager-2025.5.3.dist-info}/top_level.txt
RENAMED
|
File without changes
|