karrio-server-manager 2025.5rc1__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/__init__.py +1 -0
- karrio/server/manager/admin.py +1 -0
- karrio/server/manager/apps.py +13 -0
- karrio/server/manager/migrations/0001_initial.py +1358 -0
- karrio/server/manager/migrations/0002_auto_20201127_0721.py +61 -0
- karrio/server/manager/migrations/0003_auto_20201230_0820.py +34 -0
- karrio/server/manager/migrations/0004_auto_20210125_2125.py +18 -0
- karrio/server/manager/migrations/0005_auto_20210216_0758.py +27 -0
- karrio/server/manager/migrations/0006_auto_20210307_0438.py +24 -0
- karrio/server/manager/migrations/0006_auto_20210308_0302.py +53 -0
- karrio/server/manager/migrations/0007_merge_20210311_1428.py +14 -0
- karrio/server/manager/migrations/0008_remove_shipment_doc_images.py +17 -0
- karrio/server/manager/migrations/0009_auto_20210326_1425.py +28 -0
- karrio/server/manager/migrations/0010_auto_20210403_1404.py +28 -0
- karrio/server/manager/migrations/0011_auto_20210426_1924.py +48 -0
- karrio/server/manager/migrations/0012_auto_20210427_1319.py +24 -0
- karrio/server/manager/migrations/0013_customs_invoice_date.py +18 -0
- karrio/server/manager/migrations/0014_auto_20210515_0928.py +24 -0
- karrio/server/manager/migrations/0015_auto_20210601_0340.py +182 -0
- karrio/server/manager/migrations/0016_shipment_archived.py +18 -0
- karrio/server/manager/migrations/0017_auto_20210629_1650.py +22 -0
- karrio/server/manager/migrations/0018_auto_20210705_1049.py +23 -0
- karrio/server/manager/migrations/0019_auto_20210722_1131.py +43 -0
- karrio/server/manager/migrations/0020_tracking_messages.py +20 -0
- karrio/server/manager/migrations/0021_tracking_estimated_delivery.py +18 -0
- karrio/server/manager/migrations/0022_auto_20211122_2100.py +53 -0
- karrio/server/manager/migrations/0023_auto_20211227_2141.py +118 -0
- karrio/server/manager/migrations/0024_alter_parcel_items.py +18 -0
- karrio/server/manager/migrations/0025_auto_20220113_1158.py +25 -0
- karrio/server/manager/migrations/0026_parcel_reference_number.py +18 -0
- karrio/server/manager/migrations/0027_custom_migration_2021_1.py +47 -0
- karrio/server/manager/migrations/0028_auto_20220303_1153.py +39 -0
- karrio/server/manager/migrations/0029_auto_20220303_1249.py +55 -0
- karrio/server/manager/migrations/0030_alter_shipment_status.py +44 -0
- karrio/server/manager/migrations/0031_shipment_invoice.py +34 -0
- karrio/server/manager/migrations/0032_custom_migration_2022_3.py +26 -0
- karrio/server/manager/migrations/0033_auto_20220504_1335.py +57 -0
- karrio/server/manager/migrations/0034_commodity_hs_code.py +18 -0
- karrio/server/manager/migrations/0035_parcel_options.py +26 -0
- karrio/server/manager/migrations/0036_alter_tracking_shipment.py +24 -0
- karrio/server/manager/migrations/0037_auto_20220710_1350.py +28 -0
- karrio/server/manager/migrations/0038_alter_tracking_status.py +18 -0
- karrio/server/manager/migrations/0039_documentuploadrecord.py +43 -0
- karrio/server/manager/migrations/0040_parcel_freight_class.py +18 -0
- karrio/server/manager/migrations/0041_alter_commodity_options_alter_parcel_options.py +29 -0
- karrio/server/manager/migrations/0042_remove_shipment_shipment_tracking_number_idx_and_more.py +658 -0
- karrio/server/manager/migrations/0043_customs_duty_billing_address_and_more.py +62 -0
- karrio/server/manager/migrations/0044_address_address_line1_temp_and_more.py +326 -0
- karrio/server/manager/migrations/0045_alter_customs_duty_billing_address_and_more.py +45 -0
- karrio/server/manager/migrations/0046_auto_20230114_0930.py +78 -0
- karrio/server/manager/migrations/0047_remove_shipment_shipment_tracking_number_idx_and_more.py +595 -0
- karrio/server/manager/migrations/0048_commodity_title_alter_commodity_description_and_more.py +53 -0
- karrio/server/manager/migrations/0049_auto_20230318_0708.py +39 -0
- karrio/server/manager/migrations/0050_address_street_number_tracking_account_number_and_more.py +60 -0
- karrio/server/manager/migrations/0051_auto_20230330_0556.py +56 -0
- karrio/server/manager/migrations/0052_auto_20230520_0811.py +35 -0
- karrio/server/manager/migrations/0053_alter_commodity_weight_unit_alter_parcel_weight_unit.py +32 -0
- karrio/server/manager/migrations/0054_alter_address_company_name_alter_address_person_name.py +22 -0
- karrio/server/manager/migrations/0055_alter_tracking_status.py +32 -0
- karrio/server/manager/migrations/0056_tracking_delivery_image_tracking_signature_image.py +22 -0
- karrio/server/manager/migrations/0057_alter_customs_invoice_date.py +18 -0
- karrio/server/manager/migrations/0058_manifest_shipment_manifest.py +124 -0
- karrio/server/manager/migrations/0059_shipment_return_address.py +24 -0
- karrio/server/manager/migrations/0060_pickup_meta_alter_address_country_code_and_more.py +527 -0
- karrio/server/manager/migrations/0061_alter_customs_incoterm.py +37 -0
- karrio/server/manager/migrations/0062_alter_tracking_status.py +35 -0
- karrio/server/manager/migrations/__init__.py +0 -0
- karrio/server/manager/models.py +984 -0
- karrio/server/manager/router.py +3 -0
- karrio/server/manager/serializers/__init__.py +50 -0
- karrio/server/manager/serializers/address.py +82 -0
- karrio/server/manager/serializers/commodity.py +51 -0
- karrio/server/manager/serializers/customs.py +84 -0
- karrio/server/manager/serializers/document.py +113 -0
- karrio/server/manager/serializers/manifest.py +85 -0
- karrio/server/manager/serializers/parcel.py +84 -0
- karrio/server/manager/serializers/pickup.py +285 -0
- karrio/server/manager/serializers/rate.py +19 -0
- karrio/server/manager/serializers/shipment.py +869 -0
- karrio/server/manager/serializers/tracking.py +250 -0
- karrio/server/manager/signals.py +70 -0
- karrio/server/manager/tests/__init__.py +10 -0
- karrio/server/manager/tests/test_addresses.py +110 -0
- karrio/server/manager/tests/test_custom_infos.py +97 -0
- karrio/server/manager/tests/test_parcels.py +104 -0
- karrio/server/manager/tests/test_pickups.py +345 -0
- karrio/server/manager/tests/test_shipments.py +833 -0
- karrio/server/manager/tests/test_trackers.py +215 -0
- karrio/server/manager/urls.py +10 -0
- karrio/server/manager/views/__init__.py +9 -0
- karrio/server/manager/views/addresses.py +154 -0
- karrio/server/manager/views/customs.py +159 -0
- karrio/server/manager/views/documents.py +131 -0
- karrio/server/manager/views/manifests.py +160 -0
- karrio/server/manager/views/parcels.py +155 -0
- karrio/server/manager/views/pickups.py +182 -0
- karrio/server/manager/views/shipments.py +335 -0
- karrio/server/manager/views/trackers.py +364 -0
- karrio_server_manager-2025.5rc1.dist-info/METADATA +28 -0
- karrio_server_manager-2025.5rc1.dist-info/RECORD +102 -0
- karrio_server_manager-2025.5rc1.dist-info/WHEEL +5 -0
- karrio_server_manager-2025.5rc1.dist-info/top_level.txt +2 -0
@@ -0,0 +1,869 @@
|
|
1
|
+
import typing
|
2
|
+
import logging
|
3
|
+
import rest_framework.status as status
|
4
|
+
import django.db.transaction as transaction
|
5
|
+
from rest_framework.reverse import reverse
|
6
|
+
|
7
|
+
import karrio.lib as lib
|
8
|
+
import karrio.core.units as units
|
9
|
+
import karrio.server.conf as conf
|
10
|
+
import karrio.server.core.utils as utils
|
11
|
+
import karrio.server.core.gateway as gateway
|
12
|
+
|
13
|
+
import karrio.server.core.dataunits as dataunits
|
14
|
+
import karrio.server.core.datatypes as datatypes
|
15
|
+
import karrio.server.core.exceptions as exceptions
|
16
|
+
import karrio.server.providers.models as providers
|
17
|
+
import karrio.server.core.validators as validators
|
18
|
+
from karrio.server.serializers import (
|
19
|
+
Serializer,
|
20
|
+
CharField,
|
21
|
+
ChoiceField,
|
22
|
+
BooleanField,
|
23
|
+
owned_model_serializer,
|
24
|
+
save_one_to_one_data,
|
25
|
+
save_many_to_many_data,
|
26
|
+
link_org,
|
27
|
+
Context,
|
28
|
+
PlainDictField,
|
29
|
+
StringListField,
|
30
|
+
)
|
31
|
+
from karrio.server.core.serializers import (
|
32
|
+
SHIPMENT_STATUS,
|
33
|
+
LABEL_TYPES,
|
34
|
+
ShipmentCancelRequest,
|
35
|
+
ShipmentDetails,
|
36
|
+
ShipmentStatus,
|
37
|
+
TrackerStatus,
|
38
|
+
ShipmentData,
|
39
|
+
Documents,
|
40
|
+
Shipment,
|
41
|
+
Payment,
|
42
|
+
Message,
|
43
|
+
Rate,
|
44
|
+
)
|
45
|
+
from karrio.server.manager.serializers.document import DocumentUploadSerializer
|
46
|
+
from karrio.server.manager.serializers.address import AddressSerializer
|
47
|
+
from karrio.server.manager.serializers.customs import CustomsSerializer
|
48
|
+
from karrio.server.manager.serializers.parcel import ParcelSerializer
|
49
|
+
from karrio.server.manager.serializers.rate import RateSerializer
|
50
|
+
import karrio.server.manager.models as models
|
51
|
+
|
52
|
+
logger = logging.getLogger(__name__)
|
53
|
+
DEFAULT_CARRIER_FILTER: typing.Any = dict(active=True, capability="shipping")
|
54
|
+
|
55
|
+
|
56
|
+
@owned_model_serializer
|
57
|
+
class ShipmentSerializer(ShipmentData):
|
58
|
+
status = ChoiceField(required=False, choices=SHIPMENT_STATUS)
|
59
|
+
selected_rate_id = CharField(required=False)
|
60
|
+
rates = Rate(many=True, required=False)
|
61
|
+
label = CharField(required=False, allow_blank=True, allow_null=True)
|
62
|
+
tracking_number = CharField(required=False, allow_blank=True, allow_null=True)
|
63
|
+
shipment_identifier = CharField(required=False, allow_blank=True, allow_null=True)
|
64
|
+
selected_rate = Rate(required=False, allow_null=True)
|
65
|
+
tracking_url = CharField(required=False, allow_blank=True, allow_null=True)
|
66
|
+
test_mode = BooleanField(required=False)
|
67
|
+
docs = Documents(required=False)
|
68
|
+
meta = PlainDictField(required=False, allow_null=True)
|
69
|
+
messages = Message(many=True, required=False, default=[])
|
70
|
+
|
71
|
+
def __init__(self, instance: models.Shipment = None, **kwargs):
|
72
|
+
data = kwargs.get("data") or {}
|
73
|
+
context = getattr(self, "__context", None) or kwargs.get("context")
|
74
|
+
is_update = instance is not None
|
75
|
+
|
76
|
+
if is_update and ("parcels" in data):
|
77
|
+
save_many_to_many_data(
|
78
|
+
"parcels",
|
79
|
+
ParcelSerializer,
|
80
|
+
instance,
|
81
|
+
payload=data,
|
82
|
+
context=context,
|
83
|
+
partial=True,
|
84
|
+
)
|
85
|
+
if is_update and ("customs" in data):
|
86
|
+
instance.customs = save_one_to_one_data(
|
87
|
+
"customs",
|
88
|
+
CustomsSerializer,
|
89
|
+
instance,
|
90
|
+
payload=data,
|
91
|
+
context=context,
|
92
|
+
partial=instance.customs is not None,
|
93
|
+
)
|
94
|
+
|
95
|
+
super().__init__(instance, **kwargs)
|
96
|
+
|
97
|
+
@transaction.atomic
|
98
|
+
def create(
|
99
|
+
self, validated_data: dict, context: Context, **kwargs
|
100
|
+
) -> models.Shipment:
|
101
|
+
service = validated_data.get("service")
|
102
|
+
carrier_ids = validated_data.get("carrier_ids") or []
|
103
|
+
fetch_rates = validated_data.get("fetch_rates") is not False
|
104
|
+
services = [service] if service is not None else validated_data.get("services")
|
105
|
+
carriers = gateway.Carriers.list(
|
106
|
+
context=context,
|
107
|
+
carrier_ids=carrier_ids,
|
108
|
+
**({"services": services} if any(services) else {}),
|
109
|
+
**{"raise_not_found": True, **DEFAULT_CARRIER_FILTER},
|
110
|
+
)
|
111
|
+
payment = validated_data.get("payment") or lib.to_dict(
|
112
|
+
datatypes.Payment(
|
113
|
+
currency=(validated_data.get("options") or {}).get("currency")
|
114
|
+
)
|
115
|
+
)
|
116
|
+
rating_data = {
|
117
|
+
**validated_data,
|
118
|
+
**({"services": services} if any(services) else {}),
|
119
|
+
}
|
120
|
+
rates = validated_data.get("rates") or []
|
121
|
+
messages = validated_data.get("messages") or []
|
122
|
+
apply_shipping_rules = lib.identity(
|
123
|
+
getattr(conf.settings, "SHIPPING_RULES", False)
|
124
|
+
and (validated_data.get("options") or {}).get("apply_shipping_rules", False)
|
125
|
+
)
|
126
|
+
|
127
|
+
# Get live rates.
|
128
|
+
if fetch_rates or apply_shipping_rules:
|
129
|
+
rate_response: datatypes.RateResponse = (
|
130
|
+
RateSerializer.map(data=rating_data, context=context)
|
131
|
+
.save(carriers=carriers)
|
132
|
+
.instance
|
133
|
+
)
|
134
|
+
rates = lib.to_dict(rate_response.rates)
|
135
|
+
messages = lib.to_dict(rate_response.messages)
|
136
|
+
|
137
|
+
shipment = models.Shipment.objects.create(
|
138
|
+
**{
|
139
|
+
**{
|
140
|
+
key: value
|
141
|
+
for key, value in validated_data.items()
|
142
|
+
if key in models.Shipment.DIRECT_PROPS and value is not None
|
143
|
+
},
|
144
|
+
"customs": save_one_to_one_data(
|
145
|
+
"customs",
|
146
|
+
CustomsSerializer,
|
147
|
+
payload=validated_data,
|
148
|
+
context=context,
|
149
|
+
),
|
150
|
+
"shipper": save_one_to_one_data(
|
151
|
+
"shipper",
|
152
|
+
AddressSerializer,
|
153
|
+
payload=validated_data,
|
154
|
+
context=context,
|
155
|
+
),
|
156
|
+
"recipient": save_one_to_one_data(
|
157
|
+
"recipient",
|
158
|
+
AddressSerializer,
|
159
|
+
payload=validated_data,
|
160
|
+
context=context,
|
161
|
+
),
|
162
|
+
"return_address": save_one_to_one_data(
|
163
|
+
"return_address",
|
164
|
+
AddressSerializer,
|
165
|
+
payload=validated_data,
|
166
|
+
context=context,
|
167
|
+
),
|
168
|
+
"billing_address": save_one_to_one_data(
|
169
|
+
"billing_address",
|
170
|
+
AddressSerializer,
|
171
|
+
payload=validated_data,
|
172
|
+
context=context,
|
173
|
+
),
|
174
|
+
"rates": rates,
|
175
|
+
"payment": payment,
|
176
|
+
"services": services,
|
177
|
+
"messages": messages,
|
178
|
+
"test_mode": context.test_mode,
|
179
|
+
}
|
180
|
+
)
|
181
|
+
|
182
|
+
shipment.carriers.set(carriers if any(carrier_ids) else [])
|
183
|
+
|
184
|
+
save_many_to_many_data(
|
185
|
+
"parcels",
|
186
|
+
ParcelSerializer,
|
187
|
+
shipment,
|
188
|
+
payload=validated_data,
|
189
|
+
context=context,
|
190
|
+
)
|
191
|
+
|
192
|
+
# Buy label if preferred service is selected or shipping rules should applied.
|
193
|
+
if (service and fetch_rates) or apply_shipping_rules:
|
194
|
+
return buy_shipment_label(
|
195
|
+
shipment,
|
196
|
+
context=context,
|
197
|
+
service=service,
|
198
|
+
)
|
199
|
+
|
200
|
+
return shipment
|
201
|
+
|
202
|
+
@transaction.atomic
|
203
|
+
def update(
|
204
|
+
self, instance: models.Shipment, validated_data: dict, context: Context
|
205
|
+
) -> models.Shipment:
|
206
|
+
changes = []
|
207
|
+
data = validated_data.copy()
|
208
|
+
carriers = validated_data.get("carriers") or []
|
209
|
+
|
210
|
+
for key, val in data.items():
|
211
|
+
if key in models.Shipment.DIRECT_PROPS and getattr(instance, key) != val:
|
212
|
+
setattr(instance, key, val)
|
213
|
+
changes.append(key)
|
214
|
+
validated_data.pop(key)
|
215
|
+
|
216
|
+
if key in models.Shipment.RELATIONAL_PROPS and val is None:
|
217
|
+
prop = getattr(instance, key)
|
218
|
+
# Delete related data from database if payload set to null
|
219
|
+
if hasattr(prop, "delete"):
|
220
|
+
prop.delete(keep_parents=True)
|
221
|
+
setattr(instance, key, None)
|
222
|
+
validated_data.pop(key)
|
223
|
+
|
224
|
+
save_one_to_one_data(
|
225
|
+
"shipper",
|
226
|
+
AddressSerializer,
|
227
|
+
instance,
|
228
|
+
payload=validated_data,
|
229
|
+
context=context,
|
230
|
+
)
|
231
|
+
save_one_to_one_data(
|
232
|
+
"recipient",
|
233
|
+
AddressSerializer,
|
234
|
+
instance,
|
235
|
+
payload=validated_data,
|
236
|
+
context=context,
|
237
|
+
)
|
238
|
+
|
239
|
+
if "return_address" in validated_data:
|
240
|
+
changes.append("return_address")
|
241
|
+
instance.return_address = save_one_to_one_data(
|
242
|
+
"return_address",
|
243
|
+
AddressSerializer,
|
244
|
+
instance,
|
245
|
+
payload=validated_data,
|
246
|
+
context=context,
|
247
|
+
)
|
248
|
+
|
249
|
+
if "billing_address" in validated_data:
|
250
|
+
changes.append("billing_address")
|
251
|
+
instance.billing_address = save_one_to_one_data(
|
252
|
+
"billing_address",
|
253
|
+
AddressSerializer,
|
254
|
+
instance,
|
255
|
+
payload=validated_data,
|
256
|
+
context=context,
|
257
|
+
)
|
258
|
+
|
259
|
+
if "docs" in validated_data:
|
260
|
+
changes.append("label")
|
261
|
+
changes.append("invoice")
|
262
|
+
instance.label = validated_data["docs"].get("label") or instance.label
|
263
|
+
instance.invoice = validated_data["docs"].get("invoice") or instance.invoice
|
264
|
+
|
265
|
+
if "selected_rate" in validated_data:
|
266
|
+
selected_rate = validated_data.get("selected_rate", {})
|
267
|
+
carrier = providers.Carrier.objects.filter(
|
268
|
+
carrier_id=selected_rate.get("carrier_id")
|
269
|
+
).first()
|
270
|
+
instance.test_mode = selected_rate.get("test_mode", instance.test_mode)
|
271
|
+
|
272
|
+
instance.selected_rate = {
|
273
|
+
**selected_rate,
|
274
|
+
"meta": {
|
275
|
+
**selected_rate.get("meta", {}),
|
276
|
+
**(
|
277
|
+
{"carrier_connection_id": carrier.id}
|
278
|
+
if carrier is not None
|
279
|
+
else {}
|
280
|
+
),
|
281
|
+
},
|
282
|
+
}
|
283
|
+
instance.selected_rate_carrier = carrier
|
284
|
+
changes += ["selected_rate", "selected_rate_carrier"]
|
285
|
+
|
286
|
+
if any(changes):
|
287
|
+
instance.save(update_fields=changes)
|
288
|
+
|
289
|
+
if "carrier_ids" in validated_data:
|
290
|
+
instance.carriers.set(carriers)
|
291
|
+
|
292
|
+
return instance
|
293
|
+
|
294
|
+
|
295
|
+
class ShipmentPurchaseData(Serializer):
|
296
|
+
selected_rate_id = CharField(required=True, help_text="The shipment selected rate.")
|
297
|
+
label_type = ChoiceField(
|
298
|
+
required=False,
|
299
|
+
choices=LABEL_TYPES,
|
300
|
+
default=units.LabelType.PDF.name,
|
301
|
+
help_text="The shipment label file type.",
|
302
|
+
)
|
303
|
+
payment = Payment(required=False, help_text="The payment details")
|
304
|
+
reference = CharField(
|
305
|
+
required=False,
|
306
|
+
allow_blank=True,
|
307
|
+
allow_null=True,
|
308
|
+
help_text="The shipment reference",
|
309
|
+
)
|
310
|
+
metadata = PlainDictField(
|
311
|
+
required=False, help_text="User metadata for the shipment"
|
312
|
+
)
|
313
|
+
|
314
|
+
|
315
|
+
class ShipmentUpdateData(validators.OptionDefaultSerializer):
|
316
|
+
label_type = ChoiceField(
|
317
|
+
required=False,
|
318
|
+
choices=LABEL_TYPES,
|
319
|
+
default=units.LabelType.PDF.name,
|
320
|
+
help_text="The shipment label file type.",
|
321
|
+
)
|
322
|
+
payment = Payment(required=False, help_text="The payment details")
|
323
|
+
options = PlainDictField(
|
324
|
+
required=False,
|
325
|
+
default={},
|
326
|
+
help_text="""<details>
|
327
|
+
<summary>The options available for the shipment.</summary>
|
328
|
+
|
329
|
+
{
|
330
|
+
"currency": "USD",
|
331
|
+
"insurance": 100.00,
|
332
|
+
"cash_on_delivery": 30.00,
|
333
|
+
"dangerous_good": true,
|
334
|
+
"declared_value": 150.00,
|
335
|
+
"sms_notification": true,
|
336
|
+
"email_notification": true,
|
337
|
+
"email_notification_to": "shipper@mail.com",
|
338
|
+
"hold_at_location": true,
|
339
|
+
"paperless_trade": true,
|
340
|
+
"preferred_service": "fedex_express_saver",
|
341
|
+
"shipment_date": "2020-01-01", # TODO: deprecate
|
342
|
+
"shipping_date": "2020-01-01T00:00",
|
343
|
+
"shipment_note": "This is a shipment note",
|
344
|
+
"signature_confirmation": true,
|
345
|
+
"saturday_delivery": true,
|
346
|
+
"is_return": true,
|
347
|
+
"doc_files": [
|
348
|
+
{
|
349
|
+
"doc_type": "commercial_invoice",
|
350
|
+
"doc_file": "base64 encoded file",
|
351
|
+
"doc_name": "commercial_invoice.pdf",
|
352
|
+
"doc_format": "pdf",
|
353
|
+
}
|
354
|
+
],
|
355
|
+
"doc_references": [
|
356
|
+
{
|
357
|
+
"doc_id": "123456789",
|
358
|
+
"doc_type": "commercial_invoice",
|
359
|
+
}
|
360
|
+
],
|
361
|
+
}
|
362
|
+
</details>
|
363
|
+
""",
|
364
|
+
)
|
365
|
+
reference = CharField(
|
366
|
+
required=False,
|
367
|
+
allow_blank=True,
|
368
|
+
allow_null=True,
|
369
|
+
help_text="The shipment reference",
|
370
|
+
)
|
371
|
+
metadata = PlainDictField(
|
372
|
+
required=False, help_text="User metadata for the shipment"
|
373
|
+
)
|
374
|
+
|
375
|
+
|
376
|
+
class ShipmentRateData(validators.OptionDefaultSerializer):
|
377
|
+
services = StringListField(
|
378
|
+
required=False,
|
379
|
+
allow_null=True,
|
380
|
+
help_text="""The requested carrier service for the shipment.<br/>
|
381
|
+
Please consult [the reference](#operation/references) for specific carriers services.<br/>
|
382
|
+
**Note that this is a list because on a Multi-carrier rate request you could
|
383
|
+
specify a service per carrier.**
|
384
|
+
""",
|
385
|
+
)
|
386
|
+
carrier_ids = StringListField(
|
387
|
+
required=False,
|
388
|
+
allow_null=True,
|
389
|
+
help_text="""The list of configured carriers you wish to get rates from.<br/>
|
390
|
+
**Note that the request will be sent to all carriers in nothing is specified**
|
391
|
+
""",
|
392
|
+
)
|
393
|
+
options = PlainDictField(
|
394
|
+
required=False,
|
395
|
+
default={},
|
396
|
+
help_text="""<details>
|
397
|
+
<summary>The options available for the shipment.</summary>
|
398
|
+
|
399
|
+
{
|
400
|
+
"currency": "USD",
|
401
|
+
"insurance": 100.00,
|
402
|
+
"cash_on_delivery": 30.00,
|
403
|
+
"dangerous_good": true,
|
404
|
+
"declared_value": 150.00,
|
405
|
+
"sms_notification": true,
|
406
|
+
"email_notification": true,
|
407
|
+
"email_notification_to": "shipper@mail.com",
|
408
|
+
"hold_at_location": true,
|
409
|
+
"paperless_trade": true,
|
410
|
+
"preferred_service": "fedex_express_saver",
|
411
|
+
"shipment_date": "2020-01-01", # TODO: deprecate
|
412
|
+
"shipping_date": "2020-01-01T00:00",
|
413
|
+
"shipment_note": "This is a shipment note",
|
414
|
+
"signature_confirmation": true,
|
415
|
+
"saturday_delivery": true,
|
416
|
+
"is_return": true,
|
417
|
+
"doc_files": [
|
418
|
+
{
|
419
|
+
"doc_type": "commercial_invoice",
|
420
|
+
"doc_file": "base64 encoded file",
|
421
|
+
"doc_name": "commercial_invoice.pdf",
|
422
|
+
"doc_format": "pdf",
|
423
|
+
}
|
424
|
+
],
|
425
|
+
"doc_references": [
|
426
|
+
{
|
427
|
+
"doc_id": "123456789",
|
428
|
+
"doc_type": "commercial_invoice",
|
429
|
+
}
|
430
|
+
],
|
431
|
+
}
|
432
|
+
</details>
|
433
|
+
""",
|
434
|
+
)
|
435
|
+
reference = CharField(
|
436
|
+
required=False,
|
437
|
+
allow_blank=True,
|
438
|
+
allow_null=True,
|
439
|
+
help_text="The shipment reference",
|
440
|
+
)
|
441
|
+
metadata = PlainDictField(
|
442
|
+
required=False, help_text="User metadata for the shipment"
|
443
|
+
)
|
444
|
+
|
445
|
+
|
446
|
+
@owned_model_serializer
|
447
|
+
class ShipmentPurchaseSerializer(Shipment):
|
448
|
+
rates = Rate(many=True, required=True)
|
449
|
+
payment = Payment(required=True)
|
450
|
+
options = PlainDictField(
|
451
|
+
required=False,
|
452
|
+
default={},
|
453
|
+
help_text="""<details>
|
454
|
+
<summary>The options available for the shipment.</summary>
|
455
|
+
|
456
|
+
{
|
457
|
+
"currency": "USD",
|
458
|
+
"insurance": 100.00,
|
459
|
+
"cash_on_delivery": 30.00,
|
460
|
+
"dangerous_good": true,
|
461
|
+
"declared_value": 150.00,
|
462
|
+
"sms_notification": true,
|
463
|
+
"email_notification": true,
|
464
|
+
"email_notification_to": "shipper@mail.com",
|
465
|
+
"hold_at_location": true,
|
466
|
+
"paperless_trade": true,
|
467
|
+
"preferred_service": "fedex_express_saver",
|
468
|
+
"shipment_date": "2020-01-01", # TODO: deprecate
|
469
|
+
"shipping_date": "2020-01-01T00:00",
|
470
|
+
"shipment_note": "This is a shipment note",
|
471
|
+
"signature_confirmation": true,
|
472
|
+
"saturday_delivery": true,
|
473
|
+
"is_return": true,
|
474
|
+
"doc_files": [
|
475
|
+
{
|
476
|
+
"doc_type": "commercial_invoice",
|
477
|
+
"doc_file": "base64 encoded file",
|
478
|
+
"doc_name": "commercial_invoice.pdf",
|
479
|
+
"doc_format": "pdf",
|
480
|
+
}
|
481
|
+
],
|
482
|
+
"doc_references": [
|
483
|
+
{
|
484
|
+
"doc_id": "123456789",
|
485
|
+
"doc_type": "commercial_invoice",
|
486
|
+
}
|
487
|
+
],
|
488
|
+
}
|
489
|
+
</details>
|
490
|
+
""",
|
491
|
+
)
|
492
|
+
reference = CharField(required=False, allow_blank=True, allow_null=True)
|
493
|
+
|
494
|
+
def create(self, validated_data: dict, **kwargs) -> datatypes.Shipment:
|
495
|
+
return gateway.Shipments.create(
|
496
|
+
Shipment(validated_data).data,
|
497
|
+
carrier=validated_data.get("carrier"),
|
498
|
+
selected_rate=kwargs.get("selected_rate"),
|
499
|
+
resolve_tracking_url=lib.identity(
|
500
|
+
lambda tracking_number, carrier_name: reverse(
|
501
|
+
"karrio.server.manager:shipment-tracker",
|
502
|
+
kwargs=dict(
|
503
|
+
tracking_number=tracking_number, carrier_name=carrier_name
|
504
|
+
),
|
505
|
+
)
|
506
|
+
),
|
507
|
+
**kwargs,
|
508
|
+
)
|
509
|
+
|
510
|
+
|
511
|
+
class ShipmentCancelSerializer(Shipment):
|
512
|
+
def update(
|
513
|
+
self, instance: models.Shipment, validated_data: dict, **kwargs
|
514
|
+
) -> datatypes.ConfirmationResponse:
|
515
|
+
if instance.status == ShipmentStatus.purchased.value:
|
516
|
+
gateway.Shipments.cancel(
|
517
|
+
payload={
|
518
|
+
**ShipmentCancelRequest(instance).data,
|
519
|
+
"options": {
|
520
|
+
**instance.options,
|
521
|
+
**instance.meta,
|
522
|
+
**(validated_data.get("options") or {}),
|
523
|
+
},
|
524
|
+
},
|
525
|
+
carrier=instance.selected_rate_carrier,
|
526
|
+
)
|
527
|
+
|
528
|
+
instance.status = ShipmentStatus.cancelled.value
|
529
|
+
instance.save(update_fields=["status"])
|
530
|
+
remove_shipment_tracker(instance)
|
531
|
+
|
532
|
+
return instance
|
533
|
+
|
534
|
+
|
535
|
+
def fetch_shipment_rates(
|
536
|
+
shipment: models.Shipment,
|
537
|
+
context: typing.Any,
|
538
|
+
data: dict = dict(),
|
539
|
+
) -> models.Shipment:
|
540
|
+
carrier_ids = data["carrier_ids"] if "carrier_ids" in data else shipment.carrier_ids
|
541
|
+
|
542
|
+
carriers = gateway.Carriers.list(
|
543
|
+
active=True,
|
544
|
+
capability="shipping",
|
545
|
+
context=context,
|
546
|
+
carrier_ids=carrier_ids,
|
547
|
+
)
|
548
|
+
|
549
|
+
rate_response: datatypes.RateResponse = (
|
550
|
+
RateSerializer.map(
|
551
|
+
context=context, data={**ShipmentData(shipment).data, **data}
|
552
|
+
)
|
553
|
+
.save(carriers=carriers)
|
554
|
+
.instance
|
555
|
+
)
|
556
|
+
|
557
|
+
updated_shipment = (
|
558
|
+
ShipmentSerializer.map(
|
559
|
+
shipment,
|
560
|
+
context=context,
|
561
|
+
data={
|
562
|
+
"rates": Rate(rate_response.rates, many=True).data,
|
563
|
+
"messages": lib.to_dict(rate_response.messages),
|
564
|
+
**data,
|
565
|
+
},
|
566
|
+
)
|
567
|
+
.save(carriers=carriers)
|
568
|
+
.instance
|
569
|
+
)
|
570
|
+
|
571
|
+
return updated_shipment
|
572
|
+
|
573
|
+
|
574
|
+
@utils.require_selected_rate
|
575
|
+
def buy_shipment_label(
|
576
|
+
shipment: models.Shipment,
|
577
|
+
context: typing.Any = None,
|
578
|
+
data: dict = dict(),
|
579
|
+
selected_rate: typing.Union[dict, datatypes.Rate] = None,
|
580
|
+
**kwargs,
|
581
|
+
) -> models.Shipment:
|
582
|
+
extra: dict = {}
|
583
|
+
invoice: dict = {}
|
584
|
+
selected_rate = lib.to_dict(selected_rate or {})
|
585
|
+
invoice_template = shipment.options.get("invoice_template")
|
586
|
+
|
587
|
+
payload = {**data, "selected_rate_id": selected_rate.get("id")}
|
588
|
+
carrier = gateway.Carriers.first(
|
589
|
+
carrier_id=selected_rate.get("carrier_id"),
|
590
|
+
test_mode=selected_rate.get("test_mode"),
|
591
|
+
context=context,
|
592
|
+
)
|
593
|
+
|
594
|
+
is_paperless_trade = lib.identity(
|
595
|
+
"paperless" in carrier.capabilities
|
596
|
+
and shipment.options.get("paperless_trade") == True
|
597
|
+
)
|
598
|
+
pre_purchase_generation = invoice_template is not None and is_paperless_trade
|
599
|
+
|
600
|
+
# Generate invoice in advance if is_paperless_trade
|
601
|
+
if pre_purchase_generation:
|
602
|
+
shipment.selected_rate = selected_rate
|
603
|
+
shipment.selected_rate_carrier = carrier
|
604
|
+
document = generate_custom_invoice(invoice_template, shipment)
|
605
|
+
invoice = dict(invoice=document["doc_file"])
|
606
|
+
|
607
|
+
# Handle Paperless flow per carrier
|
608
|
+
|
609
|
+
if carrier.carrier_name == "ups":
|
610
|
+
# TODO:: Check support for dedicated document upload before upload...
|
611
|
+
upload = upload_customs_forms(shipment, document, context=context)
|
612
|
+
extra.update(
|
613
|
+
options={**shipment.options, **dict(doc_references=upload.documents)},
|
614
|
+
)
|
615
|
+
else:
|
616
|
+
extra.update(
|
617
|
+
options={**shipment.options, **dict(doc_files=[document])},
|
618
|
+
)
|
619
|
+
|
620
|
+
# Submit shipment to carriers
|
621
|
+
response: Shipment = lib.identity(
|
622
|
+
ShipmentPurchaseSerializer.map(
|
623
|
+
context=context,
|
624
|
+
data={**Shipment(shipment).data, **payload, **extra},
|
625
|
+
)
|
626
|
+
.save(carrier=carrier, selected_rate=selected_rate, **kwargs)
|
627
|
+
.instance
|
628
|
+
)
|
629
|
+
|
630
|
+
extra.update(
|
631
|
+
parcels=[
|
632
|
+
dict(id=parcel.id, reference_number=parcel.reference_number)
|
633
|
+
for parcel in response.parcels
|
634
|
+
],
|
635
|
+
docs={**lib.to_dict(response.docs), **invoice},
|
636
|
+
)
|
637
|
+
|
638
|
+
# Update shipment state
|
639
|
+
purchased_shipment = lib.identity(
|
640
|
+
ShipmentSerializer.map(
|
641
|
+
shipment,
|
642
|
+
context=context,
|
643
|
+
data={
|
644
|
+
**payload,
|
645
|
+
**ShipmentDetails(response).data,
|
646
|
+
**extra,
|
647
|
+
# "meta": {**(response.meta or {}), "rule_activity": kwargs.get("rule_activity", None)},
|
648
|
+
},
|
649
|
+
)
|
650
|
+
.save()
|
651
|
+
.instance
|
652
|
+
)
|
653
|
+
|
654
|
+
utils.failsafe(
|
655
|
+
lambda: (
|
656
|
+
create_shipment_tracker(purchased_shipment, context=context),
|
657
|
+
(
|
658
|
+
None
|
659
|
+
if pre_purchase_generation
|
660
|
+
else generate_custom_invoice(invoice_template, purchased_shipment)
|
661
|
+
),
|
662
|
+
)
|
663
|
+
)
|
664
|
+
|
665
|
+
return purchased_shipment
|
666
|
+
|
667
|
+
|
668
|
+
def reset_related_shipment_rates(shipment: typing.Optional[models.Shipment]):
|
669
|
+
if shipment is not None:
|
670
|
+
changes = []
|
671
|
+
|
672
|
+
if shipment.selected_rate is not None:
|
673
|
+
changes += ["selected_rate"]
|
674
|
+
shipment.selected_rate = None
|
675
|
+
|
676
|
+
if len(shipment.rates or []) > 0:
|
677
|
+
changes += ["rates"]
|
678
|
+
shipment.rates = []
|
679
|
+
|
680
|
+
if len(shipment.messages or []) > 0:
|
681
|
+
changes += ["messages"]
|
682
|
+
shipment.messages = []
|
683
|
+
|
684
|
+
if any(changes):
|
685
|
+
shipment.save(update_fields=changes)
|
686
|
+
|
687
|
+
|
688
|
+
def can_mutate_shipment(
|
689
|
+
shipment: models.Shipment,
|
690
|
+
update: bool = False,
|
691
|
+
delete: bool = False,
|
692
|
+
purchase: bool = False,
|
693
|
+
payload: dict = None,
|
694
|
+
):
|
695
|
+
if update and [*(payload or {}).keys()] == ["metadata"]:
|
696
|
+
return
|
697
|
+
|
698
|
+
if purchase and shipment.status == ShipmentStatus.purchased.value:
|
699
|
+
raise exceptions.APIException(
|
700
|
+
f"The shipment is '{shipment.status}' and cannot be purchased again",
|
701
|
+
code="state_error",
|
702
|
+
status_code=status.HTTP_409_CONFLICT,
|
703
|
+
)
|
704
|
+
|
705
|
+
if update and shipment.status != ShipmentStatus.draft.value:
|
706
|
+
raise exceptions.APIException(
|
707
|
+
f"Shipment is {shipment.status} and cannot be updated anymore...",
|
708
|
+
code="state_error",
|
709
|
+
status_code=status.HTTP_409_CONFLICT,
|
710
|
+
)
|
711
|
+
|
712
|
+
if delete and shipment.status not in [
|
713
|
+
ShipmentStatus.purchased.value,
|
714
|
+
ShipmentStatus.draft.value,
|
715
|
+
]:
|
716
|
+
raise exceptions.APIException(
|
717
|
+
f"The shipment is '{shipment.status}' and can not be cancelled anymore...",
|
718
|
+
code="state_error",
|
719
|
+
status_code=status.HTTP_409_CONFLICT,
|
720
|
+
)
|
721
|
+
|
722
|
+
if delete and shipment.shipment_pickup.exists():
|
723
|
+
raise exceptions.APIException(
|
724
|
+
(
|
725
|
+
f"This shipment is scheduled for pickup '{shipment.shipment_pickup.first().pk}' "
|
726
|
+
"Please cancel this shipment pickup before."
|
727
|
+
),
|
728
|
+
code="state_error",
|
729
|
+
status_code=status.HTTP_409_CONFLICT,
|
730
|
+
)
|
731
|
+
|
732
|
+
|
733
|
+
def remove_shipment_tracker(shipment: models.Shipment):
|
734
|
+
if hasattr(shipment, "shipment_tracker"):
|
735
|
+
shipment.shipment_tracker.delete()
|
736
|
+
|
737
|
+
|
738
|
+
def create_shipment_tracker(shipment: typing.Optional[models.Shipment], context):
|
739
|
+
rate_provider = (shipment.meta or {}).get("rate_provider") or shipment.carrier_name
|
740
|
+
carrier = shipment.selected_rate_carrier
|
741
|
+
|
742
|
+
# Get rate provider carrier if supported instead of carrier account
|
743
|
+
if (
|
744
|
+
rate_provider != shipment.carrier_name
|
745
|
+
) and rate_provider in dataunits.CARRIER_NAMES:
|
746
|
+
carrier = (
|
747
|
+
providers.Carrier.access_by(context)
|
748
|
+
.filter(carrier_code=rate_provider)
|
749
|
+
.first()
|
750
|
+
)
|
751
|
+
|
752
|
+
# Handle hub extension tracking
|
753
|
+
if shipment.selected_rate_carrier.gateway.is_hub and carrier is None:
|
754
|
+
carrier = shipment.selected_rate_carrier
|
755
|
+
|
756
|
+
# Get dhl universal account if a dhl integration doesn't support tracking API
|
757
|
+
if (
|
758
|
+
carrier
|
759
|
+
and "dhl" in carrier.carrier_name
|
760
|
+
and "get_tracking" not in carrier.gateway.proxy_methods
|
761
|
+
):
|
762
|
+
carrier = gateway.Carriers.first(
|
763
|
+
carrier_name="dhl_universal",
|
764
|
+
context=context,
|
765
|
+
)
|
766
|
+
|
767
|
+
if carrier is not None and "get_tracking" in carrier.gateway.proxy_methods:
|
768
|
+
# Create shipment tracker
|
769
|
+
try:
|
770
|
+
pkg_weight = sum([p.weight or 0.0 for p in shipment.parcels.all()], 0.0)
|
771
|
+
tracker = models.Tracking.objects.create(
|
772
|
+
tracking_number=shipment.tracking_number,
|
773
|
+
delivered=False,
|
774
|
+
shipment=shipment,
|
775
|
+
tracking_carrier=carrier,
|
776
|
+
test_mode=carrier.test_mode,
|
777
|
+
created_by=shipment.created_by,
|
778
|
+
status=TrackerStatus.pending.value,
|
779
|
+
events=utils.default_tracking_event(event_at=shipment.updated_at),
|
780
|
+
options={shipment.tracking_number: dict(carrier=rate_provider)},
|
781
|
+
meta=dict(carrier=rate_provider),
|
782
|
+
info=dict(
|
783
|
+
source="api",
|
784
|
+
shipment_weight=str(pkg_weight),
|
785
|
+
shipment_package_count=str(shipment.parcels.count()),
|
786
|
+
customer_name=shipment.recipient.person_name,
|
787
|
+
shipment_origin_country=shipment.shipper.country_code,
|
788
|
+
shipment_origin_postal_code=shipment.shipper.postal_code,
|
789
|
+
shipment_destination_country=shipment.recipient.country_code,
|
790
|
+
shipment_destination_postal_code=shipment.recipient.postal_code,
|
791
|
+
shipment_service=shipment.meta.get("service_name"),
|
792
|
+
shipping_date=shipment.options.get("shipment_date"),
|
793
|
+
carrier_tracking_link=utils.get_carrier_tracking_link(
|
794
|
+
carrier, shipment.tracking_number
|
795
|
+
),
|
796
|
+
),
|
797
|
+
)
|
798
|
+
tracker.save()
|
799
|
+
link_org(tracker, context)
|
800
|
+
logger.info(f"Successfully added a tracker to the shipment {shipment.id}")
|
801
|
+
except Exception as e:
|
802
|
+
logger.exception("Failed to create new label tracker", e)
|
803
|
+
|
804
|
+
# Update shipment tracking url if different from the current one
|
805
|
+
try:
|
806
|
+
url = reverse(
|
807
|
+
"karrio.server.manager:shipment-tracker",
|
808
|
+
kwargs=dict(
|
809
|
+
tracking_number=shipment.tracking_number,
|
810
|
+
carrier_name=(
|
811
|
+
rate_provider
|
812
|
+
if carrier.gateway.is_hub
|
813
|
+
else carrier.carrier_name
|
814
|
+
),
|
815
|
+
),
|
816
|
+
)
|
817
|
+
tracking_url = utils.app_tracking_query_params(url, carrier)
|
818
|
+
|
819
|
+
if tracking_url != shipment.tracking_url:
|
820
|
+
shipment.tracking_url = tracking_url
|
821
|
+
shipment.save(update_fields=["tracking_url"])
|
822
|
+
except Exception as e:
|
823
|
+
logger.exception("Failed to update shipment tracking url", e)
|
824
|
+
|
825
|
+
|
826
|
+
def generate_custom_invoice(template: str, shipment: models.Shipment, **kwargs):
|
827
|
+
"""This function generates a custom invoice using Karrio's
|
828
|
+
document generation service. And dispatch a document upload if
|
829
|
+
paperless trade is supported.
|
830
|
+
"""
|
831
|
+
# Skip document generation if not template is provided
|
832
|
+
if any(template or "") is False:
|
833
|
+
return
|
834
|
+
|
835
|
+
# Check if carrier and shipment support ETD and document url is provided
|
836
|
+
if conf.settings.DOCUMENTS_MANAGEMENT is False:
|
837
|
+
logger.info("document generation not supported!")
|
838
|
+
return
|
839
|
+
|
840
|
+
# generate invoice document
|
841
|
+
import karrio.server.documents.generator as generator
|
842
|
+
|
843
|
+
document = generator.Documents.generate_shipment_document(
|
844
|
+
template, shipment, **kwargs
|
845
|
+
)
|
846
|
+
|
847
|
+
if getattr(shipment, "tracking_number", None) is not None:
|
848
|
+
shipment.invoice = document["doc_file"]
|
849
|
+
shipment.save(update_fields=["invoice"])
|
850
|
+
|
851
|
+
logger.info("> custom document successfully generated.")
|
852
|
+
|
853
|
+
return document
|
854
|
+
|
855
|
+
|
856
|
+
def upload_customs_forms(shipment: models.Shipment, document: dict, context=None):
|
857
|
+
return (
|
858
|
+
DocumentUploadSerializer.map(
|
859
|
+
getattr(shipment, "shipment_upload_record", None),
|
860
|
+
data=dict(
|
861
|
+
document_files=[document],
|
862
|
+
shipment_id=shipment.id,
|
863
|
+
reference=shipment.id,
|
864
|
+
),
|
865
|
+
context=context,
|
866
|
+
)
|
867
|
+
.save(shipment=shipment)
|
868
|
+
.instance
|
869
|
+
)
|