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.
Files changed (102) hide show
  1. karrio/server/manager/__init__.py +1 -0
  2. karrio/server/manager/admin.py +1 -0
  3. karrio/server/manager/apps.py +13 -0
  4. karrio/server/manager/migrations/0001_initial.py +1358 -0
  5. karrio/server/manager/migrations/0002_auto_20201127_0721.py +61 -0
  6. karrio/server/manager/migrations/0003_auto_20201230_0820.py +34 -0
  7. karrio/server/manager/migrations/0004_auto_20210125_2125.py +18 -0
  8. karrio/server/manager/migrations/0005_auto_20210216_0758.py +27 -0
  9. karrio/server/manager/migrations/0006_auto_20210307_0438.py +24 -0
  10. karrio/server/manager/migrations/0006_auto_20210308_0302.py +53 -0
  11. karrio/server/manager/migrations/0007_merge_20210311_1428.py +14 -0
  12. karrio/server/manager/migrations/0008_remove_shipment_doc_images.py +17 -0
  13. karrio/server/manager/migrations/0009_auto_20210326_1425.py +28 -0
  14. karrio/server/manager/migrations/0010_auto_20210403_1404.py +28 -0
  15. karrio/server/manager/migrations/0011_auto_20210426_1924.py +48 -0
  16. karrio/server/manager/migrations/0012_auto_20210427_1319.py +24 -0
  17. karrio/server/manager/migrations/0013_customs_invoice_date.py +18 -0
  18. karrio/server/manager/migrations/0014_auto_20210515_0928.py +24 -0
  19. karrio/server/manager/migrations/0015_auto_20210601_0340.py +182 -0
  20. karrio/server/manager/migrations/0016_shipment_archived.py +18 -0
  21. karrio/server/manager/migrations/0017_auto_20210629_1650.py +22 -0
  22. karrio/server/manager/migrations/0018_auto_20210705_1049.py +23 -0
  23. karrio/server/manager/migrations/0019_auto_20210722_1131.py +43 -0
  24. karrio/server/manager/migrations/0020_tracking_messages.py +20 -0
  25. karrio/server/manager/migrations/0021_tracking_estimated_delivery.py +18 -0
  26. karrio/server/manager/migrations/0022_auto_20211122_2100.py +53 -0
  27. karrio/server/manager/migrations/0023_auto_20211227_2141.py +118 -0
  28. karrio/server/manager/migrations/0024_alter_parcel_items.py +18 -0
  29. karrio/server/manager/migrations/0025_auto_20220113_1158.py +25 -0
  30. karrio/server/manager/migrations/0026_parcel_reference_number.py +18 -0
  31. karrio/server/manager/migrations/0027_custom_migration_2021_1.py +47 -0
  32. karrio/server/manager/migrations/0028_auto_20220303_1153.py +39 -0
  33. karrio/server/manager/migrations/0029_auto_20220303_1249.py +55 -0
  34. karrio/server/manager/migrations/0030_alter_shipment_status.py +44 -0
  35. karrio/server/manager/migrations/0031_shipment_invoice.py +34 -0
  36. karrio/server/manager/migrations/0032_custom_migration_2022_3.py +26 -0
  37. karrio/server/manager/migrations/0033_auto_20220504_1335.py +57 -0
  38. karrio/server/manager/migrations/0034_commodity_hs_code.py +18 -0
  39. karrio/server/manager/migrations/0035_parcel_options.py +26 -0
  40. karrio/server/manager/migrations/0036_alter_tracking_shipment.py +24 -0
  41. karrio/server/manager/migrations/0037_auto_20220710_1350.py +28 -0
  42. karrio/server/manager/migrations/0038_alter_tracking_status.py +18 -0
  43. karrio/server/manager/migrations/0039_documentuploadrecord.py +43 -0
  44. karrio/server/manager/migrations/0040_parcel_freight_class.py +18 -0
  45. karrio/server/manager/migrations/0041_alter_commodity_options_alter_parcel_options.py +29 -0
  46. karrio/server/manager/migrations/0042_remove_shipment_shipment_tracking_number_idx_and_more.py +658 -0
  47. karrio/server/manager/migrations/0043_customs_duty_billing_address_and_more.py +62 -0
  48. karrio/server/manager/migrations/0044_address_address_line1_temp_and_more.py +326 -0
  49. karrio/server/manager/migrations/0045_alter_customs_duty_billing_address_and_more.py +45 -0
  50. karrio/server/manager/migrations/0046_auto_20230114_0930.py +78 -0
  51. karrio/server/manager/migrations/0047_remove_shipment_shipment_tracking_number_idx_and_more.py +595 -0
  52. karrio/server/manager/migrations/0048_commodity_title_alter_commodity_description_and_more.py +53 -0
  53. karrio/server/manager/migrations/0049_auto_20230318_0708.py +39 -0
  54. karrio/server/manager/migrations/0050_address_street_number_tracking_account_number_and_more.py +60 -0
  55. karrio/server/manager/migrations/0051_auto_20230330_0556.py +56 -0
  56. karrio/server/manager/migrations/0052_auto_20230520_0811.py +35 -0
  57. karrio/server/manager/migrations/0053_alter_commodity_weight_unit_alter_parcel_weight_unit.py +32 -0
  58. karrio/server/manager/migrations/0054_alter_address_company_name_alter_address_person_name.py +22 -0
  59. karrio/server/manager/migrations/0055_alter_tracking_status.py +32 -0
  60. karrio/server/manager/migrations/0056_tracking_delivery_image_tracking_signature_image.py +22 -0
  61. karrio/server/manager/migrations/0057_alter_customs_invoice_date.py +18 -0
  62. karrio/server/manager/migrations/0058_manifest_shipment_manifest.py +124 -0
  63. karrio/server/manager/migrations/0059_shipment_return_address.py +24 -0
  64. karrio/server/manager/migrations/0060_pickup_meta_alter_address_country_code_and_more.py +527 -0
  65. karrio/server/manager/migrations/0061_alter_customs_incoterm.py +37 -0
  66. karrio/server/manager/migrations/0062_alter_tracking_status.py +35 -0
  67. karrio/server/manager/migrations/__init__.py +0 -0
  68. karrio/server/manager/models.py +984 -0
  69. karrio/server/manager/router.py +3 -0
  70. karrio/server/manager/serializers/__init__.py +50 -0
  71. karrio/server/manager/serializers/address.py +82 -0
  72. karrio/server/manager/serializers/commodity.py +51 -0
  73. karrio/server/manager/serializers/customs.py +84 -0
  74. karrio/server/manager/serializers/document.py +113 -0
  75. karrio/server/manager/serializers/manifest.py +85 -0
  76. karrio/server/manager/serializers/parcel.py +84 -0
  77. karrio/server/manager/serializers/pickup.py +285 -0
  78. karrio/server/manager/serializers/rate.py +19 -0
  79. karrio/server/manager/serializers/shipment.py +869 -0
  80. karrio/server/manager/serializers/tracking.py +250 -0
  81. karrio/server/manager/signals.py +70 -0
  82. karrio/server/manager/tests/__init__.py +10 -0
  83. karrio/server/manager/tests/test_addresses.py +110 -0
  84. karrio/server/manager/tests/test_custom_infos.py +97 -0
  85. karrio/server/manager/tests/test_parcels.py +104 -0
  86. karrio/server/manager/tests/test_pickups.py +345 -0
  87. karrio/server/manager/tests/test_shipments.py +833 -0
  88. karrio/server/manager/tests/test_trackers.py +215 -0
  89. karrio/server/manager/urls.py +10 -0
  90. karrio/server/manager/views/__init__.py +9 -0
  91. karrio/server/manager/views/addresses.py +154 -0
  92. karrio/server/manager/views/customs.py +159 -0
  93. karrio/server/manager/views/documents.py +131 -0
  94. karrio/server/manager/views/manifests.py +160 -0
  95. karrio/server/manager/views/parcels.py +155 -0
  96. karrio/server/manager/views/pickups.py +182 -0
  97. karrio/server/manager/views/shipments.py +335 -0
  98. karrio/server/manager/views/trackers.py +364 -0
  99. karrio_server_manager-2025.5rc1.dist-info/METADATA +28 -0
  100. karrio_server_manager-2025.5rc1.dist-info/RECORD +102 -0
  101. karrio_server_manager-2025.5rc1.dist-info/WHEEL +5 -0
  102. 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
+ )