karrio-server-manager 2026.1.1__py3-none-any.whl → 2026.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. karrio/server/manager/migrations/0070_add_meta_and_product_fields.py +98 -0
  2. karrio/server/manager/migrations/0071_product_proxy.py +25 -0
  3. karrio/server/manager/migrations/0072_populate_json_fields.py +267 -0
  4. karrio/server/manager/migrations/0073_make_shipment_fk_nullable.py +36 -0
  5. karrio/server/manager/migrations/0074_clean_model_refactoring.py +207 -0
  6. karrio/server/manager/migrations/0075_populate_template_meta.py +69 -0
  7. karrio/server/manager/migrations/0076_remove_customs_model.py +66 -0
  8. karrio/server/manager/migrations/0077_add_carrier_snapshot_fields.py +83 -0
  9. karrio/server/manager/migrations/0078_populate_carrier_snapshots.py +112 -0
  10. karrio/server/manager/migrations/0079_remove_carrier_fk_fields.py +56 -0
  11. karrio/server/manager/migrations/0080_add_carrier_json_indexes.py +137 -0
  12. karrio/server/manager/migrations/0081_cleanup.py +62 -0
  13. karrio/server/manager/migrations/0082_shipment_fees.py +26 -0
  14. karrio/server/manager/models.py +421 -321
  15. karrio/server/manager/serializers/__init__.py +5 -4
  16. karrio/server/manager/serializers/address.py +8 -2
  17. karrio/server/manager/serializers/commodity.py +11 -4
  18. karrio/server/manager/serializers/document.py +29 -15
  19. karrio/server/manager/serializers/manifest.py +6 -3
  20. karrio/server/manager/serializers/parcel.py +5 -2
  21. karrio/server/manager/serializers/pickup.py +194 -67
  22. karrio/server/manager/serializers/shipment.py +226 -171
  23. karrio/server/manager/serializers/tracking.py +45 -12
  24. karrio/server/manager/tests/__init__.py +0 -1
  25. karrio/server/manager/tests/test_addresses.py +53 -0
  26. karrio/server/manager/tests/test_parcels.py +50 -0
  27. karrio/server/manager/tests/test_pickups.py +286 -50
  28. karrio/server/manager/tests/test_products.py +597 -0
  29. karrio/server/manager/tests/test_shipments.py +237 -92
  30. karrio/server/manager/tests/test_trackers.py +4 -3
  31. karrio/server/manager/views/__init__.py +1 -1
  32. karrio/server/manager/views/addresses.py +38 -2
  33. karrio/server/manager/views/documents.py +1 -1
  34. karrio/server/manager/views/parcels.py +25 -2
  35. karrio/server/manager/views/products.py +239 -0
  36. karrio/server/manager/views/trackers.py +69 -1
  37. {karrio_server_manager-2026.1.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/METADATA +1 -1
  38. {karrio_server_manager-2026.1.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/RECORD +40 -28
  39. {karrio_server_manager-2026.1.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/WHEEL +1 -1
  40. karrio/server/manager/serializers/customs.py +0 -84
  41. karrio/server/manager/tests/test_custom_infos.py +0 -101
  42. karrio/server/manager/views/customs.py +0 -159
  43. {karrio_server_manager-2026.1.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/top_level.txt +0 -0
@@ -4,10 +4,10 @@ import django.conf as conf
4
4
  import django.urls as urls
5
5
  import django.db.models as models
6
6
  import django.db.models.fields as fields
7
+ from django.contrib.contenttypes.fields import GenericRelation
7
8
 
8
9
  import karrio.server.core.utils as utils
9
10
  import karrio.server.core.models as core
10
- import karrio.server.providers.models as providers
11
11
  import karrio.server.core.serializers as serializers
12
12
 
13
13
 
@@ -54,19 +54,6 @@ class CommodityManager(models.Manager):
54
54
  )
55
55
 
56
56
 
57
- class CustomsManager(models.Manager):
58
- def get_queryset(self):
59
- return (
60
- super()
61
- .get_queryset()
62
- .select_related("duty_billing_address")
63
- .prefetch_related(
64
- "commodities",
65
- *(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
66
- )
67
- )
68
-
69
-
70
57
  class PickupManager(models.Manager):
71
58
  def get_queryset(self):
72
59
  return (
@@ -86,20 +73,11 @@ class ShipmentManager(models.Manager):
86
73
  .get_queryset()
87
74
  .select_related(
88
75
  "created_by",
89
- "recipient",
90
- "shipper",
91
- "customs",
92
76
  "manifest",
93
- "return_address",
94
- "billing_address",
95
77
  "shipment_tracker",
96
78
  "shipment_upload_record",
97
79
  )
98
80
  .prefetch_related(
99
- "parcels",
100
- "parcels__items",
101
- "customs__commodities",
102
- "customs__duty_billing_address",
103
81
  *(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
104
82
  )
105
83
  )
@@ -113,8 +91,6 @@ class TrackingManager(models.Manager):
113
91
  .select_related(
114
92
  "created_by",
115
93
  "shipment",
116
- "shipment__recipient",
117
- "shipment__shipper",
118
94
  )
119
95
  .prefetch_related(
120
96
  *(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
@@ -156,16 +132,10 @@ class ManifestManager(models.Manager):
156
132
 
157
133
  @core.register_model
158
134
  class Address(core.OwnedEntity):
135
+ # Note: shipper_shipment, recipient_shipment, billing_address_shipment removed
136
+ # as shipment addresses are now stored as JSON fields
159
137
  HIDDEN_PROPS = (
160
- "shipper_shipment",
161
- "recipient_shipment",
162
- "billing_address_shipment",
163
138
  *(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
164
- *(
165
- ("shipper_order", "recipient_order")
166
- if conf.settings.ORDERS_MANAGEMENT
167
- else tuple()
168
- ),
169
139
  )
170
140
  objects = AddressManager()
171
141
 
@@ -211,6 +181,36 @@ class Address(core.OwnedEntity):
211
181
  validate_location = models.BooleanField(null=True)
212
182
  validation = models.JSONField(blank=True, null=True)
213
183
 
184
+ # Template metadata: enables Address to serve as a reusable template
185
+ # Structure: {"label": "Warehouse A", "is_default": true, "usage": ["sender", "return"]}
186
+ meta = models.JSONField(
187
+ blank=True,
188
+ null=True,
189
+ default=functools.partial(utils.identity, value={}),
190
+ help_text="Template metadata: label, is_default, usage[]",
191
+ )
192
+
193
+ # Metafields via GenericRelation
194
+ metafields = GenericRelation(
195
+ "core.Metafield",
196
+ related_query_name="address",
197
+ )
198
+
199
+ @property
200
+ def is_template(self) -> bool:
201
+ """Check if this address is a template (has meta with label)."""
202
+ return bool(self.meta and self.meta.get("label"))
203
+
204
+ @property
205
+ def label(self) -> typing.Optional[str]:
206
+ """Template label from meta field."""
207
+ return (self.meta or {}).get("label")
208
+
209
+ @property
210
+ def is_default(self) -> bool:
211
+ """Whether this is the default template."""
212
+ return (self.meta or {}).get("is_default", False)
213
+
214
214
  @property
215
215
  def object_type(self):
216
216
  return "address"
@@ -226,13 +226,6 @@ class Address(core.OwnedEntity):
226
226
 
227
227
  return None
228
228
 
229
- @property
230
- def customs(self):
231
- if hasattr(self, "duty_billing_address_customs"):
232
- return self.duty_billing_address_customs
233
-
234
- return None
235
-
236
229
  @property
237
230
  def order(self):
238
231
  if hasattr(self, "shipper_order"):
@@ -247,10 +240,12 @@ class Address(core.OwnedEntity):
247
240
 
248
241
  @core.register_model
249
242
  class Parcel(core.OwnedEntity):
250
- HIDDEN_PROPS = (
251
- "parcel_shipment",
252
- *(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
253
- )
243
+ """Standalone parcel model - used for parcel templates only.
244
+
245
+ Parcels attached to shipments are stored as JSON in Shipment.parcels field.
246
+ """
247
+
248
+ HIDDEN_PROPS = (*(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),)
254
249
  objects = ParcelManager()
255
250
 
256
251
  class Meta:
@@ -292,17 +287,49 @@ class Parcel(core.OwnedEntity):
292
287
  blank=True, null=True, default=functools.partial(utils.identity, value={})
293
288
  )
294
289
 
290
+ # Template metadata: enables Parcel to serve as a reusable template
291
+ # Structure: {"label": "Standard Box", "is_default": true}
292
+ meta = models.JSONField(
293
+ blank=True,
294
+ null=True,
295
+ default=functools.partial(utils.identity, value={}),
296
+ help_text="Template metadata: label, is_default",
297
+ )
298
+
299
+ # Metafields via GenericRelation
300
+ metafields = GenericRelation(
301
+ "core.Metafield",
302
+ related_query_name="parcel",
303
+ )
304
+
295
305
  def delete(self, *args, **kwargs):
296
306
  self.items.all().delete()
297
307
  return super().delete(*args, **kwargs)
298
308
 
309
+ @property
310
+ def is_template(self) -> bool:
311
+ """Check if this parcel is a template (has meta with label)."""
312
+ return bool(self.meta and self.meta.get("label"))
313
+
314
+ @property
315
+ def label(self) -> typing.Optional[str]:
316
+ """Template label from meta field."""
317
+ return (self.meta or {}).get("label")
318
+
319
+ @property
320
+ def is_default(self) -> bool:
321
+ """Whether this is the default template."""
322
+ return (self.meta or {}).get("is_default", False)
323
+
299
324
  @property
300
325
  def object_type(self):
301
326
  return "parcel"
302
327
 
303
328
  @property
304
329
  def shipment(self):
305
- return self.parcel_shipment.first()
330
+ # Standalone parcels (templates) don't have a shipment relationship
331
+ # Parcels attached to shipments are stored as JSON in Shipment.parcels
332
+ return None
306
333
 
307
334
 
308
335
  @core.register_model
@@ -310,7 +337,6 @@ class Commodity(core.OwnedEntity):
310
337
  HIDDEN_PROPS = (
311
338
  "children",
312
339
  "commodity_parcel",
313
- "commodity_customs",
314
340
  *(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
315
341
  )
316
342
  objects = CommodityManager()
@@ -363,10 +389,34 @@ class Commodity(core.OwnedEntity):
363
389
  blank=True, null=True, default=functools.partial(utils.identity, value={})
364
390
  )
365
391
 
392
+ # Template metadata: enables Commodity to serve as a reusable template (product)
393
+ # Structure: {"label": "Widget Pro", "is_default": false}
394
+ meta = models.JSONField(
395
+ blank=True,
396
+ null=True,
397
+ default=functools.partial(utils.identity, value={}),
398
+ help_text="Template metadata: label, is_default",
399
+ )
400
+
366
401
  def delete(self, *args, **kwargs):
367
402
  self.children.all().delete()
368
403
  return super().delete(*args, **kwargs)
369
404
 
405
+ @property
406
+ def is_template(self) -> bool:
407
+ """Check if this commodity is a template/product (has meta with label)."""
408
+ return bool(self.meta and self.meta.get("label"))
409
+
410
+ @property
411
+ def label(self) -> typing.Optional[str]:
412
+ """Template label from meta field."""
413
+ return (self.meta or {}).get("label")
414
+
415
+ @property
416
+ def is_default(self) -> bool:
417
+ """Whether this is the default template."""
418
+ return (self.meta or {}).get("is_default", False)
419
+
370
420
  @property
371
421
  def object_type(self):
372
422
  return "commodity"
@@ -375,15 +425,9 @@ class Commodity(core.OwnedEntity):
375
425
  def parcel(self):
376
426
  return self.commodity_parcel.first()
377
427
 
378
- @property
379
- def customs(self):
380
- return self.commodity_customs.first()
381
-
382
428
  @property
383
429
  def shipment(self):
384
- related = self.customs or self.parcel
385
-
386
- return getattr(related, "shipment", None)
430
+ return getattr(self.parcel, "shipment", None)
387
431
 
388
432
  @property
389
433
  def order(self):
@@ -395,92 +439,27 @@ class Commodity(core.OwnedEntity):
395
439
  return None
396
440
 
397
441
 
398
- @core.register_model
399
- class Customs(core.OwnedEntity):
400
- DIRECT_PROPS = [
401
- "content_description",
402
- "content_type",
403
- "incoterm",
404
- "commercial_invoice",
405
- "certify",
406
- "duty",
407
- "created_by",
408
- "signer",
409
- "invoice",
410
- "invoice_date",
411
- "options",
412
- ]
413
- RELATIONAL_PROPS = ["commodities", "duty_billing_address"]
414
- HIDDEN_PROPS = (
415
- "customs_shipment",
416
- *(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
417
- )
418
- objects = CustomsManager()
419
-
420
- class Meta:
421
- db_table = "customs"
422
- verbose_name = "Customs Info"
423
- verbose_name_plural = "Customs Info"
424
- ordering = ["-created_at"]
425
-
426
- id = models.CharField(
427
- max_length=50,
428
- primary_key=True,
429
- default=functools.partial(core.uuid, prefix="cst_"),
430
- editable=False,
431
- )
442
+ class Product(Commodity):
443
+ """Product is a proxy model for Commodity, used for template/product API endpoints.
432
444
 
433
- certify = models.BooleanField(null=True)
434
- commercial_invoice = models.BooleanField(null=True)
435
- content_type = models.CharField(
436
- max_length=100, null=True, blank=True, db_index=True
437
- )
438
- content_description = models.CharField(max_length=250, null=True, blank=True)
439
- incoterm = models.CharField(
440
- max_length=50, choices=serializers.INCOTERMS, db_index=True
441
- )
442
- invoice = models.CharField(max_length=100, null=True, blank=True)
443
- invoice_date = models.CharField(max_length=100, null=True, blank=True)
444
- signer = models.CharField(max_length=100, null=True, blank=True)
445
+ This provides a cleaner "Product" naming for the template API while using
446
+ the same underlying Commodity table.
447
+ """
445
448
 
446
- duty = models.JSONField(
447
- blank=True, null=True, default=functools.partial(utils.identity, value=None)
448
- )
449
- options = models.JSONField(
450
- blank=True, null=True, default=functools.partial(utils.identity, value={})
451
- )
452
-
453
- # System Reference fields
454
-
455
- commodities = models.ManyToManyField(
456
- "Commodity", blank=True, related_name="commodity_customs"
457
- )
458
- duty_billing_address = models.OneToOneField(
459
- "Address",
460
- null=True,
461
- on_delete=models.SET_NULL,
462
- related_name="duty_billing_address_customs",
463
- )
464
-
465
- def delete(self, *args, **kwargs):
466
- self.commodities.all().delete()
467
- return super().delete(*args, **kwargs)
449
+ class Meta:
450
+ proxy = True
451
+ verbose_name = "Product"
452
+ verbose_name_plural = "Products"
468
453
 
469
454
  @property
470
455
  def object_type(self):
471
- return "customs_info"
472
-
473
- @property
474
- def shipment(self):
475
- if hasattr(self, "customs_shipment"):
476
- return self.customs_shipment
477
-
478
- return None
456
+ return "product"
479
457
 
480
458
 
481
459
  @core.register_model
482
460
  class Pickup(core.OwnedEntity):
483
- CONTEXT_RELATIONS = ["pickup_carrier"]
461
+ """Pickup model with embedded JSON address and carrier snapshot."""
462
+
484
463
  DIRECT_PROPS = [
485
464
  "confirmation_number",
486
465
  "pickup_date",
@@ -493,11 +472,13 @@ class Pickup(core.OwnedEntity):
493
472
  "created_by",
494
473
  "metadata",
495
474
  "meta",
475
+ "address", # Embedded JSON field
476
+ "carrier", # Carrier snapshot
496
477
  ]
497
478
  objects = PickupManager()
498
479
 
499
480
  class Meta:
500
- db_table = "pickup"
481
+ db_table = "pickups"
501
482
  verbose_name = "Pickup"
502
483
  verbose_name_plural = "Pickups"
503
484
  ordering = ["-created_at"]
@@ -516,17 +497,30 @@ class Pickup(core.OwnedEntity):
516
497
  instruction = models.CharField(max_length=250, null=True, blank=True)
517
498
  package_location = models.CharField(max_length=250, null=True, blank=True)
518
499
 
519
- options = models.JSONField(
520
- blank=True, null=True, default=functools.partial(utils.identity, value={})
500
+ # ─────────────────────────────────────────────────────────────────
501
+ # EMBEDDED JSON FIELDS
502
+ # ─────────────────────────────────────────────────────────────────
503
+ address = models.JSONField(
504
+ blank=True,
505
+ null=True,
506
+ help_text="Pickup address (embedded JSON)",
521
507
  )
522
- pickup_charge = models.JSONField(blank=True, null=True)
523
- address = models.ForeignKey(
524
- "Address",
525
- on_delete=models.CASCADE,
526
- related_name="address_pickup",
508
+
509
+ # Carrier snapshot - replaces pickup_carrier FK
510
+ # Structure: {connection_id, connection_type, carrier_code, carrier_id, carrier_name, test_mode}
511
+ carrier = models.JSONField(
527
512
  blank=True,
528
513
  null=True,
514
+ help_text="Carrier snapshot at time of pickup creation",
515
+ )
516
+
517
+ # ─────────────────────────────────────────────────────────────────
518
+ # OPERATIONAL JSON FIELDS
519
+ # ─────────────────────────────────────────────────────────────────
520
+ options = models.JSONField(
521
+ blank=True, null=True, default=functools.partial(utils.identity, value={})
529
522
  )
523
+ pickup_charge = models.JSONField(blank=True, null=True)
530
524
  metadata = models.JSONField(
531
525
  blank=True, null=True, default=functools.partial(utils.identity, value={})
532
526
  )
@@ -534,59 +528,84 @@ class Pickup(core.OwnedEntity):
534
528
  blank=True, default=functools.partial(utils.identity, value={})
535
529
  )
536
530
 
537
- # System Reference fields
538
-
539
- pickup_carrier = models.ForeignKey(providers.Carrier, on_delete=models.CASCADE)
531
+ # ─────────────────────────────────────────────────────────────────
532
+ # SHIPMENT RELATION (kept - operational necessity)
533
+ # ─────────────────────────────────────────────────────────────────
540
534
  shipments = models.ManyToManyField("Shipment", related_name="shipment_pickup")
541
535
 
542
- def delete(self, *args, **kwargs):
543
- handle = self.address or super()
544
- return handle.delete(*args, **kwargs)
545
-
546
- @classmethod
547
- def resolve_context_data(cls, queryset, context):
548
- """Apply context-aware carrier config resolution for pickup_carrier."""
549
- from karrio.server.providers.models.carrier import Carrier
536
+ # Metafields via GenericRelation
537
+ metafields = GenericRelation(
538
+ "core.Metafield",
539
+ related_query_name="pickup",
540
+ )
550
541
 
551
- carrier_queryset = Carrier.objects.resolve_config_for(context)
552
- return queryset.prefetch_related(
553
- models.Prefetch("pickup_carrier", queryset=carrier_queryset),
554
- )
542
+ def delete(self, *args, **kwargs):
543
+ return super().delete(*args, **kwargs)
555
544
 
556
545
  @property
557
546
  def object_type(self):
558
547
  return "pickup"
559
548
 
560
- # Computed properties
549
+ # Computed properties from carrier snapshot
550
+
551
+ @property
552
+ def carrier_id(self) -> typing.Optional[str]:
553
+ if self.carrier is None:
554
+ return None
555
+ return self.carrier.get("carrier_id")
561
556
 
562
557
  @property
563
- def carrier_id(self) -> str:
564
- return typing.cast(providers.Carrier, self.pickup_carrier).carrier_id
558
+ def carrier_name(self) -> typing.Optional[str]:
559
+ if self.carrier is None:
560
+ return None
561
+ return self.carrier.get("carrier_name")
565
562
 
566
563
  @property
567
- def carrier_name(self) -> str:
568
- return typing.cast(providers.Carrier, self.pickup_carrier).data.carrier_name
564
+ def carrier_code(self) -> typing.Optional[str]:
565
+ if self.carrier is None:
566
+ return None
567
+ return self.carrier.get("carrier_code")
569
568
 
570
569
  @property
571
- def parcels(self) -> typing.List[Parcel]:
570
+ def parcels(self) -> typing.List[dict]:
571
+ """Get all parcels from related shipments as JSON data."""
572
+ if self.pk is None:
573
+ return []
572
574
  return sum(
573
- [list(shipment.parcels.all()) for shipment in self.shipments.all()], []
575
+ [shipment.parcels or [] for shipment in self.shipments.all()], []
574
576
  )
575
577
 
576
578
  @property
577
579
  def tracking_numbers(self) -> typing.List[str]:
580
+ if self.pk is None:
581
+ return []
578
582
  return [shipment.tracking_number for shipment in self.shipments.all()]
579
583
 
584
+ @property
585
+ def pickup_type(self) -> typing.Optional[str]:
586
+ """Get pickup type from meta field (one_time, daily, recurring)."""
587
+ if self.meta is None:
588
+ return "one_time"
589
+ return self.meta.get("pickup_type", "one_time")
590
+
591
+ @property
592
+ def recurrence(self) -> typing.Optional[dict]:
593
+ """Get recurrence config from meta field."""
594
+ if self.meta is None:
595
+ return None
596
+ return self.meta.get("recurrence")
597
+
580
598
 
581
599
  @core.register_model
582
600
  class Tracking(core.OwnedEntity):
583
- CONTEXT_RELATIONS = ["tracking_carrier"]
601
+ """Tracking model with embedded carrier snapshot."""
602
+
584
603
  DIRECT_PROPS = [
585
604
  "metadata",
586
605
  "info",
606
+ "carrier", # Carrier snapshot
587
607
  ]
588
608
  HIDDEN_PROPS = (
589
- "tracking_carrier",
590
609
  *(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
591
610
  )
592
611
  objects = TrackingManager()
@@ -597,8 +616,18 @@ class Tracking(core.OwnedEntity):
597
616
  verbose_name_plural = "Tracking Statuses"
598
617
  ordering = ["-created_at"]
599
618
  indexes = [
600
- # Index for archiving queries based on creation date
601
619
  models.Index(fields=["created_at"], name="tracking_created_at_idx"),
620
+ # JSONField indexes for carrier snapshot queries
621
+ models.Index(
622
+ fields.json.KeyTextTransform("carrier_code", "carrier"),
623
+ condition=models.Q(carrier__isnull=False),
624
+ name="tracking_carrier_code_idx",
625
+ ),
626
+ models.Index(
627
+ fields.json.KeyTextTransform("connection_id", "carrier"),
628
+ condition=models.Q(carrier__isnull=False),
629
+ name="tracking_connection_id_idx",
630
+ ),
602
631
  ]
603
632
 
604
633
  id = models.CharField(
@@ -642,36 +671,52 @@ class Tracking(core.OwnedEntity):
642
671
  delivery_image = models.TextField(max_length=None, null=True, blank=True)
643
672
  signature_image = models.TextField(max_length=None, null=True, blank=True)
644
673
 
645
- # System Reference fields
674
+ # ─────────────────────────────────────────────────────────────────
675
+ # CARRIER SNAPSHOT (replaces tracking_carrier FK)
676
+ # ─────────────────────────────────────────────────────────────────
677
+ # Structure: {connection_id, connection_type, carrier_code, carrier_id, carrier_name, test_mode}
678
+ carrier = models.JSONField(
679
+ blank=True,
680
+ null=True,
681
+ help_text="Carrier snapshot at time of tracker creation",
682
+ )
646
683
 
647
- tracking_carrier = models.ForeignKey(providers.Carrier, on_delete=models.CASCADE)
684
+ # ─────────────────────────────────────────────────────────────────
685
+ # SHIPMENT RELATION (kept - operational necessity)
686
+ # ─────────────────────────────────────────────────────────────────
648
687
  shipment = models.OneToOneField(
649
688
  "Shipment", on_delete=models.CASCADE, related_name="shipment_tracker", null=True
650
689
  )
651
690
 
652
- @classmethod
653
- def resolve_context_data(cls, queryset, context):
654
- """Apply context-aware carrier config resolution for tracking_carrier."""
655
- from karrio.server.providers.models.carrier import Carrier
656
-
657
- carrier_queryset = Carrier.objects.resolve_config_for(context)
658
- return queryset.prefetch_related(
659
- models.Prefetch("tracking_carrier", queryset=carrier_queryset),
660
- )
691
+ # Metafields via GenericRelation
692
+ metafields = GenericRelation(
693
+ "core.Metafield",
694
+ related_query_name="tracking",
695
+ )
661
696
 
662
697
  @property
663
698
  def object_type(self):
664
699
  return "tracker"
665
700
 
666
- # Computed properties
701
+ # Computed properties from carrier snapshot
702
+
703
+ @property
704
+ def carrier_id(self) -> typing.Optional[str]:
705
+ if self.carrier is None:
706
+ return None
707
+ return self.carrier.get("carrier_id")
667
708
 
668
709
  @property
669
- def carrier_id(self) -> str:
670
- return typing.cast(providers.Carrier, self.tracking_carrier).carrier_id
710
+ def carrier_name(self) -> typing.Optional[str]:
711
+ if self.carrier is None:
712
+ return None
713
+ return self.carrier.get("carrier_name")
671
714
 
672
715
  @property
673
- def carrier_name(self) -> str:
674
- return typing.cast(providers.Carrier, self.tracking_carrier).data.carrier_name
716
+ def carrier_code(self) -> typing.Optional[str]:
717
+ if self.carrier is None:
718
+ return None
719
+ return self.carrier.get("carrier_code")
675
720
 
676
721
  @property
677
722
  def pending(self) -> bool:
@@ -702,10 +747,12 @@ class Tracking(core.OwnedEntity):
702
747
 
703
748
  @core.register_model
704
749
  class Shipment(core.OwnedEntity):
705
- CONTEXT_RELATIONS = ["selected_rate_carrier", "carriers"]
750
+ """Shipment model with embedded JSON data for addresses, parcels, customs, and carrier."""
751
+
706
752
  DIRECT_PROPS = [
707
753
  "options",
708
754
  "services",
755
+ "carrier_ids",
709
756
  "status",
710
757
  "meta",
711
758
  "label_type",
@@ -719,29 +766,28 @@ class Shipment(core.OwnedEntity):
719
766
  "metadata",
720
767
  "created_by",
721
768
  "reference",
722
- ]
723
- RELATIONAL_PROPS = [
769
+ "applied_fees", # Accounting: addons + surcharge COGS values
770
+ # Embedded JSON fields
724
771
  "shipper",
725
772
  "recipient",
726
773
  "parcels",
727
774
  "customs",
728
- "selected_rate",
729
775
  "return_address",
730
776
  "billing_address",
777
+ "selected_rate",
778
+ "carrier", # Carrier snapshot
731
779
  ]
732
780
  HIDDEN_PROPS = (
733
- "carriers",
734
781
  "label",
735
782
  "invoice",
736
783
  "shipment_pickup",
737
784
  "shipment_tracker",
738
- "selected_rate_carrier",
739
785
  *(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
740
786
  )
741
787
  objects = ShipmentManager()
742
788
 
743
789
  class Meta:
744
- db_table = "shipment"
790
+ db_table = "shipments"
745
791
  verbose_name = "Shipment"
746
792
  verbose_name_plural = "Shipments"
747
793
  ordering = ["-created_at"]
@@ -751,8 +797,13 @@ class Shipment(core.OwnedEntity):
751
797
  condition=models.Q(meta__object_id__isnull=False),
752
798
  name="shipment_service_idx",
753
799
  ),
754
- # Index for archiving queries based on creation date
755
800
  models.Index(fields=["created_at"], name="shipment_created_at_idx"),
801
+ # JSONField index for carrier snapshot queries
802
+ models.Index(
803
+ fields.json.KeyTextTransform("carrier_code", "carrier"),
804
+ condition=models.Q(carrier__isnull=False),
805
+ name="shipment_carrier_code_idx",
806
+ ),
756
807
  ]
757
808
 
758
809
  id = models.CharField(
@@ -767,44 +818,63 @@ class Shipment(core.OwnedEntity):
767
818
  default=serializers.SHIPMENT_STATUS[0][0],
768
819
  db_index=True,
769
820
  )
821
+ label_type = models.CharField(max_length=25, null=True, blank=True)
822
+ tracking_number = models.CharField(
823
+ max_length=100, null=True, blank=True, db_index=True
824
+ )
825
+ shipment_identifier = models.CharField(max_length=100, null=True, blank=True)
826
+ tracking_url = models.TextField(max_length=None, null=True, blank=True)
827
+ test_mode = models.BooleanField(null=False)
828
+ reference = models.CharField(max_length=100, null=True, blank=True)
770
829
 
771
- recipient = models.OneToOneField(
772
- "Address", on_delete=models.CASCADE, related_name="recipient_shipment"
830
+ # Document storage
831
+ label = models.TextField(max_length=None, null=True, blank=True)
832
+ invoice = models.TextField(max_length=None, null=True, blank=True)
833
+
834
+ # ─────────────────────────────────────────────────────────────────
835
+ # EMBEDDED JSON FIELDS
836
+ # ─────────────────────────────────────────────────────────────────
837
+ shipper = models.JSONField(
838
+ blank=True,
839
+ null=True,
840
+ help_text="Shipper address (embedded JSON)",
773
841
  )
774
- shipper = models.OneToOneField(
775
- "Address", on_delete=models.CASCADE, related_name="shipper_shipment"
842
+ recipient = models.JSONField(
843
+ blank=True,
844
+ null=True,
845
+ help_text="Recipient address (embedded JSON)",
776
846
  )
777
- return_address = models.OneToOneField(
778
- "Address",
847
+ return_address = models.JSONField(
848
+ blank=True,
779
849
  null=True,
780
- on_delete=models.SET_NULL,
781
- related_name="return_address_shipment",
850
+ help_text="Return address (embedded JSON)",
782
851
  )
783
- billing_address = models.OneToOneField(
784
- "Address",
852
+ billing_address = models.JSONField(
853
+ blank=True,
785
854
  null=True,
786
- on_delete=models.SET_NULL,
787
- related_name="billing_address_shipment",
855
+ help_text="Billing address (embedded JSON)",
788
856
  )
789
- label_type = models.CharField(max_length=25, null=True, blank=True)
790
- tracking_number = models.CharField(
791
- max_length=100, null=True, blank=True, db_index=True
857
+ parcels = models.JSONField(
858
+ blank=True,
859
+ null=True,
860
+ default=functools.partial(utils.identity, value=[]),
861
+ help_text="Parcels array with nested items (embedded JSON)",
792
862
  )
793
- shipment_identifier = models.CharField(max_length=100, null=True, blank=True)
794
- tracking_url = models.TextField(max_length=None, null=True, blank=True)
795
- test_mode = models.BooleanField(null=False)
796
- customs = models.OneToOneField(
797
- "Customs",
798
- on_delete=models.SET_NULL,
863
+ customs = models.JSONField(
799
864
  blank=True,
800
865
  null=True,
801
- related_name="customs_shipment",
866
+ help_text="Customs information (embedded JSON)",
802
867
  )
803
868
 
804
- label = models.TextField(max_length=None, null=True, blank=True)
805
- invoice = models.TextField(max_length=None, null=True, blank=True)
806
- reference = models.CharField(max_length=100, null=True, blank=True)
869
+ # ─────────────────────────────────────────────────────────────────
870
+ # OPERATIONAL JSON FIELDS
871
+ # ─────────────────────────────────────────────────────────────────
872
+ # selected_rate contains rate details:
873
+ # {id, carrier_id, carrier_name, service, currency, total_charge, test_mode, meta: {...}}
807
874
  selected_rate = models.JSONField(blank=True, null=True)
875
+ rates = models.JSONField(
876
+ blank=True, null=True, default=functools.partial(utils.identity, value=[])
877
+ )
808
878
  payment = models.JSONField(
809
879
  blank=True, null=True, default=functools.partial(utils.identity, value=None)
810
880
  )
@@ -814,6 +884,10 @@ class Shipment(core.OwnedEntity):
814
884
  services = models.JSONField(
815
885
  blank=True, null=True, default=functools.partial(utils.identity, value=[])
816
886
  )
887
+ carrier_ids = models.JSONField(
888
+ blank=True, null=True, default=functools.partial(utils.identity, value=[]),
889
+ help_text="List of carrier IDs to filter rate requests",
890
+ )
817
891
  messages = models.JSONField(
818
892
  blank=True, null=True, default=functools.partial(utils.identity, value=[])
819
893
  )
@@ -826,23 +900,26 @@ class Shipment(core.OwnedEntity):
826
900
  extra_documents = models.JSONField(
827
901
  blank=True, null=True, default=functools.partial(utils.identity, value=[])
828
902
  )
829
-
830
- # System Reference fields
831
-
832
- rates = models.JSONField(
833
- blank=True, null=True, default=functools.partial(utils.identity, value=[])
834
- )
835
- parcels = models.ManyToManyField("Parcel", related_name="parcel_shipment")
836
- carriers = models.ManyToManyField(
837
- providers.Carrier, blank=True, related_name="related_shipments"
903
+ applied_fees = models.JSONField(
904
+ blank=True,
905
+ null=True,
906
+ default=functools.partial(utils.identity, value=[]),
907
+ help_text="Applied fees for accounting: addons + surcharge COGS values",
838
908
  )
839
- selected_rate_carrier = models.ForeignKey(
840
- providers.Carrier,
841
- on_delete=models.CASCADE,
842
- related_name="carrier_shipments",
909
+
910
+ # ─────────────────────────────────────────────────────────────────
911
+ # CARRIER SNAPSHOT (consistent with Tracking, Pickup, Manifest, DocumentUploadRecord)
912
+ # ─────────────────────────────────────────────────────────────────
913
+ # Structure: {connection_id, connection_type, carrier_code, carrier_id, carrier_name, test_mode}
914
+ carrier = models.JSONField(
843
915
  blank=True,
844
916
  null=True,
917
+ help_text="Carrier snapshot at time of label purchase",
845
918
  )
919
+
920
+ # ─────────────────────────────────────────────────────────────────
921
+ # MANIFEST RELATION (kept - operational necessity)
922
+ # ─────────────────────────────────────────────────────────────────
846
923
  manifest = models.ForeignKey(
847
924
  "Manifest",
848
925
  on_delete=models.SET_NULL,
@@ -851,77 +928,63 @@ class Shipment(core.OwnedEntity):
851
928
  null=True,
852
929
  )
853
930
 
931
+ # Metafields via GenericRelation
932
+ metafields = GenericRelation(
933
+ "core.Metafield",
934
+ related_query_name="shipment",
935
+ )
936
+
854
937
  def delete(self, *args, **kwargs):
855
- self.parcels.all().delete()
856
- self.customs and self.customs.delete()
857
938
  return super().delete(*args, **kwargs)
858
939
 
859
- @classmethod
860
- def resolve_context_data(cls, queryset, context):
861
- """
862
- Apply context-aware prefetching for carriers with proper config resolution.
863
- This is called by access_by() to ensure carrier configs are resolved for the request context.
864
- """
865
- from karrio.server.providers.models.carrier import Carrier
866
-
867
- # Resolve carrier configs with the request context for user/org-specific config
868
- carrier_queryset = Carrier.objects.resolve_config_for(context)
869
-
870
- # Re-apply carrier prefetches with context-aware config resolution
871
- # Note: Manager's get_queryset() already sets up base prefetches with context=None
872
- # This overrides those prefetches with context-aware ones when called via access_by()
873
- return queryset.prefetch_related(
874
- models.Prefetch("carriers", queryset=carrier_queryset),
875
- models.Prefetch("selected_rate_carrier", queryset=carrier_queryset),
876
- )
877
-
878
940
  @property
879
941
  def object_type(self):
880
942
  return "shipment"
881
943
 
882
- # Computed properties
944
+ # Computed properties from carrier snapshot
883
945
 
884
946
  @property
885
- def carrier_id(self) -> str:
886
- return typing.cast(providers.Carrier, self.selected_rate_carrier).carrier_id
947
+ def carrier_id(self) -> typing.Optional[str]:
948
+ if self.carrier is None:
949
+ return None
950
+ return self.carrier.get("carrier_id")
887
951
 
888
952
  @property
889
- def carrier_name(self) -> str:
890
- return typing.cast(providers.Carrier, self.selected_rate_carrier).carrier_name
953
+ def carrier_name(self) -> typing.Optional[str]:
954
+ if self.carrier is None:
955
+ return None
956
+ return self.carrier.get("carrier_name")
891
957
 
892
958
  @property
893
- def tracker_id(self) -> typing.Optional[str]:
894
- return getattr(self.tracker, "id", None)
959
+ def carrier_code(self) -> typing.Optional[str]:
960
+ if self.carrier is None:
961
+ return None
962
+ return self.carrier.get("carrier_code")
895
963
 
896
964
  @property
897
- def carrier_ids(self) -> typing.List[str]:
898
- return [carrier.carrier_id for carrier in self.carriers.all()]
965
+ def tracker_id(self) -> typing.Optional[str]:
966
+ return getattr(self.tracker, "id", None)
899
967
 
900
968
  @property
901
- def selected_rate_id(self) -> str:
902
- return (
903
- typing.cast(dict, self.selected_rate).get("id")
904
- if self.selected_rate is not None
905
- else None
906
- )
969
+ def selected_rate_id(self) -> typing.Optional[str]:
970
+ if self.selected_rate is None:
971
+ return None
972
+ return self.selected_rate.get("id")
907
973
 
908
974
  @property
909
- def service(self) -> str:
910
- return (
911
- typing.cast(dict, self.selected_rate).get("service")
912
- if self.selected_rate is not None
913
- else None
914
- )
975
+ def service(self) -> typing.Optional[str]:
976
+ if self.selected_rate is None:
977
+ return None
978
+ return self.selected_rate.get("service")
915
979
 
916
980
  @property
917
981
  def tracker(self):
918
982
  if hasattr(self, "shipment_tracker"):
919
983
  return self.shipment_tracker
920
-
921
984
  return None
922
985
 
923
986
  @property
924
- def label_url(self) -> str:
987
+ def label_url(self) -> typing.Optional[str]:
925
988
  if self.label is None:
926
989
  return None
927
990
 
@@ -933,7 +996,7 @@ class Shipment(core.OwnedEntity):
933
996
  )
934
997
 
935
998
  @property
936
- def invoice_url(self) -> str:
999
+ def invoice_url(self) -> typing.Optional[str]:
937
1000
  if self.invoice is None:
938
1001
  return None
939
1002
 
@@ -949,12 +1012,19 @@ class Shipment(core.OwnedEntity):
949
1012
  invoice=self.invoice,
950
1013
  )
951
1014
 
952
-
953
1015
  @core.register_model
954
1016
  class DocumentUploadRecord(core.OwnedEntity):
955
- CONTEXT_RELATIONS = ["upload_carrier"]
1017
+ """Document upload record with embedded carrier snapshot."""
1018
+
1019
+ DIRECT_PROPS = [
1020
+ "documents",
1021
+ "messages",
1022
+ "meta",
1023
+ "options",
1024
+ "reference",
1025
+ "carrier", # Carrier snapshot
1026
+ ]
956
1027
  HIDDEN_PROPS = (
957
- "upload_carrier",
958
1028
  *(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
959
1029
  )
960
1030
  objects = DocumentUploadRecordManager()
@@ -985,38 +1055,50 @@ class DocumentUploadRecord(core.OwnedEntity):
985
1055
  )
986
1056
  reference = models.CharField(max_length=100, null=True, blank=True)
987
1057
 
988
- # System Reference fields
1058
+ # ─────────────────────────────────────────────────────────────────
1059
+ # CARRIER SNAPSHOT (replaces upload_carrier FK)
1060
+ # ─────────────────────────────────────────────────────────────────
1061
+ # Structure: {connection_id, connection_type, carrier_code, carrier_id, carrier_name, test_mode}
1062
+ carrier = models.JSONField(
1063
+ blank=True,
1064
+ null=True,
1065
+ help_text="Carrier snapshot at time of document upload",
1066
+ )
989
1067
 
990
- upload_carrier = models.ForeignKey(providers.Carrier, on_delete=models.CASCADE)
1068
+ # ─────────────────────────────────────────────────────────────────
1069
+ # SHIPMENT RELATION (kept - operational necessity)
1070
+ # ─────────────────────────────────────────────────────────────────
991
1071
  shipment = models.OneToOneField(
992
1072
  "Shipment",
993
1073
  on_delete=models.CASCADE,
994
1074
  related_name="shipment_upload_record",
995
1075
  )
996
1076
 
997
- @classmethod
998
- def resolve_context_data(cls, queryset, context):
999
- """Apply context-aware carrier config resolution for upload_carrier."""
1000
- from karrio.server.providers.models.carrier import Carrier
1077
+ # Computed properties from carrier snapshot
1001
1078
 
1002
- carrier_queryset = Carrier.objects.resolve_config_for(context)
1003
- return queryset.prefetch_related(
1004
- models.Prefetch("upload_carrier", queryset=carrier_queryset),
1005
- )
1006
-
1007
- # Computed properties
1079
+ @property
1080
+ def carrier_id(self) -> typing.Optional[str]:
1081
+ if self.carrier is None:
1082
+ return None
1083
+ return self.carrier.get("carrier_id")
1008
1084
 
1009
1085
  @property
1010
- def carrier_id(self) -> str:
1011
- return typing.cast(providers.Carrier, self.upload_carrier).carrier_id
1086
+ def carrier_name(self) -> typing.Optional[str]:
1087
+ if self.carrier is None:
1088
+ return None
1089
+ return self.carrier.get("carrier_name")
1012
1090
 
1013
1091
  @property
1014
- def carrier_name(self) -> str:
1015
- return typing.cast(providers.Carrier, self.upload_carrier).data.carrier_name
1092
+ def carrier_code(self) -> typing.Optional[str]:
1093
+ if self.carrier is None:
1094
+ return None
1095
+ return self.carrier.get("carrier_code")
1016
1096
 
1017
1097
 
1018
1098
  @core.register_model
1019
1099
  class Manifest(core.OwnedEntity):
1100
+ """Manifest model with embedded JSON address and carrier snapshot."""
1101
+
1020
1102
  DIRECT_PROPS = [
1021
1103
  "meta",
1022
1104
  "options",
@@ -1024,23 +1106,27 @@ class Manifest(core.OwnedEntity):
1024
1106
  "messages",
1025
1107
  "created_by",
1026
1108
  "reference",
1027
- ]
1028
- RELATIONAL_PROPS = [
1029
- "address",
1109
+ "address", # Embedded JSON field
1110
+ "carrier", # Carrier snapshot
1030
1111
  ]
1031
1112
  HIDDEN_PROPS = (
1032
- "manifest_carrier",
1033
1113
  *(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
1034
1114
  )
1035
1115
  objects = ManifestManager()
1036
1116
 
1037
1117
  class Meta:
1038
- db_table = "manifest"
1118
+ db_table = "manifests"
1039
1119
  verbose_name = "Manifest"
1040
1120
  verbose_name_plural = "Manifests"
1041
1121
  ordering = ["-created_at"]
1042
-
1043
- CONTEXT_RELATIONS = ["manifest_carrier"]
1122
+ indexes = [
1123
+ # JSONField index for carrier snapshot queries
1124
+ models.Index(
1125
+ fields.json.KeyTextTransform("carrier_code", "carrier"),
1126
+ condition=models.Q(carrier__isnull=False),
1127
+ name="manifest_carrier_code_idx",
1128
+ ),
1129
+ ]
1044
1130
 
1045
1131
  id = models.CharField(
1046
1132
  max_length=50,
@@ -1048,9 +1134,30 @@ class Manifest(core.OwnedEntity):
1048
1134
  default=functools.partial(core.uuid, prefix="manf_"),
1049
1135
  editable=False,
1050
1136
  )
1051
- address = models.OneToOneField(
1052
- "Address", on_delete=models.CASCADE, related_name="manifest"
1137
+ reference = models.CharField(max_length=100, null=True, blank=True)
1138
+ manifest = models.TextField(max_length=None, null=True, blank=True)
1139
+ test_mode = models.BooleanField(null=False)
1140
+
1141
+ # ─────────────────────────────────────────────────────────────────
1142
+ # EMBEDDED JSON FIELDS
1143
+ # ─────────────────────────────────────────────────────────────────
1144
+ address = models.JSONField(
1145
+ blank=True,
1146
+ null=True,
1147
+ help_text="Manifest address (embedded JSON)",
1053
1148
  )
1149
+
1150
+ # Carrier snapshot - replaces manifest_carrier FK
1151
+ # Structure: {connection_id, connection_type, carrier_code, carrier_id, carrier_name, test_mode}
1152
+ carrier = models.JSONField(
1153
+ blank=True,
1154
+ null=True,
1155
+ help_text="Carrier snapshot at time of manifest creation",
1156
+ )
1157
+
1158
+ # ─────────────────────────────────────────────────────────────────
1159
+ # OPERATIONAL JSON FIELDS
1160
+ # ─────────────────────────────────────────────────────────────────
1054
1161
  metadata = models.JSONField(
1055
1162
  blank=True, null=True, default=functools.partial(utils.identity, value={})
1056
1163
  )
@@ -1063,40 +1170,33 @@ class Manifest(core.OwnedEntity):
1063
1170
  messages = models.JSONField(
1064
1171
  blank=True, null=True, default=functools.partial(utils.identity, value=[])
1065
1172
  )
1066
- reference = models.CharField(max_length=100, null=True, blank=True)
1067
- manifest = models.TextField(max_length=None, null=True, blank=True)
1068
- test_mode = models.BooleanField(null=False)
1069
-
1070
- # System Reference fields
1071
-
1072
- manifest_carrier = models.ForeignKey(providers.Carrier, on_delete=models.CASCADE)
1073
1173
 
1074
- @classmethod
1075
- def resolve_context_data(cls, queryset, context):
1076
- """Apply context-aware carrier config resolution for manifest_carrier."""
1077
- from karrio.server.providers.models.carrier import Carrier
1078
-
1079
- carrier_queryset = Carrier.objects.resolve_config_for(context)
1080
- return queryset.prefetch_related(
1081
- models.Prefetch("manifest_carrier", queryset=carrier_queryset),
1082
- )
1083
-
1084
- # Computed properties
1174
+ # Computed properties from carrier snapshot
1085
1175
 
1086
1176
  @property
1087
1177
  def object_type(self):
1088
1178
  return "manifest"
1089
1179
 
1090
1180
  @property
1091
- def carrier_id(self) -> str:
1092
- return self.manifest_carrier.carrier_id
1181
+ def carrier_id(self) -> typing.Optional[str]:
1182
+ if self.carrier is None:
1183
+ return None
1184
+ return self.carrier.get("carrier_id")
1185
+
1186
+ @property
1187
+ def carrier_name(self) -> typing.Optional[str]:
1188
+ if self.carrier is None:
1189
+ return None
1190
+ return self.carrier.get("carrier_name")
1093
1191
 
1094
1192
  @property
1095
- def carrier_name(self) -> str:
1096
- return self.manifest_carrier.data.carrier_name
1193
+ def carrier_code(self) -> typing.Optional[str]:
1194
+ if self.carrier is None:
1195
+ return None
1196
+ return self.carrier.get("carrier_code")
1097
1197
 
1098
1198
  @property
1099
- def manifest_url(self) -> str:
1199
+ def manifest_url(self) -> typing.Optional[str]:
1100
1200
  if self.manifest is None:
1101
1201
  return None
1102
1202