karrio-server-manager 2026.1.3__py3-none-any.whl → 2026.1.5__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/0083_pickup_status.py +23 -0
- karrio/server/manager/models.py +6 -0
- karrio/server/manager/serializers/__init__.py +1 -0
- karrio/server/manager/serializers/pickup.py +65 -8
- karrio/server/manager/tests/test_pickups.py +477 -10
- karrio/server/manager/views/pickups.py +59 -8
- {karrio_server_manager-2026.1.3.dist-info → karrio_server_manager-2026.1.5.dist-info}/METADATA +1 -1
- {karrio_server_manager-2026.1.3.dist-info → karrio_server_manager-2026.1.5.dist-info}/RECORD +10 -9
- {karrio_server_manager-2026.1.3.dist-info → karrio_server_manager-2026.1.5.dist-info}/WHEEL +0 -0
- {karrio_server_manager-2026.1.3.dist-info → karrio_server_manager-2026.1.5.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Generated by Django 5.2.10 on 2026-01-31
|
|
2
|
+
# Adds status field to Pickup for lifecycle management
|
|
3
|
+
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
("manager", "0082_shipment_fees"),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AddField(
|
|
15
|
+
model_name="pickup",
|
|
16
|
+
name="status",
|
|
17
|
+
field=models.CharField(
|
|
18
|
+
db_index=True,
|
|
19
|
+
default="scheduled",
|
|
20
|
+
max_length=25,
|
|
21
|
+
),
|
|
22
|
+
),
|
|
23
|
+
]
|
karrio/server/manager/models.py
CHANGED
|
@@ -472,6 +472,7 @@ class Pickup(core.OwnedEntity):
|
|
|
472
472
|
"created_by",
|
|
473
473
|
"metadata",
|
|
474
474
|
"meta",
|
|
475
|
+
"status",
|
|
475
476
|
"address", # Embedded JSON field
|
|
476
477
|
"carrier", # Carrier snapshot
|
|
477
478
|
]
|
|
@@ -496,6 +497,11 @@ class Pickup(core.OwnedEntity):
|
|
|
496
497
|
closing_time = models.CharField(max_length=5, blank=False)
|
|
497
498
|
instruction = models.CharField(max_length=250, null=True, blank=True)
|
|
498
499
|
package_location = models.CharField(max_length=250, null=True, blank=True)
|
|
500
|
+
status = models.CharField(
|
|
501
|
+
max_length=25,
|
|
502
|
+
default="scheduled",
|
|
503
|
+
db_index=True,
|
|
504
|
+
)
|
|
499
505
|
|
|
500
506
|
# ─────────────────────────────────────────────────────────────────
|
|
501
507
|
# EMBEDDED JSON FIELDS
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import typing
|
|
2
2
|
|
|
3
|
+
from rest_framework import status as http_status
|
|
4
|
+
|
|
3
5
|
from karrio.server import serializers
|
|
4
6
|
from karrio.server.serializers import (
|
|
5
7
|
owned_model_serializer,
|
|
@@ -12,17 +14,54 @@ from karrio.server.core.datatypes import Confirmation
|
|
|
12
14
|
from karrio.server.core.utils import create_carrier_snapshot, resolve_carrier
|
|
13
15
|
from karrio.server.core.serializers import (
|
|
14
16
|
Pickup,
|
|
17
|
+
PickupStatus,
|
|
15
18
|
AddressData,
|
|
16
19
|
PickupRequest,
|
|
17
20
|
PickupUpdateRequest,
|
|
18
21
|
PickupCancelRequest,
|
|
19
22
|
)
|
|
23
|
+
import karrio.server.core.exceptions as exceptions
|
|
20
24
|
from karrio.server.manager.serializers import AddressSerializer
|
|
21
25
|
import karrio.server.manager.models as models
|
|
22
26
|
|
|
23
27
|
DEFAULT_CARRIER_FILTER: typing.Any = dict(active=True, capability="pickup")
|
|
24
28
|
|
|
25
29
|
|
|
30
|
+
def can_mutate_pickup(
|
|
31
|
+
pickup: models.Pickup,
|
|
32
|
+
update: bool = False,
|
|
33
|
+
cancel: bool = False,
|
|
34
|
+
payload: dict = None,
|
|
35
|
+
):
|
|
36
|
+
# Allow metadata-only updates regardless of status
|
|
37
|
+
if update and [*(payload or {}).keys()] == ["metadata"]:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
# Cannot update cancelled pickups
|
|
41
|
+
if update and pickup.status == PickupStatus.cancelled.value:
|
|
42
|
+
raise exceptions.APIException(
|
|
43
|
+
f"The pickup is '{pickup.status}' and cannot be updated",
|
|
44
|
+
code="state_error",
|
|
45
|
+
status_code=http_status.HTTP_409_CONFLICT,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Cannot update or cancel closed pickups
|
|
49
|
+
if (update or cancel) and pickup.status == PickupStatus.closed.value:
|
|
50
|
+
raise exceptions.APIException(
|
|
51
|
+
f"The pickup is '{pickup.status}' and cannot be modified",
|
|
52
|
+
code="state_error",
|
|
53
|
+
status_code=http_status.HTTP_409_CONFLICT,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Cannot update picked_up pickups (but can cancel)
|
|
57
|
+
if update and pickup.status == PickupStatus.picked_up.value:
|
|
58
|
+
raise exceptions.APIException(
|
|
59
|
+
f"The pickup is '{pickup.status}' and cannot be updated",
|
|
60
|
+
code="state_error",
|
|
61
|
+
status_code=http_status.HTTP_409_CONFLICT,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
26
65
|
def shipment_exists(value):
|
|
27
66
|
validation = {
|
|
28
67
|
key: models.Shipment.objects.filter(tracking_number=key) for key in value
|
|
@@ -34,11 +73,18 @@ def shipment_exists(value):
|
|
|
34
73
|
f"Shipment with the tracking numbers: {invalids} not found", code="invalid"
|
|
35
74
|
)
|
|
36
75
|
|
|
37
|
-
if any(
|
|
76
|
+
if any(
|
|
77
|
+
val.first().shipment_pickup.exclude(
|
|
78
|
+
status__in=["cancelled", "closed"]
|
|
79
|
+
).exists()
|
|
80
|
+
for val in validation.values()
|
|
81
|
+
):
|
|
38
82
|
scheduled = [
|
|
39
83
|
key
|
|
40
84
|
for key, val in validation.items()
|
|
41
|
-
if val.first().shipment_pickup.
|
|
85
|
+
if val.first().shipment_pickup.exclude(
|
|
86
|
+
status__in=["cancelled", "closed"]
|
|
87
|
+
).exists()
|
|
42
88
|
]
|
|
43
89
|
raise serializers.ValidationError(
|
|
44
90
|
f"The following shipments {scheduled} are already scheduled for pickups",
|
|
@@ -156,11 +202,21 @@ class PickupSerializer(PickupRequest):
|
|
|
156
202
|
@owned_model_serializer
|
|
157
203
|
class PickupData(PickupSerializer):
|
|
158
204
|
def create(self, validated_data: dict, context: Context, **kwargs) -> models.Pickup:
|
|
159
|
-
carrier_filter = validated_data
|
|
205
|
+
carrier_filter = validated_data.get("carrier_filter") or {}
|
|
206
|
+
carrier_code = validated_data.get("carrier_code")
|
|
207
|
+
options = validated_data.get("options") or {}
|
|
208
|
+
connection_id = options.get("connection_id")
|
|
160
209
|
parcels_count = validated_data.get("parcels_count")
|
|
161
210
|
pickup_type = validated_data.get("pickup_type", "one_time")
|
|
162
211
|
recurrence = validated_data.get("recurrence") or {}
|
|
163
212
|
|
|
213
|
+
# Build carrier filter from body fields when no URL-based filter provided
|
|
214
|
+
if not carrier_filter:
|
|
215
|
+
if carrier_code:
|
|
216
|
+
carrier_filter["carrier_name"] = carrier_code
|
|
217
|
+
if connection_id:
|
|
218
|
+
carrier_filter["carrier_id"] = connection_id
|
|
219
|
+
|
|
164
220
|
# Extract shipment identifiers only if shipments linked
|
|
165
221
|
shipment_identifiers = []
|
|
166
222
|
billing_number = None
|
|
@@ -199,7 +255,7 @@ class PickupData(PickupSerializer):
|
|
|
199
255
|
|
|
200
256
|
# Build request data directly (address is now a JSON dict)
|
|
201
257
|
# Exclude non-serializable fields from request data
|
|
202
|
-
excluded_keys = {"created_by", "carrier_filter", "tracking_numbers", "parcels_count", "recurrence"}
|
|
258
|
+
excluded_keys = {"created_by", "carrier_filter", "carrier_code", "tracking_numbers", "parcels_count", "recurrence"}
|
|
203
259
|
filtered_data = {k: v for k, v in validated_data.items() if k not in excluded_keys}
|
|
204
260
|
|
|
205
261
|
request_data = {
|
|
@@ -212,7 +268,7 @@ class PickupData(PickupSerializer):
|
|
|
212
268
|
},
|
|
213
269
|
}
|
|
214
270
|
|
|
215
|
-
response = Pickups.schedule(payload=request_data, carrier=carrier)
|
|
271
|
+
response = Pickups.schedule(payload=request_data, carrier=carrier, context=context)
|
|
216
272
|
payload = {
|
|
217
273
|
key: value
|
|
218
274
|
for key, value in Pickup(response.pickup).data.items()
|
|
@@ -368,7 +424,7 @@ class PickupUpdateData(PickupSerializer):
|
|
|
368
424
|
|
|
369
425
|
# Resolve carrier from snapshot for API call
|
|
370
426
|
carrier = resolve_carrier(instance.carrier, context)
|
|
371
|
-
Pickups.update(payload=request_data, carrier=carrier)
|
|
427
|
+
Pickups.update(payload=request_data, carrier=carrier, context=context)
|
|
372
428
|
|
|
373
429
|
data = validated_data.copy()
|
|
374
430
|
for key, val in data.items():
|
|
@@ -406,7 +462,8 @@ class PickupCancelData(serializers.Serializer):
|
|
|
406
462
|
)
|
|
407
463
|
# Resolve carrier from snapshot for API call
|
|
408
464
|
carrier = resolve_carrier(instance.carrier, context)
|
|
409
|
-
Pickups.cancel(payload=request.data, carrier=carrier)
|
|
410
|
-
instance.
|
|
465
|
+
Pickups.cancel(payload=request.data, carrier=carrier, context=context)
|
|
466
|
+
instance.status = PickupStatus.cancelled.value
|
|
467
|
+
instance.save(update_fields=["status", "updated_at"])
|
|
411
468
|
|
|
412
469
|
return instance
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import json
|
|
2
|
-
import logging
|
|
3
2
|
from unittest.mock import patch, ANY
|
|
4
3
|
from django.urls import reverse
|
|
5
4
|
from rest_framework import status
|
|
@@ -8,7 +7,6 @@ from karrio.server.manager.tests.test_shipments import TestShipmentFixture
|
|
|
8
7
|
from karrio.server.core.utils import create_carrier_snapshot
|
|
9
8
|
import karrio.server.manager.models as models
|
|
10
9
|
|
|
11
|
-
|
|
12
10
|
class TestFixture(TestShipmentFixture):
|
|
13
11
|
def setUp(self) -> None:
|
|
14
12
|
super().setUp()
|
|
@@ -43,7 +41,6 @@ class TestFixture(TestShipmentFixture):
|
|
|
43
41
|
self.shipment.carrier = create_carrier_snapshot(self.carrier)
|
|
44
42
|
self.shipment.save()
|
|
45
43
|
|
|
46
|
-
|
|
47
44
|
class TestPickupSchedule(TestFixture):
|
|
48
45
|
def test_schedule_pickup(self):
|
|
49
46
|
url = reverse(
|
|
@@ -73,6 +70,7 @@ class TestPickupSchedule(TestFixture):
|
|
|
73
70
|
|
|
74
71
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
75
72
|
self.assertEqual(response_data["confirmation_number"], "27241")
|
|
73
|
+
self.assertEqual(response_data["status"], "scheduled")
|
|
76
74
|
|
|
77
75
|
def test_schedule_pickup_validation_no_source(self):
|
|
78
76
|
"""Test that validation fails when neither tracking_numbers nor parcels_count is provided."""
|
|
@@ -162,7 +160,6 @@ class TestPickupSchedule(TestFixture):
|
|
|
162
160
|
self.assertEqual(response_data["recurrence"]["frequency"], "weekly")
|
|
163
161
|
self.assertIn("monday", response_data["recurrence"]["days_of_week"])
|
|
164
162
|
|
|
165
|
-
|
|
166
163
|
class TestPickupDetails(TestFixture):
|
|
167
164
|
def setUp(self) -> None:
|
|
168
165
|
super().setUp()
|
|
@@ -209,6 +206,372 @@ class TestPickupDetails(TestFixture):
|
|
|
209
206
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
210
207
|
self.assertDictEqual(response_data, PICKUP_CANCEL_RESPONSE)
|
|
211
208
|
|
|
209
|
+
class TestPickupStatusLifecycle(TestFixture):
|
|
210
|
+
"""Tests for pickup status lifecycle transitions."""
|
|
211
|
+
|
|
212
|
+
def setUp(self) -> None:
|
|
213
|
+
super().setUp()
|
|
214
|
+
self.pickup: models.Pickup = models.Pickup.objects.create(
|
|
215
|
+
address=self.address_data,
|
|
216
|
+
carrier=create_carrier_snapshot(self.carrier),
|
|
217
|
+
created_by=self.user,
|
|
218
|
+
test_mode=True,
|
|
219
|
+
pickup_date="2020-10-25",
|
|
220
|
+
ready_time="13:00",
|
|
221
|
+
closing_time="17:00",
|
|
222
|
+
instruction="Should not be folded",
|
|
223
|
+
package_location="At the main entrance hall",
|
|
224
|
+
confirmation_number="00110215",
|
|
225
|
+
pickup_charge={"name": "Pickup fees", "amount": 0.0, "currency": "CAD"},
|
|
226
|
+
)
|
|
227
|
+
self.pickup.shipments.set([self.shipment])
|
|
228
|
+
|
|
229
|
+
def test_pickup_created_with_scheduled_status(self):
|
|
230
|
+
"""New pickups should default to 'scheduled' status."""
|
|
231
|
+
self.assertEqual(self.pickup.status, "scheduled")
|
|
232
|
+
|
|
233
|
+
def test_cancel_sets_cancelled_status(self):
|
|
234
|
+
"""Cancelling a pickup sets status to 'cancelled' instead of deleting."""
|
|
235
|
+
url = reverse(
|
|
236
|
+
"karrio.server.manager:shipment-pickup-cancel",
|
|
237
|
+
kwargs=dict(pk=self.pickup.pk),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
with patch("karrio.server.core.gateway.utils.identity") as mock:
|
|
241
|
+
mock.return_value = CANCEL_RETURNED_VALUE
|
|
242
|
+
response = self.client.post(url, {})
|
|
243
|
+
response_data = json.loads(response.content)
|
|
244
|
+
|
|
245
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
246
|
+
self.assertEqual(response_data["status"], "cancelled")
|
|
247
|
+
|
|
248
|
+
# Pickup should still exist in the database
|
|
249
|
+
self.pickup.refresh_from_db()
|
|
250
|
+
self.assertEqual(self.pickup.status, "cancelled")
|
|
251
|
+
|
|
252
|
+
def test_cancelled_pickup_still_exists(self):
|
|
253
|
+
"""Cancelled pickups are not deleted from the database."""
|
|
254
|
+
url = reverse(
|
|
255
|
+
"karrio.server.manager:shipment-pickup-cancel",
|
|
256
|
+
kwargs=dict(pk=self.pickup.pk),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
with patch("karrio.server.core.gateway.utils.identity") as mock:
|
|
260
|
+
mock.return_value = CANCEL_RETURNED_VALUE
|
|
261
|
+
self.client.post(url, {})
|
|
262
|
+
|
|
263
|
+
# Pickup should still be queryable
|
|
264
|
+
pickup = models.Pickup.objects.get(pk=self.pickup.pk)
|
|
265
|
+
self.assertEqual(pickup.status, "cancelled")
|
|
266
|
+
|
|
267
|
+
def test_schedule_creates_with_scheduled_status(self):
|
|
268
|
+
"""Scheduling a pickup via API creates it with 'scheduled' status."""
|
|
269
|
+
# Free the shipment from the existing pickup so it can be re-scheduled
|
|
270
|
+
self.pickup.shipments.clear()
|
|
271
|
+
|
|
272
|
+
url = reverse(
|
|
273
|
+
"karrio.server.manager:shipment-pickup-request",
|
|
274
|
+
kwargs=dict(carrier_name="canadapost"),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
with patch("karrio.server.core.gateway.utils.identity") as mock:
|
|
278
|
+
mock.return_value = SCHEDULE_RETURNED_VALUE
|
|
279
|
+
response = self.client.post(f"{url}", PICKUP_DATA)
|
|
280
|
+
response_data = json.loads(response.content)
|
|
281
|
+
|
|
282
|
+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
283
|
+
self.assertEqual(response_data["status"], "scheduled")
|
|
284
|
+
|
|
285
|
+
class TestPickupStatusFilter(TestFixture):
|
|
286
|
+
"""Tests for pickup status filtering."""
|
|
287
|
+
|
|
288
|
+
def setUp(self) -> None:
|
|
289
|
+
super().setUp()
|
|
290
|
+
# Create pickups with different statuses
|
|
291
|
+
self.scheduled_pickup = models.Pickup.objects.create(
|
|
292
|
+
address=self.address_data,
|
|
293
|
+
carrier=create_carrier_snapshot(self.carrier),
|
|
294
|
+
created_by=self.user,
|
|
295
|
+
test_mode=True,
|
|
296
|
+
pickup_date="2020-10-25",
|
|
297
|
+
ready_time="13:00",
|
|
298
|
+
closing_time="17:00",
|
|
299
|
+
confirmation_number="SCH001",
|
|
300
|
+
status="scheduled",
|
|
301
|
+
)
|
|
302
|
+
self.cancelled_pickup = models.Pickup.objects.create(
|
|
303
|
+
address=self.address_data,
|
|
304
|
+
carrier=create_carrier_snapshot(self.carrier),
|
|
305
|
+
created_by=self.user,
|
|
306
|
+
test_mode=True,
|
|
307
|
+
pickup_date="2020-10-26",
|
|
308
|
+
ready_time="13:00",
|
|
309
|
+
closing_time="17:00",
|
|
310
|
+
confirmation_number="CAN001",
|
|
311
|
+
status="cancelled",
|
|
312
|
+
)
|
|
313
|
+
self.closed_pickup = models.Pickup.objects.create(
|
|
314
|
+
address=self.address_data,
|
|
315
|
+
carrier=create_carrier_snapshot(self.carrier),
|
|
316
|
+
created_by=self.user,
|
|
317
|
+
test_mode=True,
|
|
318
|
+
pickup_date="2020-10-27",
|
|
319
|
+
ready_time="13:00",
|
|
320
|
+
closing_time="17:00",
|
|
321
|
+
confirmation_number="CLO001",
|
|
322
|
+
status="closed",
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def test_filter_by_scheduled_status(self):
|
|
326
|
+
url = reverse("karrio.server.manager:shipment-pickup-list")
|
|
327
|
+
|
|
328
|
+
response = self.client.get(f"{url}?status=scheduled")
|
|
329
|
+
response_data = json.loads(response.content)
|
|
330
|
+
|
|
331
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
332
|
+
confirmation_numbers = [r["confirmation_number"] for r in response_data["results"]]
|
|
333
|
+
self.assertIn("SCH001", confirmation_numbers)
|
|
334
|
+
self.assertNotIn("CAN001", confirmation_numbers)
|
|
335
|
+
self.assertNotIn("CLO001", confirmation_numbers)
|
|
336
|
+
|
|
337
|
+
def test_filter_by_cancelled_status(self):
|
|
338
|
+
url = reverse("karrio.server.manager:shipment-pickup-list")
|
|
339
|
+
|
|
340
|
+
response = self.client.get(f"{url}?status=cancelled")
|
|
341
|
+
response_data = json.loads(response.content)
|
|
342
|
+
|
|
343
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
344
|
+
confirmation_numbers = [r["confirmation_number"] for r in response_data["results"]]
|
|
345
|
+
self.assertIn("CAN001", confirmation_numbers)
|
|
346
|
+
self.assertNotIn("SCH001", confirmation_numbers)
|
|
347
|
+
|
|
348
|
+
def test_filter_by_multiple_statuses(self):
|
|
349
|
+
url = reverse("karrio.server.manager:shipment-pickup-list")
|
|
350
|
+
|
|
351
|
+
response = self.client.get(f"{url}?status=scheduled&status=closed")
|
|
352
|
+
response_data = json.loads(response.content)
|
|
353
|
+
|
|
354
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
355
|
+
confirmation_numbers = [r["confirmation_number"] for r in response_data["results"]]
|
|
356
|
+
self.assertIn("SCH001", confirmation_numbers)
|
|
357
|
+
self.assertIn("CLO001", confirmation_numbers)
|
|
358
|
+
self.assertNotIn("CAN001", confirmation_numbers)
|
|
359
|
+
|
|
360
|
+
class TestPickupGuardrails(TestFixture):
|
|
361
|
+
"""Tests for pickup status guardrails preventing invalid mutations."""
|
|
362
|
+
|
|
363
|
+
def setUp(self) -> None:
|
|
364
|
+
super().setUp()
|
|
365
|
+
self.pickup: models.Pickup = models.Pickup.objects.create(
|
|
366
|
+
address=self.address_data,
|
|
367
|
+
carrier=create_carrier_snapshot(self.carrier),
|
|
368
|
+
created_by=self.user,
|
|
369
|
+
test_mode=True,
|
|
370
|
+
pickup_date="2020-10-25",
|
|
371
|
+
ready_time="13:00",
|
|
372
|
+
closing_time="17:00",
|
|
373
|
+
instruction="Should not be folded",
|
|
374
|
+
package_location="At the main entrance hall",
|
|
375
|
+
confirmation_number="00110215",
|
|
376
|
+
pickup_charge={"name": "Pickup fees", "amount": 0.0, "currency": "CAD"},
|
|
377
|
+
)
|
|
378
|
+
self.pickup.shipments.set([self.shipment])
|
|
379
|
+
|
|
380
|
+
def test_update_cancelled_pickup_returns_409(self):
|
|
381
|
+
"""Cannot update a cancelled pickup."""
|
|
382
|
+
self.pickup.status = "cancelled"
|
|
383
|
+
self.pickup.save(update_fields=["status"])
|
|
384
|
+
|
|
385
|
+
url = reverse(
|
|
386
|
+
"karrio.server.manager:shipment-pickup-details",
|
|
387
|
+
kwargs=dict(pk=self.pickup.pk),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
response = self.client.post(url, PICKUP_UPDATE_DATA)
|
|
391
|
+
|
|
392
|
+
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
|
|
393
|
+
|
|
394
|
+
def test_update_closed_pickup_returns_409(self):
|
|
395
|
+
"""Cannot update a closed pickup."""
|
|
396
|
+
self.pickup.status = "closed"
|
|
397
|
+
self.pickup.save(update_fields=["status"])
|
|
398
|
+
|
|
399
|
+
url = reverse(
|
|
400
|
+
"karrio.server.manager:shipment-pickup-details",
|
|
401
|
+
kwargs=dict(pk=self.pickup.pk),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
response = self.client.post(url, PICKUP_UPDATE_DATA)
|
|
405
|
+
|
|
406
|
+
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
|
|
407
|
+
|
|
408
|
+
def test_cancel_closed_pickup_returns_409(self):
|
|
409
|
+
"""Cannot cancel a closed pickup."""
|
|
410
|
+
self.pickup.status = "closed"
|
|
411
|
+
self.pickup.save(update_fields=["status"])
|
|
412
|
+
|
|
413
|
+
url = reverse(
|
|
414
|
+
"karrio.server.manager:shipment-pickup-cancel",
|
|
415
|
+
kwargs=dict(pk=self.pickup.pk),
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
response = self.client.post(url, {})
|
|
419
|
+
|
|
420
|
+
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
|
|
421
|
+
|
|
422
|
+
def test_recancel_returns_409(self):
|
|
423
|
+
"""Re-cancelling an already cancelled pickup returns 409."""
|
|
424
|
+
self.pickup.status = "cancelled"
|
|
425
|
+
self.pickup.save(update_fields=["status"])
|
|
426
|
+
|
|
427
|
+
url = reverse(
|
|
428
|
+
"karrio.server.manager:shipment-pickup-cancel",
|
|
429
|
+
kwargs=dict(pk=self.pickup.pk),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
response = self.client.post(url, {})
|
|
433
|
+
|
|
434
|
+
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
|
|
435
|
+
|
|
436
|
+
def test_update_picked_up_pickup_returns_409(self):
|
|
437
|
+
"""Cannot update a picked_up pickup."""
|
|
438
|
+
self.pickup.status = "picked_up"
|
|
439
|
+
self.pickup.save(update_fields=["status"])
|
|
440
|
+
|
|
441
|
+
url = reverse(
|
|
442
|
+
"karrio.server.manager:shipment-pickup-details",
|
|
443
|
+
kwargs=dict(pk=self.pickup.pk),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
response = self.client.post(url, PICKUP_UPDATE_DATA)
|
|
447
|
+
|
|
448
|
+
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
|
|
449
|
+
|
|
450
|
+
def test_cancel_picked_up_pickup_allowed(self):
|
|
451
|
+
"""Can cancel a picked_up pickup (but not update it)."""
|
|
452
|
+
self.pickup.status = "picked_up"
|
|
453
|
+
self.pickup.save(update_fields=["status"])
|
|
454
|
+
|
|
455
|
+
url = reverse(
|
|
456
|
+
"karrio.server.manager:shipment-pickup-cancel",
|
|
457
|
+
kwargs=dict(pk=self.pickup.pk),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
with patch("karrio.server.core.gateway.utils.identity") as mock:
|
|
461
|
+
mock.return_value = CANCEL_RETURNED_VALUE
|
|
462
|
+
response = self.client.post(url, {})
|
|
463
|
+
|
|
464
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
465
|
+
|
|
466
|
+
def test_metadata_update_allowed_on_cancelled(self):
|
|
467
|
+
"""Metadata-only updates are allowed regardless of status."""
|
|
468
|
+
self.pickup.status = "cancelled"
|
|
469
|
+
self.pickup.save(update_fields=["status"])
|
|
470
|
+
|
|
471
|
+
url = reverse(
|
|
472
|
+
"karrio.server.manager:shipment-pickup-details",
|
|
473
|
+
kwargs=dict(pk=self.pickup.pk),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
with patch("karrio.server.core.gateway.utils.identity") as mock:
|
|
477
|
+
mock.return_value = UPDATE_RETURNED_VALUE
|
|
478
|
+
response = self.client.post(url, {"metadata": {"note": "important"}})
|
|
479
|
+
|
|
480
|
+
# Metadata-only updates bypass the guard
|
|
481
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
482
|
+
|
|
483
|
+
class TestPickupScheduleNewAPI(TestFixture):
|
|
484
|
+
"""Tests for the new POST /v1/pickups endpoint with carrier_code in body."""
|
|
485
|
+
|
|
486
|
+
def test_schedule_pickup_with_carrier_code(self):
|
|
487
|
+
url = reverse("karrio.server.manager:shipment-pickup-list")
|
|
488
|
+
|
|
489
|
+
with patch("karrio.server.core.gateway.utils.identity") as mock:
|
|
490
|
+
mock.return_value = SCHEDULE_RETURNED_VALUE
|
|
491
|
+
response = self.client.post(f"{url}", PICKUP_DATA_NEW_API)
|
|
492
|
+
response_data = json.loads(response.content)
|
|
493
|
+
|
|
494
|
+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
495
|
+
self.assertDictEqual(response_data, PICKUP_RESPONSE)
|
|
496
|
+
|
|
497
|
+
def test_schedule_pickup_with_connection_id(self):
|
|
498
|
+
url = reverse("karrio.server.manager:shipment-pickup-list")
|
|
499
|
+
|
|
500
|
+
with patch("karrio.server.core.gateway.utils.identity") as mock:
|
|
501
|
+
mock.return_value = SCHEDULE_RETURNED_VALUE
|
|
502
|
+
response = self.client.post(f"{url}", PICKUP_DATA_WITH_CONNECTION_ID)
|
|
503
|
+
response_data = json.loads(response.content)
|
|
504
|
+
|
|
505
|
+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
506
|
+
self.assertEqual(response_data["confirmation_number"], "27241")
|
|
507
|
+
self.assertEqual(response_data["carrier_name"], "canadapost")
|
|
508
|
+
self.assertEqual(response_data["carrier_id"], "canadapost")
|
|
509
|
+
|
|
510
|
+
def test_schedule_pickup_standalone_with_carrier_code(self):
|
|
511
|
+
url = reverse("karrio.server.manager:shipment-pickup-list")
|
|
512
|
+
|
|
513
|
+
with patch("karrio.server.core.gateway.utils.identity") as mock:
|
|
514
|
+
mock.return_value = SCHEDULE_RETURNED_VALUE
|
|
515
|
+
response = self.client.post(f"{url}", PICKUP_DATA_NEW_API_STANDALONE)
|
|
516
|
+
response_data = json.loads(response.content)
|
|
517
|
+
|
|
518
|
+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
519
|
+
self.assertEqual(response_data["confirmation_number"], "27241")
|
|
520
|
+
|
|
521
|
+
def test_schedule_pickup_validation_no_source_new_api(self):
|
|
522
|
+
url = reverse("karrio.server.manager:shipment-pickup-list")
|
|
523
|
+
data = {
|
|
524
|
+
"carrier_code": "canadapost",
|
|
525
|
+
"pickup_date": "2020-10-25",
|
|
526
|
+
"ready_time": "13:00",
|
|
527
|
+
"closing_time": "17:00",
|
|
528
|
+
"address": {
|
|
529
|
+
"address_line1": "125 Church St",
|
|
530
|
+
"person_name": "John Doe",
|
|
531
|
+
"city": "Moncton",
|
|
532
|
+
"country_code": "CA",
|
|
533
|
+
"postal_code": "E1C4Z8",
|
|
534
|
+
},
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
response = self.client.post(f"{url}", data)
|
|
538
|
+
|
|
539
|
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
540
|
+
response_data = json.loads(response.content)
|
|
541
|
+
self.assertIn("errors", response_data)
|
|
542
|
+
|
|
543
|
+
class TestLegacyEndpointDeprecation(TestFixture):
|
|
544
|
+
"""Tests for the legacy endpoint deprecation headers."""
|
|
545
|
+
|
|
546
|
+
def test_legacy_endpoint_returns_deprecation_headers(self):
|
|
547
|
+
url = reverse(
|
|
548
|
+
"karrio.server.manager:shipment-pickup-request",
|
|
549
|
+
kwargs=dict(carrier_name="canadapost"),
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
with patch("karrio.server.core.gateway.utils.identity") as mock:
|
|
553
|
+
mock.return_value = SCHEDULE_RETURNED_VALUE
|
|
554
|
+
response = self.client.post(f"{url}", PICKUP_DATA)
|
|
555
|
+
|
|
556
|
+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
557
|
+
self.assertEqual(response["Deprecation"], "true")
|
|
558
|
+
self.assertIn("successor-version", response["Link"])
|
|
559
|
+
self.assertIn("/v1/pickups", response["Link"])
|
|
560
|
+
|
|
561
|
+
def test_legacy_endpoint_still_creates_pickup(self):
|
|
562
|
+
url = reverse(
|
|
563
|
+
"karrio.server.manager:shipment-pickup-request",
|
|
564
|
+
kwargs=dict(carrier_name="canadapost"),
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
with patch("karrio.server.core.gateway.utils.identity") as mock:
|
|
568
|
+
mock.return_value = SCHEDULE_RETURNED_VALUE
|
|
569
|
+
response = self.client.post(f"{url}", PICKUP_DATA)
|
|
570
|
+
response_data = json.loads(response.content)
|
|
571
|
+
|
|
572
|
+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
573
|
+
self.assertEqual(response_data["confirmation_number"], "27241")
|
|
574
|
+
self.assertEqual(response_data["carrier_name"], "canadapost")
|
|
212
575
|
|
|
213
576
|
PICKUP_DATA = {
|
|
214
577
|
"pickup_date": "2020-10-25",
|
|
@@ -357,7 +720,6 @@ PICKUP_UPDATE_DATA = {
|
|
|
357
720
|
"address": {"person_name": "Janet Jackson"},
|
|
358
721
|
}
|
|
359
722
|
|
|
360
|
-
|
|
361
723
|
SCHEDULE_RETURNED_VALUE = [
|
|
362
724
|
PickupDetails(
|
|
363
725
|
carrier_id="canadapost",
|
|
@@ -393,13 +755,14 @@ CANCEL_RETURNED_VALUE = [
|
|
|
393
755
|
[],
|
|
394
756
|
]
|
|
395
757
|
|
|
396
|
-
|
|
397
758
|
PICKUP_RESPONSE = {
|
|
398
759
|
"id": ANY,
|
|
399
760
|
"object_type": "pickup",
|
|
400
761
|
"carrier_name": "canadapost",
|
|
401
762
|
"carrier_id": "canadapost",
|
|
763
|
+
"carrier_code": "canadapost",
|
|
402
764
|
"confirmation_number": "27241",
|
|
765
|
+
"status": "scheduled",
|
|
403
766
|
"pickup_date": "2020-10-25",
|
|
404
767
|
"pickup_charge": {
|
|
405
768
|
"name": "Pickup fees",
|
|
@@ -468,7 +831,9 @@ PICKUP_UPDATE_RESPONSE = {
|
|
|
468
831
|
"object_type": "pickup",
|
|
469
832
|
"carrier_name": "canadapost",
|
|
470
833
|
"carrier_id": "canadapost",
|
|
834
|
+
"carrier_code": "canadapost",
|
|
471
835
|
"confirmation_number": "00110215",
|
|
836
|
+
"status": "scheduled",
|
|
472
837
|
"pickup_date": "2020-10-25",
|
|
473
838
|
"pickup_charge": {
|
|
474
839
|
"name": "Pickup fees",
|
|
@@ -533,22 +898,24 @@ PICKUP_UPDATE_RESPONSE = {
|
|
|
533
898
|
}
|
|
534
899
|
|
|
535
900
|
PICKUP_CANCEL_RESPONSE = {
|
|
536
|
-
"id":
|
|
901
|
+
"id": ANY,
|
|
537
902
|
"object_type": "pickup",
|
|
538
903
|
"carrier_name": "canadapost",
|
|
539
904
|
"carrier_id": "canadapost",
|
|
905
|
+
"carrier_code": "canadapost",
|
|
540
906
|
"confirmation_number": "00110215",
|
|
907
|
+
"status": "cancelled",
|
|
541
908
|
"pickup_date": "2020-10-25",
|
|
542
909
|
"pickup_charge": {
|
|
543
910
|
"name": "Pickup fees",
|
|
544
911
|
"amount": 0.0,
|
|
545
912
|
"currency": "CAD",
|
|
546
|
-
"id":
|
|
913
|
+
"id": ANY,
|
|
547
914
|
},
|
|
548
915
|
"ready_time": "13:00",
|
|
549
916
|
"closing_time": "17:00",
|
|
550
917
|
"address": {
|
|
551
|
-
"id": "adr_001122334455",
|
|
918
|
+
"id": "adr_001122334455",
|
|
552
919
|
"postal_code": "E1C4Z8",
|
|
553
920
|
"city": "Moncton",
|
|
554
921
|
"federal_tax_id": None,
|
|
@@ -568,7 +935,28 @@ PICKUP_CANCEL_RESPONSE = {
|
|
|
568
935
|
"validation": None,
|
|
569
936
|
"meta": {},
|
|
570
937
|
},
|
|
571
|
-
"parcels": [
|
|
938
|
+
"parcels": [
|
|
939
|
+
{
|
|
940
|
+
"id": ANY,
|
|
941
|
+
"object_type": "parcel",
|
|
942
|
+
"weight": 1.0,
|
|
943
|
+
"width": None,
|
|
944
|
+
"height": None,
|
|
945
|
+
"length": None,
|
|
946
|
+
"packaging_type": None,
|
|
947
|
+
"package_preset": "canadapost_corrugated_small_box",
|
|
948
|
+
"description": None,
|
|
949
|
+
"content": None,
|
|
950
|
+
"is_document": False,
|
|
951
|
+
"items": [],
|
|
952
|
+
"weight_unit": "KG",
|
|
953
|
+
"dimension_unit": None,
|
|
954
|
+
"freight_class": None,
|
|
955
|
+
"reference_number": ANY,
|
|
956
|
+
"options": {},
|
|
957
|
+
"meta": {},
|
|
958
|
+
}
|
|
959
|
+
],
|
|
572
960
|
"parcels_count": None,
|
|
573
961
|
"instruction": "Should not be folded",
|
|
574
962
|
"package_location": "At the main entrance hall",
|
|
@@ -579,3 +967,82 @@ PICKUP_CANCEL_RESPONSE = {
|
|
|
579
967
|
"test_mode": True,
|
|
580
968
|
"meta": ANY,
|
|
581
969
|
}
|
|
970
|
+
|
|
971
|
+
# ─────────────────────────────────────────────────────────────────
|
|
972
|
+
# NEW API TEST DATA (POST /v1/pickups/schedule with carrier_code)
|
|
973
|
+
# ─────────────────────────────────────────────────────────────────
|
|
974
|
+
|
|
975
|
+
PICKUP_DATA_NEW_API = {
|
|
976
|
+
"carrier_code": "canadapost",
|
|
977
|
+
"pickup_date": "2020-10-25",
|
|
978
|
+
"ready_time": "13:00",
|
|
979
|
+
"closing_time": "17:00",
|
|
980
|
+
"instruction": "Should not be folded",
|
|
981
|
+
"package_location": "At the main entrance hall",
|
|
982
|
+
"address": {
|
|
983
|
+
"id": "adr_aabbccddeeff",
|
|
984
|
+
"address_line1": "125 Church St",
|
|
985
|
+
"person_name": "John Doe",
|
|
986
|
+
"company_name": "A corp.",
|
|
987
|
+
"phone_number": "514 000 0000",
|
|
988
|
+
"city": "Moncton",
|
|
989
|
+
"country_code": "CA",
|
|
990
|
+
"postal_code": "E1C4Z8",
|
|
991
|
+
"residential": False,
|
|
992
|
+
"state_code": "NB",
|
|
993
|
+
"email": "john@a.com",
|
|
994
|
+
"validate_location": False,
|
|
995
|
+
"validation": None,
|
|
996
|
+
},
|
|
997
|
+
"tracking_numbers": ["123456789012"],
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
PICKUP_DATA_WITH_CONNECTION_ID = {
|
|
1001
|
+
"carrier_code": "canadapost",
|
|
1002
|
+
"pickup_date": "2020-10-25",
|
|
1003
|
+
"ready_time": "13:00",
|
|
1004
|
+
"closing_time": "17:00",
|
|
1005
|
+
"instruction": "Should not be folded",
|
|
1006
|
+
"package_location": "At the main entrance hall",
|
|
1007
|
+
"address": {
|
|
1008
|
+
"id": "adr_aabbccddeeff",
|
|
1009
|
+
"address_line1": "125 Church St",
|
|
1010
|
+
"person_name": "John Doe",
|
|
1011
|
+
"company_name": "A corp.",
|
|
1012
|
+
"phone_number": "514 000 0000",
|
|
1013
|
+
"city": "Moncton",
|
|
1014
|
+
"country_code": "CA",
|
|
1015
|
+
"postal_code": "E1C4Z8",
|
|
1016
|
+
"residential": False,
|
|
1017
|
+
"state_code": "NB",
|
|
1018
|
+
"email": "john@a.com",
|
|
1019
|
+
"validate_location": False,
|
|
1020
|
+
"validation": None,
|
|
1021
|
+
},
|
|
1022
|
+
"tracking_numbers": ["123456789012"],
|
|
1023
|
+
"options": {
|
|
1024
|
+
"connection_id": "canadapost",
|
|
1025
|
+
},
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
PICKUP_DATA_NEW_API_STANDALONE = {
|
|
1029
|
+
"carrier_code": "canadapost",
|
|
1030
|
+
"pickup_date": "2020-10-25",
|
|
1031
|
+
"ready_time": "13:00",
|
|
1032
|
+
"closing_time": "17:00",
|
|
1033
|
+
"instruction": "Handle with care",
|
|
1034
|
+
"package_location": "Front desk",
|
|
1035
|
+
"address": {
|
|
1036
|
+
"address_line1": "125 Church St",
|
|
1037
|
+
"person_name": "John Doe",
|
|
1038
|
+
"company_name": "A corp.",
|
|
1039
|
+
"phone_number": "514 000 0000",
|
|
1040
|
+
"city": "Moncton",
|
|
1041
|
+
"country_code": "CA",
|
|
1042
|
+
"postal_code": "E1C4Z8",
|
|
1043
|
+
"residential": False,
|
|
1044
|
+
"state_code": "NB",
|
|
1045
|
+
"email": "john@a.com",
|
|
1046
|
+
},
|
|
1047
|
+
"parcels_count": 3,
|
|
1048
|
+
}
|
|
@@ -9,6 +9,7 @@ from karrio.server.core.logging import logger
|
|
|
9
9
|
from karrio.server.core.views.api import GenericAPIView, APIView
|
|
10
10
|
from karrio.server.core.filters import PickupFilters
|
|
11
11
|
from karrio.server.manager.router import router
|
|
12
|
+
from karrio.server.core.serializers import PickupStatus
|
|
12
13
|
from karrio.server.manager.serializers import (
|
|
13
14
|
PaginatedResult,
|
|
14
15
|
Pickup,
|
|
@@ -17,6 +18,7 @@ from karrio.server.manager.serializers import (
|
|
|
17
18
|
PickupData,
|
|
18
19
|
PickupUpdateData,
|
|
19
20
|
PickupCancelData,
|
|
21
|
+
can_mutate_pickup,
|
|
20
22
|
)
|
|
21
23
|
import karrio.server.manager.models as models
|
|
22
24
|
import karrio.server.openapi as openapi
|
|
@@ -33,6 +35,7 @@ class PickupList(GenericAPIView):
|
|
|
33
35
|
filterset_class = PickupFilters
|
|
34
36
|
serializer_class = Pickups
|
|
35
37
|
model = models.Pickup
|
|
38
|
+
throttle_scope = "carrier_request"
|
|
36
39
|
|
|
37
40
|
@openapi.extend_schema(
|
|
38
41
|
tags=["Pickups"],
|
|
@@ -54,6 +57,34 @@ class PickupList(GenericAPIView):
|
|
|
54
57
|
response = self.paginate_queryset(Pickup(pickups, many=True).data)
|
|
55
58
|
return self.get_paginated_response(response)
|
|
56
59
|
|
|
60
|
+
@openapi.extend_schema(
|
|
61
|
+
tags=["Pickups"],
|
|
62
|
+
operation_id=f"{ENDPOINT_ID}create",
|
|
63
|
+
extensions={"x-operationId": "createPickup"},
|
|
64
|
+
summary="Schedule a pickup",
|
|
65
|
+
responses={
|
|
66
|
+
201: Pickup(),
|
|
67
|
+
400: ErrorResponse(),
|
|
68
|
+
424: ErrorMessages(),
|
|
69
|
+
500: ErrorResponse(),
|
|
70
|
+
},
|
|
71
|
+
request=PickupData(),
|
|
72
|
+
)
|
|
73
|
+
def post(self, request: Request):
|
|
74
|
+
"""
|
|
75
|
+
Schedule a pickup for one or many shipments with labels already purchased.
|
|
76
|
+
|
|
77
|
+
The carrier is identified by `carrier_code` in the request body.
|
|
78
|
+
Use `options.connection_id` to target a specific carrier connection.
|
|
79
|
+
"""
|
|
80
|
+
pickup = (
|
|
81
|
+
PickupData.map(data=request.data, context=request)
|
|
82
|
+
.save()
|
|
83
|
+
.instance
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return Response(Pickup(pickup).data, status=status.HTTP_201_CREATED)
|
|
87
|
+
|
|
57
88
|
|
|
58
89
|
class PickupRequest(APIView):
|
|
59
90
|
throttle_scope = "carrier_request"
|
|
@@ -62,7 +93,8 @@ class PickupRequest(APIView):
|
|
|
62
93
|
tags=["Pickups"],
|
|
63
94
|
operation_id=f"{ENDPOINT_ID}schedule",
|
|
64
95
|
extensions={"x-operationId": "schedulePickup"},
|
|
65
|
-
summary="Schedule a pickup",
|
|
96
|
+
summary="Schedule a pickup (deprecated)",
|
|
97
|
+
deprecated=True,
|
|
66
98
|
responses={
|
|
67
99
|
201: Pickup(),
|
|
68
100
|
400: ErrorResponse(),
|
|
@@ -74,6 +106,8 @@ class PickupRequest(APIView):
|
|
|
74
106
|
def post(self, request: Request, carrier_name: str):
|
|
75
107
|
"""
|
|
76
108
|
Schedule a pickup for one or many shipments with labels already purchased.
|
|
109
|
+
|
|
110
|
+
**Deprecated**: Use `POST /v1/pickups` with `carrier_code` in the request body instead.
|
|
77
111
|
"""
|
|
78
112
|
carrier_filter = {
|
|
79
113
|
"carrier_name": carrier_name,
|
|
@@ -85,7 +119,10 @@ class PickupRequest(APIView):
|
|
|
85
119
|
.instance
|
|
86
120
|
)
|
|
87
121
|
|
|
88
|
-
|
|
122
|
+
response = Response(Pickup(pickup).data, status=status.HTTP_201_CREATED)
|
|
123
|
+
response["Deprecation"] = "true"
|
|
124
|
+
response["Link"] = '</v1/pickups>; rel="successor-version"'
|
|
125
|
+
return response
|
|
89
126
|
|
|
90
127
|
|
|
91
128
|
class PickupDetails(APIView):
|
|
@@ -126,6 +163,15 @@ class PickupDetails(APIView):
|
|
|
126
163
|
Modify a pickup for one or many shipments with labels already purchased.
|
|
127
164
|
"""
|
|
128
165
|
pickup = models.Pickup.access_by(request).get(pk=pk)
|
|
166
|
+
can_mutate_pickup(pickup, update=True, payload=request.data)
|
|
167
|
+
|
|
168
|
+
# Metadata-only updates skip the carrier API call
|
|
169
|
+
if list((request.data or {}).keys()) == ["metadata"]:
|
|
170
|
+
existing = pickup.metadata or {}
|
|
171
|
+
pickup.metadata = {**existing, **(request.data["metadata"] or {})}
|
|
172
|
+
pickup.save(update_fields=["metadata", "updated_at"])
|
|
173
|
+
return Response(Pickup(pickup).data, status=status.HTTP_200_OK)
|
|
174
|
+
|
|
129
175
|
instance = (
|
|
130
176
|
PickupUpdateData.map(pickup, data=request.data, context=request)
|
|
131
177
|
.save()
|
|
@@ -157,6 +203,11 @@ class PickupCancel(APIView):
|
|
|
157
203
|
"""
|
|
158
204
|
pickup = models.Pickup.access_by(request).get(pk=pk)
|
|
159
205
|
|
|
206
|
+
# Idempotent re-cancel: return 409 if already cancelled
|
|
207
|
+
if pickup.status == PickupStatus.cancelled.value:
|
|
208
|
+
return Response(Pickup(pickup).data, status=status.HTTP_409_CONFLICT)
|
|
209
|
+
|
|
210
|
+
can_mutate_pickup(pickup, cancel=True)
|
|
160
211
|
update = PickupCancelData.map(pickup, data=request.data).save().instance
|
|
161
212
|
|
|
162
213
|
return Response(Pickup(update).data)
|
|
@@ -164,7 +215,11 @@ class PickupCancel(APIView):
|
|
|
164
215
|
|
|
165
216
|
router.urls.append(path("pickups", PickupList.as_view(), name="shipment-pickup-list"))
|
|
166
217
|
router.urls.append(
|
|
167
|
-
path(
|
|
218
|
+
path(
|
|
219
|
+
"pickups/<str:carrier_name>/schedule",
|
|
220
|
+
PickupRequest.as_view(),
|
|
221
|
+
name="shipment-pickup-request",
|
|
222
|
+
)
|
|
168
223
|
)
|
|
169
224
|
router.urls.append(
|
|
170
225
|
path(
|
|
@@ -172,9 +227,5 @@ router.urls.append(
|
|
|
172
227
|
)
|
|
173
228
|
)
|
|
174
229
|
router.urls.append(
|
|
175
|
-
path(
|
|
176
|
-
"pickups/<str:carrier_name>/schedule",
|
|
177
|
-
PickupRequest.as_view(),
|
|
178
|
-
name="shipment-pickup-request",
|
|
179
|
-
)
|
|
230
|
+
path("pickups/<str:pk>", PickupDetails.as_view(), name="shipment-pickup-details")
|
|
180
231
|
)
|
{karrio_server_manager-2026.1.3.dist-info → karrio_server_manager-2026.1.5.dist-info}/RECORD
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
karrio/server/manager/__init__.py,sha256=lDTMs_O6mQl0DEI2_TniT24TDJbjnkla-5QpnwfYlxs,64
|
|
2
2
|
karrio/server/manager/admin.py,sha256=QOl5e2m3ekU5aj0yj9Uq4nRQrNMB_FfqNae6RyIxkC0,35
|
|
3
3
|
karrio/server/manager/apps.py,sha256=WHTQ1t79uDZTbinRzvNg1NjtFwnwEvg0tP_ChrtTRwI,364
|
|
4
|
-
karrio/server/manager/models.py,sha256=
|
|
4
|
+
karrio/server/manager/models.py,sha256=FIrbDtHKXPpYu-wO9T4xKC3cohe310vG-EorAyq_qWQ,42453
|
|
5
5
|
karrio/server/manager/router.py,sha256=IBUR7rfBkdEHQzWxYOPcVSM8NBp3fte9G6Q5BVTUNNw,95
|
|
6
6
|
karrio/server/manager/signals.py,sha256=3ZApZY4Ne8Gb0AT5rjC3Xneb7dnbSQyXp1OiBXt7eIA,1908
|
|
7
7
|
karrio/server/manager/urls.py,sha256=oXJlvhHNKxFkc_CmpFoyTSAMJcLp4Wt9dHbViQDkqw4,220
|
|
@@ -88,14 +88,15 @@ karrio/server/manager/migrations/0079_remove_carrier_fk_fields.py,sha256=xDOEq4a
|
|
|
88
88
|
karrio/server/manager/migrations/0080_add_carrier_json_indexes.py,sha256=P7FUoX5HdXfi8GkcUjFAyA-HbtXBbAM3EXiqdIElxn4,6019
|
|
89
89
|
karrio/server/manager/migrations/0081_cleanup.py,sha256=cvON11RAgUskyXAMKrBwxIiPNHP0WbhIB31DnN3rp0w,1932
|
|
90
90
|
karrio/server/manager/migrations/0082_shipment_fees.py,sha256=CWJc-ZGITLnN2ln6XZDBIgtbDHdQYYz9AyRQpq1VChc,719
|
|
91
|
+
karrio/server/manager/migrations/0083_pickup_status.py,sha256=cxCVyJcTOisAxiXH5pff8-8Nfl8p-5v5Bov2-EvmLDQ,528
|
|
91
92
|
karrio/server/manager/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
92
|
-
karrio/server/manager/serializers/__init__.py,sha256=
|
|
93
|
+
karrio/server/manager/serializers/__init__.py,sha256=LZ9O7gDVtI3UWvTLTt8HJjEMaS5FOLIL6d9JPHPn37Q,1536
|
|
93
94
|
karrio/server/manager/serializers/address.py,sha256=jc_D9gQb0m7ObO6RHk6SEfZipNRPCH4sPKkd_sa7T_Y,2950
|
|
94
95
|
karrio/server/manager/serializers/commodity.py,sha256=tPbGXuDd6N2xqRDWCXTKnSnl4-e1MnHIKe0KdCfQYpA,1897
|
|
95
96
|
karrio/server/manager/serializers/document.py,sha256=MFa8lqcy4Qa0RAUdn4D02AO5cY4_EgxC-aabbxqr-tI,4611
|
|
96
97
|
karrio/server/manager/serializers/manifest.py,sha256=dyEG2A9rWj59Khs5d8ltgRrUWXpk32GU4a2gZNFFU_c,3114
|
|
97
98
|
karrio/server/manager/serializers/parcel.py,sha256=Wfu2trm2pqiOWhsYG-GElhRHSS4HTW5dttkpVNdfXd4,2761
|
|
98
|
-
karrio/server/manager/serializers/pickup.py,sha256=
|
|
99
|
+
karrio/server/manager/serializers/pickup.py,sha256=gY-r3cR9cX285A1e7NSaV-cvDey0src2THaznHQcrEs,17341
|
|
99
100
|
karrio/server/manager/serializers/rate.py,sha256=7vYK_v8iWEDnswqYHG2Lir16_UhHTOxW5rdC6lw3lzA,652
|
|
100
101
|
karrio/server/manager/serializers/shipment.py,sha256=uM775c5L0sfgW8kTbUwqCOrnVWoCQfsWy1awQ7dRt5M,41343
|
|
101
102
|
karrio/server/manager/serializers/tracking.py,sha256=HCl-WeCF-AjFaTdEd4xSb068NIKcMH-Hbo0QRMSYrvY,14319
|
|
@@ -104,7 +105,7 @@ karrio/server/manager/tests/test_addresses.py,sha256=4eiFQ_6YhLLrfBuLUbYiIfqeRiP
|
|
|
104
105
|
karrio/server/manager/tests/test_errors.py,sha256=x2-mSsXknHkE4V7TajEu8d3rpqV38T_xyAaYJU7xcGQ,3616
|
|
105
106
|
karrio/server/manager/tests/test_manifests.py,sha256=X35ZTXTFEM4Gxdjz598yiNNkOOKZGpILjHWRC0oM5U4,2764
|
|
106
107
|
karrio/server/manager/tests/test_parcels.py,sha256=nB5SaTjwj_ZF2962ZIGy-DXq6XHXc15uhnV3WtKjuUU,4355
|
|
107
|
-
karrio/server/manager/tests/test_pickups.py,sha256=
|
|
108
|
+
karrio/server/manager/tests/test_pickups.py,sha256=nt8S-4SAe848eaQKhQeAIVPJMlL_UbWe7OBNhb-vSM0,37210
|
|
108
109
|
karrio/server/manager/tests/test_products.py,sha256=mK0TxjO2SBllbl_rB0foZQqTjCPR1qky_yeCVCZvzOY,20984
|
|
109
110
|
karrio/server/manager/tests/test_shipments.py,sha256=G2HdzzruZcs9M66fU6sWb763DAfbL8E7if8XVjcYS48,45545
|
|
110
111
|
karrio/server/manager/tests/test_trackers.py,sha256=pDWxYYCFxjZJTLrYbTp1YReexpIeOBXEU8xifht4-D4,9959
|
|
@@ -113,11 +114,11 @@ karrio/server/manager/views/addresses.py,sha256=bwW0x75W0n1NxOfP0CdXYbnE25_YZi3x
|
|
|
113
114
|
karrio/server/manager/views/documents.py,sha256=8Lh49y9LZEDiwBJ77pq4EQVtTv04U-68hc7zzY-gwk4,4049
|
|
114
115
|
karrio/server/manager/views/manifests.py,sha256=KwDoV7GSdRv7YDAzl0SLaNoAx4R-gBguO_WiW_qwfBM,7083
|
|
115
116
|
karrio/server/manager/views/parcels.py,sha256=mF0BzP92_FLdBohnWaXecQGWWaibudoe3KsHZ3yPkR8,5322
|
|
116
|
-
karrio/server/manager/views/pickups.py,sha256=
|
|
117
|
+
karrio/server/manager/views/pickups.py,sha256=QAQ6oLvlwrZMNIbwHfAXKF_YiAMxWN3eEN2ZE2wIAn4,7497
|
|
117
118
|
karrio/server/manager/views/products.py,sha256=1ZxODea-slOhrAZ9pazviLRuJv0I90f4NRTPhA_LxzM,7762
|
|
118
119
|
karrio/server/manager/views/shipments.py,sha256=vdJFgIKnSHEb2FVGtqJbiMoHuMqzWtDpINMH4UaEtQQ,13154
|
|
119
120
|
karrio/server/manager/views/trackers.py,sha256=9uRjNiyqTULmKFOminGgIXj-H0RW-e0JUAF6Z3vMNCY,14744
|
|
120
|
-
karrio_server_manager-2026.1.
|
|
121
|
-
karrio_server_manager-2026.1.
|
|
122
|
-
karrio_server_manager-2026.1.
|
|
123
|
-
karrio_server_manager-2026.1.
|
|
121
|
+
karrio_server_manager-2026.1.5.dist-info/METADATA,sha256=JNOsUPhETcMdvPewuUG8E74mBGZaVY9D1uLaf0-XoWU,730
|
|
122
|
+
karrio_server_manager-2026.1.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
123
|
+
karrio_server_manager-2026.1.5.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
|
|
124
|
+
karrio_server_manager-2026.1.5.dist-info/RECORD,,
|
|
File without changes
|
{karrio_server_manager-2026.1.3.dist-info → karrio_server_manager-2026.1.5.dist-info}/top_level.txt
RENAMED
|
File without changes
|