karrio-server-manager 2026.1.4__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.
@@ -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
+ ]
@@ -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
@@ -43,6 +43,7 @@ from karrio.server.manager.serializers.pickup import (
43
43
  PickupData,
44
44
  PickupUpdateData,
45
45
  PickupCancelData,
46
+ can_mutate_pickup,
46
47
  )
47
48
  from karrio.server.manager.serializers.document import (
48
49
  DocumentUploadSerializer,
@@ -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(val.first().shipment_pickup.exists() for val in validation.values()):
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.exists() is True
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["carrier_filter"]
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.delete()
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": None, # Deleted pickup has no 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": None,
913
+ "id": ANY,
547
914
  },
548
915
  "ready_time": "13:00",
549
916
  "closing_time": "17:00",
550
917
  "address": {
551
- "id": "adr_001122334455", # JSON address retains its id
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": [], # Deleted pickup has no 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
- return Response(Pickup(pickup).data, status=status.HTTP_201_CREATED)
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karrio_server_manager
3
- Version: 2026.1.4
3
+ Version: 2026.1.5
4
4
  Summary: Multi-carrier shipping API Shipments manager module
5
5
  Author-email: karrio <hello@karrio.io>
6
6
  License-Expression: LGPL-3.0
@@ -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=M0VZOTMvy1r_mw5bl2zvnWUw9a0g0KsS3X9bWFLN-tc,42323
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=AW1RpB3rNLKbMD85rCFFiVcpngj-kPUNdyb1hkaYDrk,1513
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=UOIG0trWxjE12jIDyayaoEKvzepmvcSAmER7dSf8WQo,15211
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=M8x4j12Zw3vp_L8Xw2Tw9oUzopLJK4QVEfvNn5fZyGk,19081
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=wuuTtT-z81RRcy-naJoj6JcJRVwt2Aw1yncL2DO4XnE,5503
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.4.dist-info/METADATA,sha256=K2hGKU6YmyPKyih7v-p5wedS8kwdcRkpUI-68zbWb6o,730
121
- karrio_server_manager-2026.1.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
122
- karrio_server_manager-2026.1.4.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
123
- karrio_server_manager-2026.1.4.dist-info/RECORD,,
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,,