karrio-server-manager 2026.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 +232 -152
  23. karrio/server/manager/serializers/tracking.py +53 -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 +65 -1
  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.dist-info → karrio_server_manager-2026.1.3.dist-info}/METADATA +1 -1
  38. {karrio_server_manager-2026.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/RECORD +40 -28
  39. {karrio_server_manager-2026.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.dist-info → karrio_server_manager-2026.1.3.dist-info}/top_level.txt +0 -0
@@ -8,18 +8,18 @@ from karrio.server.manager.serializers.parcel import (
8
8
  ParcelSerializer,
9
9
  can_mutate_parcel,
10
10
  )
11
- from karrio.server.manager.serializers.customs import (
12
- CustomsSerializer,
13
- can_mutate_customs,
14
- )
15
11
  from karrio.server.manager.serializers.commodity import (
16
12
  CommoditySerializer,
17
13
  can_mutate_commodity,
18
14
  )
15
+
16
+ # Product is a proxy of Commodity - use the same serializer
17
+ ProductSerializer = CommoditySerializer
19
18
  from karrio.server.manager.serializers.rate import RateSerializer
20
19
  from karrio.server.manager.serializers.tracking import (
21
20
  TrackingSerializer,
22
21
  TrackerUpdateData,
22
+ TrackerEventInjectRequest,
23
23
  update_shipment_tracker,
24
24
  can_mutate_tracker,
25
25
  update_tracker,
@@ -37,6 +37,7 @@ from karrio.server.manager.serializers.shipment import (
37
37
  can_mutate_shipment,
38
38
  buy_shipment_label,
39
39
  fetch_shipment_rates,
40
+ compute_estimated_delivery,
40
41
  )
41
42
  from karrio.server.manager.serializers.pickup import (
42
43
  PickupData,
@@ -1,7 +1,10 @@
1
1
  from rest_framework import status
2
2
 
3
3
  from karrio.server.core.serializers import AddressData, ShipmentStatus
4
- from karrio.server.serializers import owned_model_serializer
4
+ from karrio.server.serializers import (
5
+ owned_model_serializer,
6
+ process_dictionaries_mutations,
7
+ )
5
8
  from karrio.server.core.exceptions import APIException
6
9
  from karrio.server.manager import models
7
10
  from karrio.server.core import gateway
@@ -34,8 +37,11 @@ class AddressSerializer(AddressData):
34
37
  def update(
35
38
  self, instance: models.Address, validated_data: dict, **kwargs
36
39
  ) -> models.Address:
40
+ # Handle dictionary mutations for meta field
41
+ data = process_dictionaries_mutations(["meta"], validated_data, instance)
37
42
  changes = []
38
- for key, val in validated_data.items():
43
+
44
+ for key, val in data.items():
39
45
  if getattr(instance, key) != val:
40
46
  changes.append(key)
41
47
  setattr(instance, key, val)
@@ -2,7 +2,10 @@ from rest_framework import status
2
2
 
3
3
  from karrio.server.core.exceptions import APIException
4
4
  from karrio.server.core.serializers import CommodityData, ShipmentStatus
5
- from karrio.server.serializers import owned_model_serializer
5
+ from karrio.server.serializers import (
6
+ owned_model_serializer,
7
+ process_dictionaries_mutations,
8
+ )
6
9
  import karrio.server.manager.models as models
7
10
 
8
11
 
@@ -16,14 +19,18 @@ class CommoditySerializer(CommodityData):
16
19
  def update(
17
20
  self, instance: models.Commodity, validated_data: dict, **kwargs
18
21
  ) -> models.Commodity:
22
+ # Handle dictionary mutations for metadata and meta fields
23
+ data = process_dictionaries_mutations(
24
+ ["metadata", "meta"], validated_data, instance
25
+ )
19
26
  changes = []
20
27
 
21
- for key, val in validated_data.items():
28
+ for key, val in data.items():
22
29
  if getattr(instance, key) != val:
23
30
  changes.append(key)
24
31
  setattr(instance, key, val)
25
32
 
26
- instance.save()
33
+ instance.save(update_fields=changes)
27
34
  return instance
28
35
 
29
36
 
@@ -43,7 +50,7 @@ def can_mutate_commodity(
43
50
  code="state_error",
44
51
  )
45
52
 
46
- if delete and order and len(order.line_items.all()) == 1:
53
+ if delete and order and len(order.line_items or []) == 1:
47
54
  raise APIException(
48
55
  f"Operation not permitted. The related order needs at least one line_item.",
49
56
  status_code=status.HTTP_409_CONFLICT,
@@ -5,6 +5,7 @@ import karrio.server.serializers as serialiazers
5
5
  import karrio.server.core.exceptions as exceptions
6
6
  import karrio.server.core.serializers as core
7
7
  import karrio.server.core.gateway as gateway
8
+ from karrio.server.core.utils import create_carrier_snapshot, resolve_carrier
8
9
  import karrio.server.manager.models as models
9
10
 
10
11
 
@@ -17,20 +18,28 @@ class DocumentUploadSerializer(core.DocumentUploadData):
17
18
  **kwargs,
18
19
  ) -> models.DocumentUploadRecord:
19
20
  shipment = validated_data.get("shipment")
20
- carrier = validated_data.get("carrier") or getattr(
21
- shipment, "selected_rate_carrier", None
22
- )
21
+ # Resolve carrier from validated_data or from shipment's carrier snapshot
22
+ carrier = validated_data.get("carrier")
23
+ if carrier is None and shipment:
24
+ carrier = resolve_carrier(getattr(shipment, "carrier", None) or {}, context)
25
+
23
26
  tracking_number = getattr(shipment, "tracking_number", None)
24
27
  reference = validated_data.get("reference") or tracking_number
25
28
 
26
29
  payload = core.DocumentUploadData(validated_data).data
27
- options = ({
28
- "origin_country_code": shipment.shipper.country_code,
29
- "origin_postal_code": shipment.shipper.postal_code,
30
- "destination_country_code": shipment.recipient.country_code,
31
- "destination_postal_code": shipment.recipient.postal_code,
32
- **(payload.get("options") or {})
33
- } if shipment else payload.get("options"))
30
+ shipper = getattr(shipment, "shipper", None) or {}
31
+ recipient = getattr(shipment, "recipient", None) or {}
32
+ options = (
33
+ {
34
+ "origin_country_code": shipper.get("country_code"),
35
+ "origin_postal_code": shipper.get("postal_code"),
36
+ "destination_country_code": recipient.get("country_code"),
37
+ "destination_postal_code": recipient.get("postal_code"),
38
+ **(payload.get("options") or {}),
39
+ }
40
+ if shipment
41
+ else payload.get("options")
42
+ )
34
43
 
35
44
  response = gateway.Documents.upload(
36
45
  {
@@ -49,7 +58,7 @@ class DocumentUploadSerializer(core.DocumentUploadData):
49
58
  options=response.options,
50
59
  meta=response.meta,
51
60
  shipment=shipment,
52
- upload_carrier=carrier,
61
+ carrier=create_carrier_snapshot(carrier),
53
62
  created_by=context.user,
54
63
  )
55
64
 
@@ -64,12 +73,15 @@ class DocumentUploadSerializer(core.DocumentUploadData):
64
73
  ) -> models.DocumentUploadRecord:
65
74
  changes = []
66
75
 
76
+ # Resolve carrier from instance.carrier snapshot
77
+ carrier = resolve_carrier(instance.carrier, context)
78
+
67
79
  response = gateway.Documents.upload(
68
80
  {
69
81
  "reference": getattr(instance.shipment, "tracking_number", None),
70
82
  **core.DocumentUploadData(validated_data).data,
71
83
  },
72
- carrier=instance.upload_carrier,
84
+ carrier=carrier,
73
85
  context=context,
74
86
  )
75
87
 
@@ -86,9 +98,11 @@ class DocumentUploadSerializer(core.DocumentUploadData):
86
98
  return instance
87
99
 
88
100
 
89
- def can_upload_shipment_document(shipment: models.Shipment):
90
- carrier = getattr(shipment, "selected_rate_carrier", None)
91
- capabilities = getattr(carrier, "capabilities", [])
101
+ def can_upload_shipment_document(shipment: models.Shipment, context=None):
102
+ # Resolve carrier from carrier snapshot
103
+ carrier_snapshot = getattr(shipment, "carrier", None) or {}
104
+ carrier = resolve_carrier(carrier_snapshot, context) if carrier_snapshot else None
105
+ capabilities = getattr(carrier, "capabilities", []) if carrier else []
92
106
 
93
107
  if shipment is None:
94
108
  raise exceptions.APIException(
@@ -1,10 +1,12 @@
1
1
  import typing
2
+ from django.db.models import Q
2
3
 
3
4
  import karrio.server.core.gateway as gateway
4
5
  import karrio.server.manager.models as models
5
6
  import karrio.server.core.serializers as core
6
7
  import karrio.server.serializers as serializers
7
8
  import karrio.server.manager.serializers as manager
9
+ from karrio.server.core.utils import create_carrier_snapshot
8
10
 
9
11
  DEFAULT_CARRIER_FILTER: typing.Any = dict(active=True, capability="manifest")
10
12
 
@@ -17,16 +19,17 @@ class ManifestSerializer(core.ManifestData):
17
19
  data = validated_data.copy()
18
20
  shipment_ids = list(set(data.pop("shipment_ids")))
19
21
  carrier_name = data["carrier_name"]
20
- carrier = gateway.Carriers.first(
22
+ carrier = gateway.Connections.first(
21
23
  context=context,
22
24
  carrier_name=carrier_name,
23
25
  **{"raise_not_found": True, **DEFAULT_CARRIER_FILTER},
24
26
  )
25
27
 
28
+ # Filter shipments by carrier_code in carrier JSON snapshot
26
29
  shipments = models.Shipment.access_by(context).filter(
27
30
  id__in=shipment_ids,
28
31
  manifest__isnull=True,
29
- selected_rate_carrier__carrier_code=carrier_name,
32
+ carrier__carrier_code=carrier_name,
30
33
  )
31
34
  shipment_identifiers = [_.shipment_identifier for _ in shipments]
32
35
 
@@ -74,7 +77,7 @@ class ManifestSerializer(core.ManifestData):
74
77
  **payload,
75
78
  "address": address,
76
79
  "created_by": context.user,
77
- "manifest_carrier": carrier,
80
+ "carrier": create_carrier_snapshot(carrier),
78
81
  "options": data.get("options", {}),
79
82
  "test_mode": response.manifest.test_mode,
80
83
  "manifest": response.manifest.doc.manifest,
@@ -49,7 +49,8 @@ class ParcelSerializer(ParcelData):
49
49
  def update(
50
50
  self, instance: models.Parcel, validated_data: dict, **kwargs
51
51
  ) -> models.Parcel:
52
- data = process_dictionaries_mutations(["options"], validated_data, instance)
52
+ # Handle dictionary mutations for options and meta fields
53
+ data = process_dictionaries_mutations(["options", "meta"], validated_data, instance)
53
54
  changes = []
54
55
 
55
56
  for key, val in data.items():
@@ -76,7 +77,9 @@ def can_mutate_parcel(
76
77
  code="state_error",
77
78
  )
78
79
 
79
- if delete and len(shipment.parcels.all()) == 1:
80
+ # Use JSON field for parcel count check
81
+ parcels = shipment.parcels or []
82
+ if delete and len(parcels) == 1:
80
83
  raise APIException(
81
84
  f"Operation not permitted. The related shipment needs at least one parcel.",
82
85
  status_code=status.HTTP_409_CONFLICT,
@@ -7,8 +7,9 @@ from karrio.server.serializers import (
7
7
  Context,
8
8
  PlainDictField,
9
9
  )
10
- from karrio.server.core.gateway import Pickups, Carriers
10
+ from karrio.server.core.gateway import Pickups, Connections
11
11
  from karrio.server.core.datatypes import Confirmation
12
+ from karrio.server.core.utils import create_carrier_snapshot, resolve_carrier
12
13
  from karrio.server.core.serializers import (
13
14
  Pickup,
14
15
  AddressData,
@@ -70,32 +71,47 @@ class PickupSerializer(PickupRequest):
70
71
  required=False, validators=[address_exists], help_text="The pickup address"
71
72
  )
72
73
  tracking_numbers = serializers.StringListField(
73
- required=True,
74
+ required=False,
74
75
  validators=[shipment_exists],
75
- help_text="The list of shipments to be picked up",
76
+ help_text="The list of shipments to be picked up (optional if parcels_count provided)",
77
+ )
78
+ parcels_count = serializers.IntegerField(
79
+ required=False,
80
+ allow_null=True,
81
+ min_value=1,
82
+ help_text="The number of parcels to be picked up (alternative to linking shipments)",
76
83
  )
77
84
  metadata = PlainDictField(
78
85
  required=False, default={}, help_text="User metadata for the pickup"
79
86
  )
80
87
 
81
88
  def __init__(self, instance: models.Pickup = None, **kwargs):
89
+ self._shipments: typing.List[models.Shipment] = []
90
+
82
91
  if "data" in kwargs:
83
92
  data = kwargs.get("data").copy()
84
-
85
- self._shipments: typing.List[models.Shipment] = (
86
- models.Shipment.objects.filter(
87
- tracking_number__in=data.get("tracking_numbers", [])
93
+ tracking_numbers = data.get("tracking_numbers", [])
94
+
95
+ # Only fetch shipments if tracking_numbers provided
96
+ if tracking_numbers:
97
+ self._shipments = list(
98
+ models.Shipment.objects.filter(
99
+ tracking_number__in=tracking_numbers
100
+ )
88
101
  )
89
- )
90
102
 
103
+ # Address resolution logic
91
104
  if data.get("address") is None and instance is None:
105
+ # Try to get address from linked shipments
92
106
  address = next(
93
- (AddressData(s.shipper).data for s in self._shipments), None
107
+ (s.shipper for s in self._shipments if s.shipper), None
94
108
  )
95
109
  elif data.get("address") is None and instance is not None:
96
- address = AddressData(instance.address).data
97
- elif data.get("address") is str:
98
- address = models.Shipment.objects.get(pk=data.get("address"))
110
+ # Use existing instance address
111
+ address = instance.address
112
+ elif isinstance(data.get("address"), str):
113
+ # Legacy: look up address by ID
114
+ address = models.Address.objects.get(pk=data.get("address"))
99
115
  else:
100
116
  address = data.get("address")
101
117
 
@@ -109,12 +125,29 @@ class PickupSerializer(PickupRequest):
109
125
  def validate(self, data):
110
126
  validated_data = super(PickupRequest, self).validate(data)
111
127
 
112
- if (
113
- len(validated_data.get("tracking_numbers", [])) > 1
114
- and validated_data.get("address") is None
115
- ):
128
+ tracking_numbers = validated_data.get("tracking_numbers", [])
129
+ parcels_count = validated_data.get("parcels_count")
130
+ address = validated_data.get("address")
131
+
132
+ # Must have at least one source of parcel info
133
+ if not tracking_numbers and not parcels_count:
134
+ raise serializers.ValidationError(
135
+ "At least one of tracking_numbers or parcels_count must be provided",
136
+ code="required"
137
+ )
138
+
139
+ # Address required for standalone pickups (no tracking_numbers)
140
+ if not tracking_numbers and not address:
141
+ raise serializers.ValidationError(
142
+ "address is required when not linking to shipments",
143
+ code="required"
144
+ )
145
+
146
+ # Existing validation for multi-shipment pickups
147
+ if len(tracking_numbers) > 1 and address is None:
116
148
  raise serializers.ValidationError(
117
- "address must be specified for multi-shipments pickup", code="required"
149
+ "address must be specified for multi-shipments pickup",
150
+ code="required"
118
151
  )
119
152
 
120
153
  return validated_data
@@ -124,30 +157,60 @@ class PickupSerializer(PickupRequest):
124
157
  class PickupData(PickupSerializer):
125
158
  def create(self, validated_data: dict, context: Context, **kwargs) -> models.Pickup:
126
159
  carrier_filter = validated_data["carrier_filter"]
127
- shipment_identifiers = [
128
- _
129
- for shipment in self._shipments
130
- for _ in set(
131
- [
132
- *(shipment.meta.get("shipment_identifiers") or []),
133
- shipment.shipment_identifier,
134
- ]
160
+ parcels_count = validated_data.get("parcels_count")
161
+ pickup_type = validated_data.get("pickup_type", "one_time")
162
+ recurrence = validated_data.get("recurrence") or {}
163
+
164
+ # Extract shipment identifiers only if shipments linked
165
+ shipment_identifiers = []
166
+ billing_number = None
167
+
168
+ if self._shipments:
169
+ shipment_identifiers = [
170
+ _
171
+ for shipment in self._shipments
172
+ for _ in set(
173
+ [
174
+ *(shipment.meta.get("shipment_identifiers") or []),
175
+ shipment.shipment_identifier,
176
+ ]
177
+ )
178
+ ]
179
+ # Extract billing_number from first shipment's meta (if available)
180
+ billing_number = next(
181
+ (s.meta.get("billing_number") for s in self._shipments if s.meta.get("billing_number")),
182
+ None,
135
183
  )
136
- ]
137
- carrier = Carriers.first(
184
+
185
+ carrier = Connections.first(
138
186
  context=context,
139
187
  **{"raise_not_found": True, **DEFAULT_CARRIER_FILTER, **carrier_filter},
140
188
  )
141
- request_data = PickupRequest(
142
- {
143
- **validated_data,
144
- "parcels": sum([list(s.parcels.all()) for s in self._shipments], []),
145
- "options": {
146
- "shipment_identifiers": shipment_identifiers,
147
- **(validated_data.get("options") or {}),
148
- },
149
- }
150
- ).data
189
+
190
+ # Determine parcels source
191
+ if self._shipments:
192
+ # Mode 1: Parcels from linked shipments
193
+ parcels_list = sum([(s.parcels or []) for s in self._shipments], [])
194
+ elif parcels_count:
195
+ # Mode 2: Generate placeholder parcels from count
196
+ parcels_list = [{"id": f"parcel_{i+1}"} for i in range(parcels_count)]
197
+ else:
198
+ parcels_list = []
199
+
200
+ # Build request data directly (address is now a JSON dict)
201
+ # Exclude non-serializable fields from request data
202
+ excluded_keys = {"created_by", "carrier_filter", "tracking_numbers", "parcels_count", "recurrence"}
203
+ filtered_data = {k: v for k, v in validated_data.items() if k not in excluded_keys}
204
+
205
+ request_data = {
206
+ **filtered_data,
207
+ "parcels": parcels_list,
208
+ "options": {
209
+ **({"shipment_identifiers": shipment_identifiers} if shipment_identifiers else {}),
210
+ **({"billing_number": billing_number} if billing_number else {}),
211
+ **(validated_data.get("options") or {}),
212
+ },
213
+ }
151
214
 
152
215
  response = Pickups.schedule(payload=request_data, carrier=carrier)
153
216
  payload = {
@@ -155,18 +218,26 @@ class PickupData(PickupSerializer):
155
218
  for key, value in Pickup(response.pickup).data.items()
156
219
  if key in models.Pickup.DIRECT_PROPS
157
220
  }
158
- address = save_one_to_one_data(
159
- "address", AddressSerializer, payload=validated_data, context=context
160
- )
221
+
222
+ # Use the address from validated_data directly (JSON field)
223
+ address_data = validated_data.get("address") or {}
224
+
225
+ # Build meta with pickup_type and recurrence (stored in meta per PRD)
226
+ meta_data = {
227
+ **(payload.get("meta") or {}),
228
+ "pickup_type": pickup_type,
229
+ **({"recurrence": recurrence} if recurrence else {}),
230
+ }
161
231
 
162
232
  pickup = models.Pickup.objects.create(
163
233
  **{
164
234
  **payload,
165
- "address": address,
166
- "pickup_carrier": carrier,
235
+ "address": address_data,
236
+ "carrier": create_carrier_snapshot(carrier),
167
237
  "created_by": context.user,
168
238
  "test_mode": response.pickup.test_mode,
169
239
  "confirmation_number": response.pickup.confirmation_number,
240
+ "meta": meta_data,
170
241
  }
171
242
  )
172
243
  pickup.shipments.set(self._shipments)
@@ -219,6 +290,22 @@ class PickupUpdateData(PickupSerializer):
219
290
  help_text="The list of shipments to be picked up",
220
291
  )
221
292
 
293
+ def validate(self, data):
294
+ """Override validation for update - existing pickups don't need parcel source validation."""
295
+ # Skip the parent's tracking_numbers/parcels_count validation for updates
296
+ # The pickup already exists with its parcels, we're just updating details
297
+ validated_data = serializers.Serializer.validate(self, data)
298
+
299
+ # Only validate multi-shipment address requirement if tracking_numbers provided
300
+ tracking_numbers = validated_data.get("tracking_numbers", [])
301
+ if len(tracking_numbers) > 1 and validated_data.get("address") is None:
302
+ raise serializers.ValidationError(
303
+ "address must be specified for multi-shipments pickup",
304
+ code="required"
305
+ )
306
+
307
+ return validated_data
308
+
222
309
  def update(
223
310
  self, instance: models.Pickup, validated_data: dict, context: dict, **kwargs
224
311
  ) -> models.Tracking:
@@ -233,36 +320,74 @@ class PickupUpdateData(PickupSerializer):
233
320
  )
234
321
  ]
235
322
 
236
- request_data = PickupUpdateRequest(
237
- {
238
- **PickupUpdateRequest(instance).data,
239
- **validated_data,
240
- "address": AddressData(
241
- {**AddressData(instance.address).data, **validated_data["address"]}
242
- ).data,
243
- "options": {
244
- "shipment_identifiers": shipment_identifiers,
245
- **(instance.meta or {}),
246
- **(validated_data.get("options") or {}),
247
- },
248
- }
249
- ).data
323
+ # Merge existing address with updates (address is now a JSON field)
324
+ existing_address = instance.address or {}
325
+ address_updates = validated_data.get("address") or {}
326
+ merged_address = {**existing_address, **address_updates}
327
+
328
+ # Extract pickup_type and recurrence for meta
329
+ pickup_type = validated_data.get("pickup_type") or (instance.meta or {}).get("pickup_type", "one_time")
330
+ recurrence = validated_data.get("recurrence") or (instance.meta or {}).get("recurrence")
250
331
 
251
- Pickups.update(payload=request_data, carrier=instance.pickup_carrier)
332
+ # Build base data from instance fields directly (not via serializer)
333
+ # Convert date to string for serializer validation
334
+ pickup_date = (
335
+ str(instance.pickup_date) if instance.pickup_date else None
336
+ )
337
+ base_data = {
338
+ "pickup_date": pickup_date,
339
+ "address": existing_address,
340
+ "parcels": instance.parcels,
341
+ "confirmation_number": instance.confirmation_number,
342
+ "ready_time": instance.ready_time,
343
+ "closing_time": instance.closing_time,
344
+ "instruction": instance.instruction,
345
+ "package_location": instance.package_location,
346
+ "options": instance.options or {},
347
+ "pickup_type": pickup_type,
348
+ }
349
+
350
+ # Extract billing_number from first shipment's meta (if available)
351
+ billing_number = next(
352
+ (s.meta.get("billing_number") for s in self._shipments if s.meta.get("billing_number")),
353
+ None,
354
+ )
355
+
356
+ # Build request data directly (data comes from trusted sources)
357
+ request_data = {
358
+ **base_data,
359
+ **validated_data,
360
+ "address": merged_address,
361
+ "options": {
362
+ "shipment_identifiers": shipment_identifiers,
363
+ **({"billing_number": billing_number} if billing_number else {}),
364
+ **(instance.meta or {}),
365
+ **(validated_data.get("options") or {}),
366
+ },
367
+ }
368
+
369
+ # Resolve carrier from snapshot for API call
370
+ carrier = resolve_carrier(instance.carrier, context)
371
+ Pickups.update(payload=request_data, carrier=carrier)
252
372
 
253
373
  data = validated_data.copy()
254
374
  for key, val in data.items():
375
+ # Skip address and meta-stored fields - they need special handling
376
+ if key in ("address", "pickup_type", "recurrence"):
377
+ continue
255
378
  if key in models.Pickup.DIRECT_PROPS:
256
379
  setattr(instance, key, val)
257
- validated_data.pop(key)
258
380
 
259
- save_one_to_one_data(
260
- "address",
261
- AddressSerializer,
262
- instance,
263
- payload=validated_data,
264
- context=context,
265
- )
381
+ # Always set the merged address (preserves existing fields while applying updates)
382
+ instance.address = merged_address
383
+
384
+ # Update meta with pickup_type and recurrence
385
+ existing_meta = instance.meta or {}
386
+ instance.meta = {
387
+ **existing_meta,
388
+ "pickup_type": pickup_type,
389
+ **({"recurrence": recurrence} if recurrence else {}),
390
+ }
266
391
 
267
392
  instance.save()
268
393
  return instance
@@ -274,12 +399,14 @@ class PickupCancelData(serializers.Serializer):
274
399
  )
275
400
 
276
401
  def update(
277
- self, instance: models.Pickup, validated_data: dict, **kwargs
402
+ self, instance: models.Pickup, validated_data: dict, context: Context = None, **kwargs
278
403
  ) -> Confirmation:
279
404
  request = PickupCancelRequest(
280
405
  {**PickupCancelRequest(instance).data, **validated_data}
281
406
  )
282
- Pickups.cancel(payload=request.data, carrier=instance.pickup_carrier)
407
+ # Resolve carrier from snapshot for API call
408
+ carrier = resolve_carrier(instance.carrier, context)
409
+ Pickups.cancel(payload=request.data, carrier=carrier)
283
410
  instance.delete()
284
411
 
285
412
  return instance