karrio-server-manager 2025.5.2__py3-none-any.whl → 2025.5.4__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.
@@ -0,0 +1,56 @@
1
+ # Generated migration to fix ManyToMany junction table names
2
+ # The RenameField in migration 0010 didn't properly rename the ManyToMany junction tables
3
+ # This is a long-standing bug that's now being fixed
4
+
5
+ from django.db import migrations
6
+
7
+
8
+ # Mapping of old table names to new table names
9
+ TABLE_RENAMES = [
10
+ ('customs_shipment_commodities', 'customs_commodities'),
11
+ ('shipment_shipment_parcels', 'shipment_parcels'),
12
+ ]
13
+
14
+
15
+ def rename_junction_tables(_apps, schema_editor):
16
+ """
17
+ Rename ManyToMany junction tables from old names to new names.
18
+ Uses Django's introspection API for database-agnostic table detection.
19
+ """
20
+ connection = schema_editor.connection
21
+ existing_tables = connection.introspection.table_names()
22
+
23
+ for old_table, new_table in TABLE_RENAMES:
24
+ if old_table in existing_tables and new_table not in existing_tables:
25
+ schema_editor.execute(
26
+ schema_editor.sql_rename_table % {
27
+ "old_table": schema_editor.quote_name(old_table),
28
+ "new_table": schema_editor.quote_name(new_table),
29
+ }
30
+ )
31
+
32
+
33
+ def reverse_rename(_apps, schema_editor):
34
+ """Reverse the table renames."""
35
+ connection = schema_editor.connection
36
+ existing_tables = connection.introspection.table_names()
37
+
38
+ for old_table, new_table in TABLE_RENAMES:
39
+ if new_table in existing_tables and old_table not in existing_tables:
40
+ schema_editor.execute(
41
+ schema_editor.sql_rename_table % {
42
+ "old_table": schema_editor.quote_name(new_table),
43
+ "new_table": schema_editor.quote_name(old_table),
44
+ }
45
+ )
46
+
47
+
48
+ class Migration(migrations.Migration):
49
+
50
+ dependencies = [
51
+ ('manager', '0066_commodity_image_url_commodity_product_id_and_more'),
52
+ ]
53
+
54
+ operations = [
55
+ migrations.RunPython(rename_junction_tables, reverse_rename),
56
+ ]
@@ -26,6 +26,7 @@ from karrio.server.manager.serializers.tracking import (
26
26
  )
27
27
  from karrio.server.manager.serializers.shipment import (
28
28
  ShipmentRateData,
29
+ PurchasedShipment,
29
30
  ShipmentSerializer,
30
31
  ShipmentUpdateData,
31
32
  ShipmentPurchaseData,
@@ -33,6 +33,7 @@ from karrio.server.core.serializers import (
33
33
  SHIPMENT_STATUS,
34
34
  LABEL_TYPES,
35
35
  ShipmentCancelRequest,
36
+ ShippingDocument,
36
37
  ShipmentDetails,
37
38
  ShipmentStatus,
38
39
  TrackerStatus,
@@ -131,11 +132,16 @@ class ShipmentSerializer(ShipmentData):
131
132
  self, validated_data: dict, context: Context, **kwargs
132
133
  ) -> models.Shipment:
133
134
  # fmt: off
135
+ # Apply shipping method if specified (HIGHEST PRIORITY - supersedes service)
136
+ apply_shipping_method_flag, validated_data = resolve_shipping_method(
137
+ validated_data, context
138
+ )
139
+ options = validated_data.get("options") or {}
140
+
134
141
  service = validated_data.get("service")
135
142
  carrier_ids = validated_data.get("carrier_ids") or []
136
143
  fetch_rates = validated_data.get("fetch_rates") is not False
137
144
  services = [service] if service is not None else validated_data.get("services")
138
- options = validated_data.get("options") or {}
139
145
 
140
146
  # Check if we should skip rate fetching for has_alternative_services
141
147
  skip_rate_fetching, resolved_carrier_name, _ = (
@@ -244,8 +250,8 @@ class ShipmentSerializer(ShipmentData):
244
250
  context=context,
245
251
  )
246
252
 
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:
253
+ # Buy label if preferred service is selected, shipping method applied, shipping rules applied, or skip rate fetching
254
+ if (service and fetch_rates) or apply_shipping_method_flag or apply_shipping_rules or skip_rate_fetching:
249
255
  return buy_shipment_label(
250
256
  shipment,
251
257
  context=context,
@@ -570,6 +576,16 @@ class ShipmentCancelSerializer(Shipment):
570
576
  return instance
571
577
 
572
578
 
579
+ @validators.shipment_documents_accessor(include_base64=True)
580
+ class PurchasedShipment(Shipment):
581
+ shipping_documents = ShippingDocument(
582
+ required=False,
583
+ many=True,
584
+ default=[],
585
+ help_text="The list of shipping documents",
586
+ )
587
+
588
+
573
589
  def fetch_shipment_rates(
574
590
  shipment: models.Shipment,
575
591
  context: typing.Any,
@@ -673,16 +689,27 @@ def buy_shipment_label(
673
689
  docs={**lib.to_dict(response.docs), **invoice},
674
690
  )
675
691
 
676
- # Update shipment state
692
+ # Update shipment state - preserve original meta and merge with response meta
693
+ response_details = ShipmentDetails(response).data
694
+ merged_meta = {
695
+ **(shipment.meta or {}),
696
+ **(response_details.get("meta") or {}),
697
+ **(
698
+ {"rule_activity": kwargs.get("rule_activity")}
699
+ if kwargs.get("rule_activity")
700
+ else {}
701
+ ),
702
+ }
703
+
677
704
  purchased_shipment = lib.identity(
678
705
  ShipmentSerializer.map(
679
706
  shipment,
680
707
  context=context,
681
708
  data={
682
709
  **payload,
683
- **ShipmentDetails(response).data,
710
+ **response_details,
684
711
  **extra,
685
- # "meta": {**(response.meta or {}), "rule_activity": kwargs.get("rule_activity", None)},
712
+ "meta": merged_meta,
686
713
  },
687
714
  )
688
715
  .save()
@@ -998,3 +1025,43 @@ def resolve_alternative_service_carrier(
998
1025
  ]
999
1026
 
1000
1027
  return skip_rate_fetching, resolved_carrier_name, synthetic_rates
1028
+
1029
+
1030
+ def resolve_shipping_method(
1031
+ validated_data: dict,
1032
+ context: Context,
1033
+ ) -> typing.Tuple[bool, dict]:
1034
+ """
1035
+ Resolve and apply shipping method configuration if specified.
1036
+
1037
+ When options.shipping_method is provided, this function:
1038
+ 1. Validates the SHIPPING_METHODS feature is enabled
1039
+ 2. Loads and applies the shipping method configuration
1040
+
1041
+ Returns:
1042
+ Tuple of (apply_shipping_method_flag, modified_validated_data)
1043
+ """
1044
+ if not getattr(conf.settings, "SHIPPING_METHODS", False):
1045
+ options = validated_data.get("options") or {}
1046
+ shipping_method_id = options.get("shipping_method")
1047
+
1048
+ if shipping_method_id is not None:
1049
+ raise exceptions.APIException(
1050
+ "Shipping methods feature is not enabled.",
1051
+ code="feature_disabled",
1052
+ status_code=status.HTTP_400_BAD_REQUEST,
1053
+ )
1054
+
1055
+ return False, validated_data
1056
+
1057
+ options = validated_data.get("options") or {}
1058
+ shipping_method_id = options.get("shipping_method")
1059
+
1060
+ if shipping_method_id is None:
1061
+ return False, validated_data
1062
+
1063
+ modified_data = utils.load_and_apply_shipping_method(
1064
+ validated_data, shipping_method_id, context
1065
+ )
1066
+
1067
+ 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
+ }
@@ -517,6 +517,7 @@ SHIPMENT_RESPONSE = {
517
517
  "created_at": ANY,
518
518
  "test_mode": True,
519
519
  "messages": [],
520
+ "shipping_documents": [],
520
521
  }
521
522
 
522
523
  SHIPMENT_OPTIONS = {
@@ -747,6 +748,14 @@ PURCHASED_SHIPMENT = {
747
748
  "test_mode": True,
748
749
  "label_url": ANY,
749
750
  "invoice_url": None,
751
+ "shipping_documents": [
752
+ {
753
+ "category": "label",
754
+ "format": "PDF",
755
+ "url": ANY,
756
+ "base64": "==apodifjoefr",
757
+ }
758
+ ],
750
759
  }
751
760
 
752
761
  CANCEL_RESPONSE = {
@@ -885,6 +894,7 @@ CANCEL_RESPONSE = {
885
894
  "test_mode": True,
886
895
  "label_url": None,
887
896
  "invoice_url": None,
897
+ "shipping_documents": [],
888
898
  }
889
899
 
890
900
  CANCEL_PURCHASED_RESPONSE = {
@@ -1023,6 +1033,7 @@ CANCEL_PURCHASED_RESPONSE = {
1023
1033
  "test_mode": True,
1024
1034
  "label_url": None,
1025
1035
  "invoice_url": None,
1036
+ "shipping_documents": [],
1026
1037
  }
1027
1038
 
1028
1039
  SINGLE_CALL_LABEL_DATA = {
@@ -1096,3 +1107,104 @@ SINGLE_CALL_SKIP_RATES_DATA = {
1096
1107
  "service": "canadapost_priority",
1097
1108
  "options": {"has_alternative_services": True},
1098
1109
  }
1110
+
1111
+
1112
+ class TestShipmentDocumentDownload(APITestCase):
1113
+ """Test shipment document download POST API."""
1114
+
1115
+ def create_purchased_shipment(self):
1116
+ """Create and purchase a shipment via API."""
1117
+ url = reverse("karrio.server.manager:shipment-list")
1118
+
1119
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
1120
+ mock.side_effect = [RETURNED_RATES_VALUE, CREATED_SHIPMENT_RESPONSE]
1121
+ response = self.client.post(url, SINGLE_CALL_LABEL_DATA)
1122
+ return json.loads(response.content)
1123
+
1124
+ def test_download_label_document(self):
1125
+ shipment = self.create_purchased_shipment()
1126
+
1127
+ url = reverse(
1128
+ "karrio.server.manager:shipment-document-download",
1129
+ kwargs=dict(pk=shipment["id"], doc="label"),
1130
+ )
1131
+ response = self.client.post(url)
1132
+ response_data = json.loads(response.content)
1133
+
1134
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
1135
+ self.assertDictEqual(
1136
+ response_data,
1137
+ LABEL_DOCUMENT_RESPONSE,
1138
+ )
1139
+
1140
+ def test_download_document_not_found(self):
1141
+ # Create a draft shipment (no label)
1142
+ url = reverse("karrio.server.manager:shipment-list")
1143
+
1144
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
1145
+ mock.return_value = RETURNED_RATES_VALUE
1146
+ response = self.client.post(url, SHIPMENT_DATA)
1147
+ shipment = json.loads(response.content)
1148
+
1149
+ url = reverse(
1150
+ "karrio.server.manager:shipment-document-download",
1151
+ kwargs=dict(pk=shipment["id"], doc="label"),
1152
+ )
1153
+ response = self.client.post(url)
1154
+
1155
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
1156
+
1157
+ def test_download_invalid_document_type(self):
1158
+ shipment = self.create_purchased_shipment()
1159
+
1160
+ url = reverse(
1161
+ "karrio.server.manager:shipment-document-download",
1162
+ kwargs=dict(pk=shipment["id"], doc="invalid"),
1163
+ )
1164
+ response = self.client.post(url)
1165
+
1166
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
1167
+
1168
+ def test_download_shipment_not_found(self):
1169
+ url = reverse(
1170
+ "karrio.server.manager:shipment-document-download",
1171
+ kwargs=dict(pk="shp_non_existent_id", doc="label"),
1172
+ )
1173
+ response = self.client.post(url)
1174
+
1175
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
1176
+
1177
+
1178
+ class TestShipmentCancelIdempotent(APITestCase):
1179
+ """Test shipment cancel idempotency."""
1180
+
1181
+ def test_cancel_already_cancelled_shipment_returns_202(self):
1182
+ # Create a shipment via API
1183
+ url = reverse("karrio.server.manager:shipment-list")
1184
+
1185
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
1186
+ mock.return_value = RETURNED_RATES_VALUE
1187
+ response = self.client.post(url, SHIPMENT_DATA)
1188
+ shipment = json.loads(response.content)
1189
+
1190
+ # Cancel the shipment first time
1191
+ cancel_url = reverse(
1192
+ "karrio.server.manager:shipment-cancel",
1193
+ kwargs=dict(pk=shipment["id"]),
1194
+ )
1195
+ self.client.post(cancel_url)
1196
+
1197
+ # Cancel again - should return 202
1198
+ response = self.client.post(cancel_url)
1199
+ response_data = json.loads(response.content)
1200
+
1201
+ self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
1202
+ self.assertEqual(response_data["status"], "cancelled")
1203
+
1204
+
1205
+ LABEL_DOCUMENT_RESPONSE = {
1206
+ "category": "label",
1207
+ "format": "PDF",
1208
+ "base64": "==apodifjoefr",
1209
+ "url": ANY,
1210
+ }
@@ -129,6 +129,7 @@ TRACKING_RESPONSE = {
129
129
  "time": "10:39",
130
130
  "latitude": None,
131
131
  "longitude": None,
132
+ "reason": None,
132
133
  }
133
134
  ],
134
135
  "messages": [],
@@ -180,6 +181,7 @@ UPDATE_TRACKING_RESPONSE = {
180
181
  "location": "BONN",
181
182
  "longitude": None,
182
183
  "time": "20:34",
184
+ "reason": None,
183
185
  }
184
186
  ],
185
187
  "delivered": False,
@@ -17,6 +17,7 @@ import karrio.server.core.filters as filters
17
17
  import karrio.server.manager.models as models
18
18
  import karrio.server.manager.router as router
19
19
  import karrio.server.manager.serializers as serializers
20
+ from karrio.server.core.serializers import ShippingDocument
20
21
 
21
22
  ENDPOINT_ID = "$$$$&&" # This endpoint id is used to make operation ids unique make sure not to duplicate
22
23
  Manifests = serializers.PaginatedResult("ManifestList", serializers.Manifest)
@@ -115,7 +116,14 @@ class ManifestDoc(django_downloadview.VirtualDownloadView):
115
116
 
116
117
  query_params = req.GET.dict()
117
118
 
118
- self.manifest = models.Manifest.objects.get(pk=pk, manifest__isnull=False)
119
+ self.manifest = models.Manifest.objects.filter(pk=pk, manifest__isnull=False).first()
120
+
121
+ if self.manifest is None:
122
+ return response.Response(
123
+ {"errors": [{"message": f"Manifest '{pk}' not found or has no document"}]},
124
+ status=status.HTTP_404_NOT_FOUND,
125
+ )
126
+
119
127
  self.document = getattr(self.manifest, doc, None)
120
128
  self.name = f"{doc}_{self.manifest.id}.{format}"
121
129
 
@@ -134,6 +142,47 @@ class ManifestDoc(django_downloadview.VirtualDownloadView):
134
142
  return base.ContentFile(buffer.getvalue(), name=self.name)
135
143
 
136
144
 
145
+ class ManifestDocumentDownload(api.APIView):
146
+
147
+ @openapi.extend_schema(
148
+ tags=["Manifests"],
149
+ operation_id=f"{ENDPOINT_ID}document",
150
+ extensions={"x-operationId": "retrieveManifestDocument"},
151
+ summary="Retrieve a manifest document",
152
+ request=None,
153
+ responses={
154
+ 200: ShippingDocument(),
155
+ 404: serializers.ErrorResponse(),
156
+ 500: serializers.ErrorResponse(),
157
+ },
158
+ )
159
+ def post(self, req: request.Request, pk: str):
160
+ """
161
+ Retrieve a manifest document as base64 encoded content.
162
+ """
163
+ manifest = models.Manifest.access_by(req).filter(pk=pk, manifest__isnull=False).first()
164
+
165
+ if manifest is None:
166
+ return response.Response(
167
+ {"errors": [{"message": f"Manifest '{pk}' not found or has no document"}]},
168
+ status=status.HTTP_404_NOT_FOUND,
169
+ )
170
+
171
+ # Build the GET URL for the document
172
+ doc_url = f"/v1/manifests/{pk}/manifest.pdf"
173
+
174
+ return response.Response(
175
+ ShippingDocument(
176
+ {
177
+ "category": "manifest",
178
+ "format": "PDF",
179
+ "base64": manifest.manifest,
180
+ "url": doc_url,
181
+ }
182
+ ).data
183
+ )
184
+
185
+
137
186
  router.router.urls.append(
138
187
  urls.path(
139
188
  "manifests",
@@ -148,9 +197,16 @@ router.router.urls.append(
148
197
  name="manifest-details",
149
198
  )
150
199
  )
200
+ router.router.urls.append(
201
+ urls.path(
202
+ "manifests/<str:pk>/document",
203
+ ManifestDocumentDownload.as_view(),
204
+ name="manifest-document-download",
205
+ )
206
+ )
151
207
  router.router.urls.append(
152
208
  urls.re_path(
153
- r"^manifests/(?P<pk>\w+)/(?P<doc>[a-z0-9]+).(?P<format>[a-z0-9]+)",
209
+ r"^manifests/(?P<pk>\w+)/(?P<doc>[a-z0-9]+)\.(?P<format>[a-z0-9]+)",
154
210
  ManifestDoc.as_view(),
155
211
  name="manifest-docs",
156
212
  )
@@ -25,6 +25,7 @@ from karrio.server.manager.serializers import (
25
25
  ErrorMessages,
26
26
  Shipment,
27
27
  ShipmentData,
28
+ ShipmentStatus,
28
29
  buy_shipment_label,
29
30
  can_mutate_shipment,
30
31
  ShipmentSerializer,
@@ -32,6 +33,8 @@ from karrio.server.manager.serializers import (
32
33
  ShipmentUpdateData,
33
34
  ShipmentPurchaseData,
34
35
  ShipmentCancelSerializer,
36
+ ShippingDocument,
37
+ PurchasedShipment,
35
38
  )
36
39
 
37
40
  ENDPOINT_ID = "$$$$$" # This endpoint id is used to make operation ids unique make sure not to duplicate
@@ -90,7 +93,9 @@ class ShipmentList(GenericAPIView):
90
93
  ShipmentSerializer.map(data=request.data, context=request).save().instance
91
94
  )
92
95
 
93
- return Response(Shipment(shipment).data, status=status.HTTP_201_CREATED)
96
+ return Response(
97
+ PurchasedShipment(shipment).data, status=status.HTTP_201_CREATED
98
+ )
94
99
 
95
100
 
96
101
  class ShipmentDetails(APIView):
@@ -165,6 +170,7 @@ class ShipmentCancel(APIView):
165
170
  request=None,
166
171
  responses={
167
172
  200: Shipment(),
173
+ 304: Shipment(),
168
174
  404: ErrorResponse(),
169
175
  400: ErrorResponse(),
170
176
  409: ErrorResponse(),
@@ -177,6 +183,11 @@ class ShipmentCancel(APIView):
177
183
  Void a shipment with the associated label.
178
184
  """
179
185
  shipment = models.Shipment.access_by(request).get(pk=pk)
186
+
187
+ # Return 202 if already cancelled (idempotent)
188
+ if shipment.status == ShipmentStatus.cancelled.value:
189
+ return Response(Shipment(shipment).data, status=status.HTTP_202_ACCEPTED)
190
+
180
191
  can_mutate_shipment(shipment, delete=True)
181
192
 
182
193
  update = ShipmentCancelSerializer.map(shipment, context=request).save().instance
@@ -253,7 +264,7 @@ class ShipmentPurchase(APIView):
253
264
  data=process_dictionaries_mutations(["metadata"], payload, shipment),
254
265
  )
255
266
 
256
- return Response(Shipment(update).data)
267
+ return Response(PurchasedShipment(update).data)
257
268
 
258
269
 
259
270
  class ShipmentDocs(VirtualDownloadView):
@@ -275,7 +286,16 @@ class ShipmentDocs(VirtualDownloadView):
275
286
 
276
287
  query_params = request.GET.dict()
277
288
 
278
- self.shipment = models.Shipment.objects.get(pk=pk, label__isnull=False)
289
+ self.shipment = models.Shipment.objects.filter(
290
+ pk=pk, label__isnull=False
291
+ ).first()
292
+
293
+ if self.shipment is None:
294
+ return Response(
295
+ {"errors": [{"message": f"Shipment '{pk}' not found or has no label"}]},
296
+ status=status.HTTP_404_NOT_FOUND,
297
+ )
298
+
279
299
  self.document = getattr(self.shipment, doc, None)
280
300
  self.name = f"{doc}_{self.shipment.tracking_number}.{format}"
281
301
 
@@ -314,6 +334,64 @@ class ShipmentDocs(VirtualDownloadView):
314
334
  return ContentFile(buffer.getvalue(), name=self.name)
315
335
 
316
336
 
337
+ class ShipmentDocumentDownload(APIView):
338
+ throttle_scope = "carrier_request"
339
+
340
+ @openapi.extend_schema(
341
+ tags=["Shipments"],
342
+ operation_id=f"{ENDPOINT_ID}document",
343
+ extensions={"x-operationId": "retrieveShipmentDocument"},
344
+ summary="Retrieve a shipment document",
345
+ request=None,
346
+ responses={
347
+ 200: ShippingDocument(),
348
+ 404: ErrorResponse(),
349
+ 500: ErrorResponse(),
350
+ },
351
+ )
352
+ def post(self, request: Request, pk: str, doc: str = "label"):
353
+ """
354
+ Retrieve a shipment document (label or invoice) as base64 encoded content.
355
+ """
356
+ if doc not in ["label", "invoice"]:
357
+ return Response(
358
+ {"errors": [{"message": f"Invalid document type: {doc}"}]},
359
+ status=status.HTTP_400_BAD_REQUEST,
360
+ )
361
+
362
+ # Build filter based on document type
363
+ filter_kwargs = {"pk": pk, f"{doc}__isnull": False}
364
+ shipment = models.Shipment.access_by(request).filter(**filter_kwargs).first()
365
+
366
+ if shipment is None:
367
+ return Response(
368
+ {"errors": [{"message": f"Shipment '{pk}' not found or has no {doc}"}]},
369
+ status=status.HTTP_404_NOT_FOUND,
370
+ )
371
+
372
+ document = getattr(shipment, doc)
373
+
374
+ # Determine format based on label_type for label, always PDF for invoice
375
+ if doc == "label":
376
+ doc_format = shipment.label_type or "PDF"
377
+ else:
378
+ doc_format = "PDF"
379
+
380
+ # Build the GET URL for the document
381
+ doc_url = f"/v1/shipments/{pk}/{doc}.{doc_format.lower()}"
382
+
383
+ return Response(
384
+ ShippingDocument(
385
+ {
386
+ "category": doc,
387
+ "format": doc_format,
388
+ "base64": document,
389
+ "url": doc_url,
390
+ }
391
+ ).data
392
+ )
393
+
394
+
317
395
  router.urls.append(path("shipments", ShipmentList.as_view(), name="shipment-list"))
318
396
  router.urls.append(
319
397
  path("shipments/<str:pk>", ShipmentDetails.as_view(), name="shipment-details")
@@ -331,9 +409,16 @@ router.urls.append(
331
409
  name="shipment-purchase",
332
410
  )
333
411
  )
412
+ router.urls.append(
413
+ path(
414
+ "shipments/<str:pk>/documents/<str:doc>",
415
+ ShipmentDocumentDownload.as_view(),
416
+ name="shipment-document-download",
417
+ )
418
+ )
334
419
  router.urls.append(
335
420
  re_path(
336
- r"^shipments/(?P<pk>\w+)/(?P<doc>[a-z0-9]+).(?P<format>[a-z0-9]+)",
421
+ r"^shipments/(?P<pk>\w+)/(?P<doc>[a-z0-9]+)\.(?P<format>[a-z0-9]+)",
337
422
  ShipmentDocs.as_view(),
338
423
  name="shipment-docs",
339
424
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karrio_server_manager
3
- Version: 2025.5.2
3
+ Version: 2025.5.4
4
4
  Summary: Multi-carrier shipping API Shipments manager module
5
5
  Author-email: karrio <hello@karrio.io>
6
6
  License-Expression: LGPL-3.0
@@ -72,8 +72,9 @@ karrio/server/manager/migrations/0063_alter_commodity_value_currency.py,sha256=8
72
72
  karrio/server/manager/migrations/0064_shipment_shipment_created_at_idx_and_more.py,sha256=EzCPxG54nk3PkT7hlS-W3oJxie6OlfMZu6Jh6tCJthQ,728
73
73
  karrio/server/manager/migrations/0065_alter_address_city_alter_address_company_name_and_more.py,sha256=m0KQSz8cnJzaXUlaAKdqU0duZ8wSQeTYQCIeam3Wivw,6279
74
74
  karrio/server/manager/migrations/0066_commodity_image_url_commodity_product_id_and_more.py,sha256=Jw7bEBmqzDSp6zw0xcAiKc9aDCsa3Wrw-oTM8r9Pqfc,997
75
+ karrio/server/manager/migrations/0067_rename_customs_commodities_table.py,sha256=WPACK9Ab5QUdcX9yxBkV6_MXTAkB9F8vETDOlVqOrT4,1933
75
76
  karrio/server/manager/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
76
- karrio/server/manager/serializers/__init__.py,sha256=YVwGfjpb-_PezzGc8N8oLTkORan0hZ2muGj2PYG3n0g,1431
77
+ karrio/server/manager/serializers/__init__.py,sha256=FyL9pqzoV7umSJut5P2KzqPFxn1ZWu07Qx7I58H2AQc,1454
77
78
  karrio/server/manager/serializers/address.py,sha256=yGKvZiNukeI15LEDdbo1ycqqK8QW77ak_vyLMIKyglI,2779
78
79
  karrio/server/manager/serializers/commodity.py,sha256=PKrW-xAYkiNByk5RF8up_Bt1Z8mJV_BGW0mPWT9w4ME,1658
79
80
  karrio/server/manager/serializers/customs.py,sha256=gMGC4TJMykjgELBLFHL6v7kPQ5YQKe7cQcMnOGBLL84,2872
@@ -82,26 +83,27 @@ karrio/server/manager/serializers/manifest.py,sha256=mSneCk_7HMXpi64_7hggWvkR7Ma
82
83
  karrio/server/manager/serializers/parcel.py,sha256=733Bg26lVbEkoWtAVM5Qt2IRBS2QDuVxhG40Hiqh3bw,2621
83
84
  karrio/server/manager/serializers/pickup.py,sha256=sX0VmcQxGkXn3IEosMuFwdXh4HhdkPcuBOp79O8PoDQ,9233
84
85
  karrio/server/manager/serializers/rate.py,sha256=7vYK_v8iWEDnswqYHG2Lir16_UhHTOxW5rdC6lw3lzA,652
85
- karrio/server/manager/serializers/shipment.py,sha256=N4mld4eIM1HQ6NdsQ7gDt73aDv4j0wHM4AAMfgChnMc,34919
86
+ karrio/server/manager/serializers/shipment.py,sha256=ByVrnexMI_fgSGBWO95qOPQn65Xzcfb8Vna9nF3k7Bk,37004
86
87
  karrio/server/manager/serializers/tracking.py,sha256=ixrAjIiZQsvSt4y0qtisGkt6TFOJ3ORNkJAQVt6YQrA,12483
87
88
  karrio/server/manager/tests/__init__.py,sha256=Y1UNteEE60vWdUAkjbldu_r_-h4u0He8-UoiBgTjKcU,391
88
89
  karrio/server/manager/tests/test_addresses.py,sha256=pNkZC_yJyb29ZlEOtOAs4blcEYiOarw0zhZIZC5uj1w,3111
89
90
  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
91
+ karrio/server/manager/tests/test_errors.py,sha256=x2-mSsXknHkE4V7TajEu8d3rpqV38T_xyAaYJU7xcGQ,3616
92
+ karrio/server/manager/tests/test_manifests.py,sha256=X35ZTXTFEM4Gxdjz598yiNNkOOKZGpILjHWRC0oM5U4,2764
91
93
  karrio/server/manager/tests/test_parcels.py,sha256=lVLBOsHzXgXQvYjHIUy5oiPvrMfxYpueVvvhtuhstWk,2559
92
94
  karrio/server/manager/tests/test_pickups.py,sha256=8jxddwTnBvBM9FOyWxW9TtZ-GOVYUje7HQ2EZjsbtD8,10681
93
- karrio/server/manager/tests/test_shipments.py,sha256=LBblskbeJyUvWtJdy5hZPvumuOT2PE__ikIK3YlvcnY,35914
94
- karrio/server/manager/tests/test_trackers.py,sha256=KvmWkplokNDZ0dzB16mFl0WcMJ0OYp_ErZeWJPGW_NA,7151
95
+ karrio/server/manager/tests/test_shipments.py,sha256=I9DIx4l7zrHZ_vEvAKgdrnIx_euZtreSj9W40fouqSU,39725
96
+ karrio/server/manager/tests/test_trackers.py,sha256=VIGT8OVH51mf_6F2HEr5C-kYvsMnhUods_rpq543RbI,7207
95
97
  karrio/server/manager/views/__init__.py,sha256=kDFUaORRQ3Xh0ZPm-Jk88Ss8dgGYM57iUFXb9TPMzh0,401
96
98
  karrio/server/manager/views/addresses.py,sha256=7YCAs2ZYgd1icYwMcGGWfX7A7vZEL4BEAbU4eIxhiMY,4620
97
99
  karrio/server/manager/views/customs.py,sha256=-ZreiKyJ1xeLeNVG53nMfRQFeURduWr1QkDItdLPnE8,4875
98
100
  karrio/server/manager/views/documents.py,sha256=znW54qJ_k7WInIut5FBZFDT93CioozXTOYFKRSUTBhA,4005
99
- karrio/server/manager/views/manifests.py,sha256=_Dd83YxVJOgWhAhD745Kr4tcLWnKaU1dxnT5xB8opvk,5227
101
+ karrio/server/manager/views/manifests.py,sha256=bk-8XoGLVqgjDfpTZbTKjXW7r8DYNDp2ce2xGG73sbI,7012
100
102
  karrio/server/manager/views/parcels.py,sha256=hZY45rg6SrTWfQqyJ38MGKSor1yqgPUEVHtu16aG37g,4594
101
103
  karrio/server/manager/views/pickups.py,sha256=gmpxz9ot1OR-BP1qh-0MXU3kUJi1ht_74hfaLJzJ42w,5503
102
- karrio/server/manager/views/shipments.py,sha256=TqLpBH5Jf-rI3enJwvNptRwGzfo7co9R1VSP_oqhB3o,10419
104
+ karrio/server/manager/views/shipments.py,sha256=YOFcZy-ymn3YfIKtcfNjFO4R_tPocrmEqMkQUXSSDCM,13083
103
105
  karrio/server/manager/views/trackers.py,sha256=3oGn2qDpHgk8GZvuz-Cb93Fc0j_h_HbXQR692Zhfiok,12363
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,,
106
+ karrio_server_manager-2025.5.4.dist-info/METADATA,sha256=sITh9mqaLlAGP7QXz6JttpFr4SBLLW1k02wHiIw-Q5k,730
107
+ karrio_server_manager-2025.5.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
108
+ karrio_server_manager-2025.5.4.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
109
+ karrio_server_manager-2025.5.4.dist-info/RECORD,,