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.
- karrio/server/manager/migrations/0067_rename_customs_commodities_table.py +56 -0
- karrio/server/manager/serializers/__init__.py +1 -0
- karrio/server/manager/serializers/shipment.py +73 -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 +112 -0
- karrio/server/manager/tests/test_trackers.py +2 -0
- karrio/server/manager/views/manifests.py +58 -2
- karrio/server/manager/views/shipments.py +89 -4
- {karrio_server_manager-2025.5.2.dist-info → karrio_server_manager-2025.5.4.dist-info}/METADATA +1 -1
- {karrio_server_manager-2025.5.2.dist-info → karrio_server_manager-2025.5.4.dist-info}/RECORD +13 -11
- {karrio_server_manager-2025.5.2.dist-info → karrio_server_manager-2025.5.4.dist-info}/WHEEL +0 -0
- {karrio_server_manager-2025.5.2.dist-info → karrio_server_manager-2025.5.4.dist-info}/top_level.txt +0 -0
|
@@ -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
|
+
]
|
|
@@ -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
|
-
**
|
|
710
|
+
**response_details,
|
|
684
711
|
**extra,
|
|
685
|
-
|
|
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.
|
|
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,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(
|
|
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(
|
|
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.
|
|
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]+)
|
|
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
|
)
|
{karrio_server_manager-2025.5.2.dist-info → karrio_server_manager-2025.5.4.dist-info}/RECORD
RENAMED
|
@@ -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=
|
|
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=
|
|
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=
|
|
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=
|
|
94
|
-
karrio/server/manager/tests/test_trackers.py,sha256=
|
|
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=
|
|
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=
|
|
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.
|
|
105
|
-
karrio_server_manager-2025.5.
|
|
106
|
-
karrio_server_manager-2025.5.
|
|
107
|
-
karrio_server_manager-2025.5.
|
|
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,,
|
|
File without changes
|
{karrio_server_manager-2025.5.2.dist-info → karrio_server_manager-2025.5.4.dist-info}/top_level.txt
RENAMED
|
File without changes
|