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.
- karrio/server/manager/migrations/0070_add_meta_and_product_fields.py +98 -0
- karrio/server/manager/migrations/0071_product_proxy.py +25 -0
- karrio/server/manager/migrations/0072_populate_json_fields.py +267 -0
- karrio/server/manager/migrations/0073_make_shipment_fk_nullable.py +36 -0
- karrio/server/manager/migrations/0074_clean_model_refactoring.py +207 -0
- karrio/server/manager/migrations/0075_populate_template_meta.py +69 -0
- karrio/server/manager/migrations/0076_remove_customs_model.py +66 -0
- karrio/server/manager/migrations/0077_add_carrier_snapshot_fields.py +83 -0
- karrio/server/manager/migrations/0078_populate_carrier_snapshots.py +112 -0
- karrio/server/manager/migrations/0079_remove_carrier_fk_fields.py +56 -0
- karrio/server/manager/migrations/0080_add_carrier_json_indexes.py +137 -0
- karrio/server/manager/migrations/0081_cleanup.py +62 -0
- karrio/server/manager/migrations/0082_shipment_fees.py +26 -0
- karrio/server/manager/models.py +421 -321
- karrio/server/manager/serializers/__init__.py +5 -4
- karrio/server/manager/serializers/address.py +8 -2
- karrio/server/manager/serializers/commodity.py +11 -4
- karrio/server/manager/serializers/document.py +29 -15
- karrio/server/manager/serializers/manifest.py +6 -3
- karrio/server/manager/serializers/parcel.py +5 -2
- karrio/server/manager/serializers/pickup.py +194 -67
- karrio/server/manager/serializers/shipment.py +232 -152
- karrio/server/manager/serializers/tracking.py +53 -12
- karrio/server/manager/tests/__init__.py +0 -1
- karrio/server/manager/tests/test_addresses.py +53 -0
- karrio/server/manager/tests/test_parcels.py +50 -0
- karrio/server/manager/tests/test_pickups.py +286 -50
- karrio/server/manager/tests/test_products.py +597 -0
- karrio/server/manager/tests/test_shipments.py +237 -92
- karrio/server/manager/tests/test_trackers.py +65 -1
- karrio/server/manager/views/__init__.py +1 -1
- karrio/server/manager/views/addresses.py +38 -2
- karrio/server/manager/views/documents.py +1 -1
- karrio/server/manager/views/parcels.py +25 -2
- karrio/server/manager/views/products.py +239 -0
- karrio/server/manager/views/trackers.py +69 -1
- {karrio_server_manager-2026.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/METADATA +1 -1
- {karrio_server_manager-2026.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/RECORD +40 -28
- {karrio_server_manager-2026.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/WHEEL +1 -1
- karrio/server/manager/serializers/customs.py +0 -84
- karrio/server/manager/tests/test_custom_infos.py +0 -101
- karrio/server/manager/views/customs.py +0 -159
- {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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
21
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
91
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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=
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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",
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 =
|
|
184
|
+
|
|
185
|
+
carrier = Connections.first(
|
|
138
186
|
context=context,
|
|
139
187
|
**{"raise_not_found": True, **DEFAULT_CARRIER_FILTER, **carrier_filter},
|
|
140
188
|
)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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":
|
|
166
|
-
"
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|