karrio-server-graph 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.
@@ -432,6 +432,28 @@ class Mutation:
432
432
  ) -> mutations.BatchUpdateServiceRatesMutation:
433
433
  return mutations.BatchUpdateServiceRatesMutation.mutate(info, **input.to_dict())
434
434
 
435
+ # ─────────────────────────────────────────────────────────────────
436
+ # WEIGHT RANGE MUTATIONS
437
+ # ─────────────────────────────────────────────────────────────────
438
+
439
+ @strawberry.mutation
440
+ def add_weight_range(
441
+ self, info: Info, input: inputs.AddWeightRangeMutationInput
442
+ ) -> mutations.AddWeightRangeMutation:
443
+ return mutations.AddWeightRangeMutation.mutate(info, **input.to_dict())
444
+
445
+ @strawberry.mutation
446
+ def remove_weight_range(
447
+ self, info: Info, input: inputs.RemoveWeightRangeMutationInput
448
+ ) -> mutations.RemoveWeightRangeMutation:
449
+ return mutations.RemoveWeightRangeMutation.mutate(info, **input.to_dict())
450
+
451
+ @strawberry.mutation
452
+ def delete_service_rate(
453
+ self, info: Info, input: inputs.DeleteServiceRateMutationInput
454
+ ) -> mutations.DeleteServiceRateMutation:
455
+ return mutations.DeleteServiceRateMutation.mutate(info, **input.to_dict())
456
+
435
457
  # ─────────────────────────────────────────────────────────────────
436
458
  # SERVICE ZONE/SURCHARGE ASSIGNMENT MUTATIONS
437
459
  # ─────────────────────────────────────────────────────────────────
@@ -71,6 +71,7 @@ class ManifestFilter(utils.Paginated):
71
71
  class PickupFilter(utils.Paginated):
72
72
  keyword: typing.Optional[str] = strawberry.UNSET
73
73
  id: typing.Optional[typing.List[str]] = strawberry.UNSET
74
+ status: typing.Optional[typing.List[str]] = strawberry.UNSET
74
75
  confirmation_number: typing.Optional[str] = strawberry.UNSET
75
76
  pickup_date_after: typing.Optional[str] = strawberry.UNSET
76
77
  pickup_date_before: typing.Optional[str] = strawberry.UNSET
@@ -194,6 +195,11 @@ class WorkspaceConfigMutationInput(utils.BaseInput):
194
195
  pref_signature_required: typing.Optional[bool] = strawberry.UNSET
195
196
  pref_max_lead_time_days: typing.Optional[int] = strawberry.UNSET
196
197
 
198
+ # ─────────────────────────────────────────────────────────────────
199
+ # Workspace Settings
200
+ # ─────────────────────────────────────────────────────────────────
201
+ shipping_app_test_mode: typing.Optional[bool] = strawberry.UNSET
202
+
197
203
 
198
204
  @strawberry.input
199
205
  class TokenMutationInput(utils.BaseInput):
@@ -937,6 +943,40 @@ class BatchUpdateServiceRatesMutationInput(utils.BaseInput):
937
943
  rates: typing.List[ServiceRateInput]
938
944
 
939
945
 
946
+ # ─────────────────────────────────────────────────────────────────────────────
947
+ # WEIGHT RANGE INPUTS
948
+ # ─────────────────────────────────────────────────────────────────────────────
949
+
950
+
951
+ @strawberry.input
952
+ class AddWeightRangeMutationInput(utils.BaseInput):
953
+ """Add a weight range to a rate sheet (creates rate entries for all service+zone combos)."""
954
+
955
+ rate_sheet_id: str
956
+ min_weight: float
957
+ max_weight: float
958
+
959
+
960
+ @strawberry.input
961
+ class RemoveWeightRangeMutationInput(utils.BaseInput):
962
+ """Remove a weight range and all its associated rate entries."""
963
+
964
+ rate_sheet_id: str
965
+ min_weight: float
966
+ max_weight: float
967
+
968
+
969
+ @strawberry.input
970
+ class DeleteServiceRateMutationInput(utils.BaseInput):
971
+ """Delete a specific service rate entry."""
972
+
973
+ rate_sheet_id: str
974
+ service_id: str
975
+ zone_id: str
976
+ min_weight: typing.Optional[float] = strawberry.UNSET
977
+ max_weight: typing.Optional[float] = strawberry.UNSET
978
+
979
+
940
980
  # ─────────────────────────────────────────────────────────────────────────────
941
981
  # SERVICE ZONE/SURCHARGE ASSIGNMENT INPUTS
942
982
  # ─────────────────────────────────────────────────────────────────────────────
@@ -882,6 +882,86 @@ class BatchUpdateServiceRatesMutation(utils.BaseMutation):
882
882
  return BatchUpdateServiceRatesMutation(rate_sheet=rate_sheet)
883
883
 
884
884
 
885
+ # ─────────────────────────────────────────────────────────────────────────────
886
+ # WEIGHT RANGE MUTATIONS
887
+ # ─────────────────────────────────────────────────────────────────────────────
888
+
889
+
890
+ @strawberry.type
891
+ class AddWeightRangeMutation(utils.BaseMutation):
892
+ rate_sheet: typing.Optional[types.RateSheetType] = None
893
+
894
+ @staticmethod
895
+ @transaction.atomic
896
+ @utils.authentication_required
897
+ def mutate(
898
+ info: Info, **input: inputs.AddWeightRangeMutationInput
899
+ ) -> "AddWeightRangeMutation":
900
+ rate_sheet = providers.RateSheet.access_by(info.context.request).get(
901
+ id=input["rate_sheet_id"]
902
+ )
903
+
904
+ rate_sheet.add_weight_range(
905
+ min_weight=input["min_weight"],
906
+ max_weight=input["max_weight"],
907
+ )
908
+
909
+ return AddWeightRangeMutation(rate_sheet=rate_sheet)
910
+
911
+
912
+ @strawberry.type
913
+ class RemoveWeightRangeMutation(utils.BaseMutation):
914
+ rate_sheet: typing.Optional[types.RateSheetType] = None
915
+
916
+ @staticmethod
917
+ @transaction.atomic
918
+ @utils.authentication_required
919
+ def mutate(
920
+ info: Info, **input: inputs.RemoveWeightRangeMutationInput
921
+ ) -> "RemoveWeightRangeMutation":
922
+ rate_sheet = providers.RateSheet.access_by(info.context.request).get(
923
+ id=input["rate_sheet_id"]
924
+ )
925
+
926
+ rate_sheet.remove_weight_range(
927
+ min_weight=input["min_weight"],
928
+ max_weight=input["max_weight"],
929
+ )
930
+
931
+ return RemoveWeightRangeMutation(rate_sheet=rate_sheet)
932
+
933
+
934
+ @strawberry.type
935
+ class DeleteServiceRateMutation(utils.BaseMutation):
936
+ rate_sheet: typing.Optional[types.RateSheetType] = None
937
+
938
+ @staticmethod
939
+ @transaction.atomic
940
+ @utils.authentication_required
941
+ def mutate(
942
+ info: Info, **input: inputs.DeleteServiceRateMutationInput
943
+ ) -> "DeleteServiceRateMutation":
944
+ rate_sheet = providers.RateSheet.access_by(info.context.request).get(
945
+ id=input["rate_sheet_id"]
946
+ )
947
+
948
+ min_weight = input.get("min_weight")
949
+ max_weight = input.get("max_weight")
950
+ if utils.is_unset(min_weight):
951
+ min_weight = None
952
+ if utils.is_unset(max_weight):
953
+ max_weight = None
954
+
955
+ rate_sheet.remove_service_rate(
956
+ service_id=input["service_id"],
957
+ zone_id=input["zone_id"],
958
+ min_weight=min_weight,
959
+ max_weight=max_weight,
960
+ )
961
+
962
+ return DeleteServiceRateMutation(rate_sheet=rate_sheet)
963
+
964
+
885
965
  # ─────────────────────────────────────────────────────────────────────────────
886
966
  # SERVICE ZONE/SURCHARGE ASSIGNMENT MUTATIONS
887
967
  # ─────────────────────────────────────────────────────────────────────────────
@@ -349,6 +349,19 @@ class WorkspaceConfigType:
349
349
 
350
350
  # endregion
351
351
 
352
+ # ─────────────────────────────────────────────────────────────────
353
+ # Workspace Settings
354
+ # ─────────────────────────────────────────────────────────────────
355
+ # region
356
+
357
+ @strawberry.field
358
+ def shipping_app_test_mode(
359
+ self: auth.WorkspaceConfig,
360
+ ) -> typing.Optional[bool]:
361
+ return self.config.get("shipping_app_test_mode")
362
+
363
+ # endregion
364
+
352
365
  @staticmethod
353
366
  @utils.authentication_required
354
367
  def resolve(info) -> typing.Optional["WorkspaceConfigType"]:
@@ -1520,6 +1533,7 @@ class PickupType:
1520
1533
  id: str
1521
1534
  object_type: str
1522
1535
  confirmation_number: typing.Optional[str]
1536
+ status: str
1523
1537
  pickup_date: typing.Optional[str]
1524
1538
  ready_time: typing.Optional[str]
1525
1539
  closing_time: typing.Optional[str]
@@ -2199,6 +2199,434 @@ UPDATE_SERVICE_ZONE_IDS_RESPONSE = {
2199
2199
  }
2200
2200
  }
2201
2201
 
2202
+ class TestPerServiceWeightRangeScenarios(GraphTestCase):
2203
+ """Tests for per-service weight range interactions.
2204
+
2205
+ These tests verify the correct scoping behavior of weight range operations:
2206
+ - Global operations (add_weight_range, remove_weight_range) affect ALL services
2207
+ - Per-service operations (update_service_rate, delete_service_rate) affect ONE service
2208
+ - Editing a weight range on one service must NOT affect other services
2209
+ - Deleting a weight range from one service must NOT affect other services
2210
+
2211
+ Scenarios covered:
2212
+ ─────────────────────────────────────────────────────────────────────────────
2213
+ SCENARIO 1: add_weight_range creates rows for ALL services (global)
2214
+ SCENARIO 2: remove_weight_range removes from ALL services (global)
2215
+ SCENARIO 3: delete_service_rate removes from ONE service only
2216
+ SCENARIO 4: Per-service weight range edit (delete old + create new) scoped correctly
2217
+ SCENARIO 5: Per-service weight range delete does not affect other services
2218
+ SCENARIO 6: Services can have different weight ranges independently
2219
+ SCENARIO 7: update_service_rate creates a new entry (implicit add for one service)
2220
+ SCENARIO 8: get_weight_ranges derives from ALL services' rates (union)
2221
+ SCENARIO 9: Overlapping weight range validation on add_weight_range
2222
+ SCENARIO 10: Weight range with negative/invalid values rejected
2223
+ """
2224
+
2225
+ def setUp(self):
2226
+ super().setUp()
2227
+
2228
+ self.rate_sheet = providers.RateSheet.objects.create(
2229
+ name="Weight Range Scoping Test",
2230
+ carrier_name="dhl_germany",
2231
+ slug="wr_scoping_test",
2232
+ zones=[
2233
+ {"id": "zone_de", "label": "Germany", "country_codes": ["DE"]},
2234
+ {"id": "zone_eu", "label": "EU", "country_codes": ["FR", "IT", "ES"]},
2235
+ ],
2236
+ service_rates=[],
2237
+ created_by=self.user,
2238
+ )
2239
+
2240
+ self.svc_paket = providers.ServiceLevel.objects.create(
2241
+ service_name="DHL Paket",
2242
+ service_code="dhl_paket",
2243
+ carrier_service_code="V01PAK",
2244
+ currency="EUR",
2245
+ zone_ids=["zone_de"],
2246
+ created_by=self.user,
2247
+ )
2248
+ self.svc_kleinpaket = providers.ServiceLevel.objects.create(
2249
+ service_name="DHL Kleinpaket",
2250
+ service_code="dhl_kleinpaket",
2251
+ carrier_service_code="V62WP",
2252
+ currency="EUR",
2253
+ zone_ids=["zone_de"],
2254
+ created_by=self.user,
2255
+ )
2256
+ self.rate_sheet.services.add(self.svc_paket, self.svc_kleinpaket)
2257
+
2258
+ # Seed: Paket has 3 weight ranges, Kleinpaket has 2 (different set)
2259
+ self.rate_sheet.service_rates = [
2260
+ # DHL Paket: 0-1, 1-5, 5-10
2261
+ {"service_id": self.svc_paket.id, "zone_id": "zone_de", "rate": 3.99, "min_weight": 0, "max_weight": 1},
2262
+ {"service_id": self.svc_paket.id, "zone_id": "zone_de", "rate": 5.49, "min_weight": 1, "max_weight": 5},
2263
+ {"service_id": self.svc_paket.id, "zone_id": "zone_de", "rate": 8.99, "min_weight": 5, "max_weight": 10},
2264
+ # DHL Kleinpaket: 0-0.5, 0.5-1
2265
+ {"service_id": self.svc_kleinpaket.id, "zone_id": "zone_de", "rate": 2.49, "min_weight": 0, "max_weight": 0.5},
2266
+ {"service_id": self.svc_kleinpaket.id, "zone_id": "zone_de", "rate": 3.39, "min_weight": 0.5, "max_weight": 1},
2267
+ ]
2268
+ self.rate_sheet.save()
2269
+
2270
+ # =========================================================================
2271
+ # SCENARIO 1: add_weight_range is GLOBAL (creates for all services)
2272
+ # =========================================================================
2273
+
2274
+ def test_add_weight_range_creates_for_all_services(self):
2275
+ """add_weight_range creates rate=0 entries for ALL service+zone combos."""
2276
+ self.rate_sheet.add_weight_range(min_weight=10, max_weight=20)
2277
+ self.rate_sheet.refresh_from_db()
2278
+
2279
+ print(self.rate_sheet.service_rates)
2280
+
2281
+ # Both services should get new entries
2282
+ paket_new = [
2283
+ r for r in self.rate_sheet.service_rates
2284
+ if r["service_id"] == self.svc_paket.id and r["min_weight"] == 10
2285
+ ]
2286
+ klein_new = [
2287
+ r for r in self.rate_sheet.service_rates
2288
+ if r["service_id"] == self.svc_kleinpaket.id and r["min_weight"] == 10
2289
+ ]
2290
+ self.assertEqual(len(paket_new), 1, "Paket should get the new weight range")
2291
+ self.assertEqual(len(klein_new), 1, "Kleinpaket should get the new weight range")
2292
+ self.assertEqual(paket_new[0]["rate"], 0, "New entries should have rate=0")
2293
+ self.assertEqual(klein_new[0]["rate"], 0, "New entries should have rate=0")
2294
+
2295
+ # =========================================================================
2296
+ # SCENARIO 2: remove_weight_range is GLOBAL (removes from all services)
2297
+ # =========================================================================
2298
+
2299
+ def test_remove_weight_range_removes_from_all_services(self):
2300
+ """remove_weight_range deletes matching entries from ALL services."""
2301
+ # Add a shared weight range first
2302
+ self.rate_sheet.add_weight_range(min_weight=10, max_weight=20)
2303
+ self.rate_sheet.refresh_from_db()
2304
+ total_before = len(self.rate_sheet.service_rates)
2305
+
2306
+ # Remove it globally
2307
+ self.rate_sheet.remove_weight_range(min_weight=10, max_weight=20)
2308
+ self.rate_sheet.refresh_from_db()
2309
+
2310
+ print(self.rate_sheet.service_rates)
2311
+
2312
+ remaining = [
2313
+ r for r in self.rate_sheet.service_rates
2314
+ if r.get("min_weight") == 10 and r.get("max_weight") == 20
2315
+ ]
2316
+ self.assertEqual(len(remaining), 0, "All entries with 10-20 should be gone")
2317
+ # Original entries still intact
2318
+ self.assertEqual(len(self.rate_sheet.service_rates), 5, "Original 5 entries should remain")
2319
+
2320
+ # =========================================================================
2321
+ # SCENARIO 3: delete_service_rate removes from ONE service only
2322
+ # =========================================================================
2323
+
2324
+ def test_delete_service_rate_scoped_to_one_service(self):
2325
+ """remove_service_rate with weight bracket only affects the specified service."""
2326
+ # Add a shared weight range (both services get it)
2327
+ self.rate_sheet.add_weight_range(min_weight=10, max_weight=20)
2328
+ self.rate_sheet.refresh_from_db()
2329
+
2330
+ # Delete only from Kleinpaket
2331
+ self.rate_sheet.remove_service_rate(
2332
+ service_id=self.svc_kleinpaket.id,
2333
+ zone_id="zone_de",
2334
+ min_weight=10,
2335
+ max_weight=20,
2336
+ )
2337
+ self.rate_sheet.refresh_from_db()
2338
+
2339
+ print(self.rate_sheet.service_rates)
2340
+
2341
+ # Paket should still have it
2342
+ paket_remaining = [
2343
+ r for r in self.rate_sheet.service_rates
2344
+ if r["service_id"] == self.svc_paket.id and r.get("min_weight") == 10
2345
+ ]
2346
+ klein_remaining = [
2347
+ r for r in self.rate_sheet.service_rates
2348
+ if r["service_id"] == self.svc_kleinpaket.id and r.get("min_weight") == 10
2349
+ ]
2350
+ self.assertEqual(len(paket_remaining), 1, "Paket still has 10-20")
2351
+ self.assertEqual(len(klein_remaining), 0, "Kleinpaket 10-20 was removed")
2352
+
2353
+ # =========================================================================
2354
+ # SCENARIO 4: Per-service weight range edit (delete old + create new)
2355
+ # =========================================================================
2356
+
2357
+ def test_per_service_weight_range_edit_does_not_affect_other_services(self):
2358
+ """Editing a weight range on one service (delete old + add new) should NOT
2359
+ create rows on other services. This simulates the frontend's per-service
2360
+ EditWeightRangeDialog flow: deleteServiceRate + updateServiceRate."""
2361
+
2362
+ # Edit Paket's 5-10 range to 5-15 (per-service, NOT global)
2363
+ old_rate = next(
2364
+ r for r in self.rate_sheet.service_rates
2365
+ if r["service_id"] == self.svc_paket.id and r["min_weight"] == 5 and r["max_weight"] == 10
2366
+ )
2367
+
2368
+ # Step 1: Delete old entry (per-service)
2369
+ self.rate_sheet.remove_service_rate(
2370
+ service_id=self.svc_paket.id,
2371
+ zone_id="zone_de",
2372
+ min_weight=5,
2373
+ max_weight=10,
2374
+ )
2375
+
2376
+ # Step 2: Create new entry with updated max_weight (per-service)
2377
+ self.rate_sheet.update_service_rate(
2378
+ service_id=self.svc_paket.id,
2379
+ zone_id="zone_de",
2380
+ rate_data={"rate": old_rate["rate"], "min_weight": 5, "max_weight": 15},
2381
+ )
2382
+ self.rate_sheet.refresh_from_db()
2383
+
2384
+ print(self.rate_sheet.service_rates)
2385
+
2386
+ # Verify Paket has new range
2387
+ paket_rates = [r for r in self.rate_sheet.service_rates if r["service_id"] == self.svc_paket.id]
2388
+ paket_weights = [(r["min_weight"], r["max_weight"]) for r in paket_rates]
2389
+ self.assertIn((5, 15), paket_weights, "Paket should have 5-15")
2390
+ self.assertNotIn((5, 10), paket_weights, "Paket should NOT have 5-10 anymore")
2391
+
2392
+ # Verify Kleinpaket is UNCHANGED
2393
+ klein_rates = [r for r in self.rate_sheet.service_rates if r["service_id"] == self.svc_kleinpaket.id]
2394
+ klein_weights = [(r["min_weight"], r["max_weight"]) for r in klein_rates]
2395
+ self.assertEqual(
2396
+ sorted(klein_weights),
2397
+ [(0, 0.5), (0.5, 1)],
2398
+ "Kleinpaket should be completely unaffected",
2399
+ )
2400
+
2401
+ # Total entries should be same (3 Paket + 2 Kleinpaket = 5)
2402
+ self.assertEqual(len(self.rate_sheet.service_rates), 5)
2403
+
2404
+ # =========================================================================
2405
+ # SCENARIO 5: Per-service weight range delete does NOT affect others
2406
+ # =========================================================================
2407
+
2408
+ def test_per_service_weight_range_delete_does_not_affect_others(self):
2409
+ """Deleting a weight range from one service by removing all its zone rates
2410
+ should NOT affect other services' rates for different weight ranges."""
2411
+
2412
+ # Delete all Paket's 5-10 entries (simulate removing a weight range row)
2413
+ self.rate_sheet.remove_service_rate(
2414
+ service_id=self.svc_paket.id,
2415
+ zone_id="zone_de",
2416
+ min_weight=5,
2417
+ max_weight=10,
2418
+ )
2419
+ self.rate_sheet.refresh_from_db()
2420
+
2421
+ print(self.rate_sheet.service_rates)
2422
+
2423
+ # Paket should have 2 ranges now
2424
+ paket_rates = [r for r in self.rate_sheet.service_rates if r["service_id"] == self.svc_paket.id]
2425
+ self.assertEqual(len(paket_rates), 2, "Paket should have 2 ranges left")
2426
+
2427
+ # Kleinpaket unchanged
2428
+ klein_rates = [r for r in self.rate_sheet.service_rates if r["service_id"] == self.svc_kleinpaket.id]
2429
+ self.assertEqual(len(klein_rates), 2, "Kleinpaket should still have 2 ranges")
2430
+
2431
+ # =========================================================================
2432
+ # SCENARIO 6: Services can have different weight ranges independently
2433
+ # =========================================================================
2434
+
2435
+ def test_services_have_independent_weight_ranges(self):
2436
+ """Each service can have its own set of weight ranges that don't overlap
2437
+ with other services."""
2438
+
2439
+ paket_ranges = set()
2440
+ klein_ranges = set()
2441
+ for r in self.rate_sheet.service_rates:
2442
+ key = (r["min_weight"], r["max_weight"])
2443
+ if r["service_id"] == self.svc_paket.id:
2444
+ paket_ranges.add(key)
2445
+ else:
2446
+ klein_ranges.add(key)
2447
+
2448
+ print(f"Paket ranges: {paket_ranges}")
2449
+ print(f"Klein ranges: {klein_ranges}")
2450
+
2451
+ self.assertEqual(paket_ranges, {(0, 1), (1, 5), (5, 10)})
2452
+ self.assertEqual(klein_ranges, {(0, 0.5), (0.5, 1)})
2453
+ # They are different sets
2454
+ self.assertNotEqual(paket_ranges, klein_ranges)
2455
+
2456
+ # =========================================================================
2457
+ # SCENARIO 7: update_service_rate creates implicit per-service entry
2458
+ # =========================================================================
2459
+
2460
+ def test_update_service_rate_creates_entry_for_single_service(self):
2461
+ """update_service_rate can create a new weight bracket for a single service
2462
+ without affecting other services."""
2463
+
2464
+ # Add a 10-20 range to Kleinpaket only (via update_service_rate)
2465
+ self.rate_sheet.update_service_rate(
2466
+ service_id=self.svc_kleinpaket.id,
2467
+ zone_id="zone_de",
2468
+ rate_data={"rate": 12.50, "min_weight": 10, "max_weight": 20},
2469
+ )
2470
+ self.rate_sheet.refresh_from_db()
2471
+
2472
+ print(self.rate_sheet.service_rates)
2473
+
2474
+ # Kleinpaket now has 3 ranges
2475
+ klein_rates = [r for r in self.rate_sheet.service_rates if r["service_id"] == self.svc_kleinpaket.id]
2476
+ self.assertEqual(len(klein_rates), 3)
2477
+ new_rate = next(r for r in klein_rates if r["min_weight"] == 10)
2478
+ self.assertEqual(new_rate["rate"], 12.50)
2479
+
2480
+ # Paket should NOT have 10-20
2481
+ paket_has_10_20 = any(
2482
+ r for r in self.rate_sheet.service_rates
2483
+ if r["service_id"] == self.svc_paket.id and r["min_weight"] == 10
2484
+ )
2485
+ self.assertFalse(paket_has_10_20, "Paket should NOT get the 10-20 range")
2486
+
2487
+ # =========================================================================
2488
+ # SCENARIO 8: get_weight_ranges is union across all services
2489
+ # =========================================================================
2490
+
2491
+ def test_get_weight_ranges_union_all_services(self):
2492
+ """get_weight_ranges returns the union of all weight ranges across services."""
2493
+ ranges = self.rate_sheet.get_weight_ranges()
2494
+ range_tuples = [(r["min_weight"], r["max_weight"]) for r in ranges]
2495
+
2496
+ print(f"All ranges: {range_tuples}")
2497
+
2498
+ # Union of Paket (0-1, 1-5, 5-10) and Kleinpaket (0-0.5, 0.5-1)
2499
+ expected = {(0, 0.5), (0, 1), (0.5, 1), (1, 5), (5, 10)}
2500
+ self.assertEqual(set(range_tuples), expected)
2501
+
2502
+ # =========================================================================
2503
+ # SCENARIO 9: add_weight_range overlap validation
2504
+ # =========================================================================
2505
+
2506
+ def test_add_weight_range_rejects_overlap(self):
2507
+ """add_weight_range raises ValueError when the new range overlaps existing ones."""
2508
+ with self.assertRaises(ValueError) as ctx:
2509
+ self.rate_sheet.add_weight_range(min_weight=0, max_weight=2)
2510
+
2511
+ print(str(ctx.exception))
2512
+ self.assertIn("overlaps", str(ctx.exception).lower())
2513
+
2514
+ # =========================================================================
2515
+ # SCENARIO 10: Input validation on add_weight_range
2516
+ # =========================================================================
2517
+
2518
+ def test_add_weight_range_rejects_invalid_inputs(self):
2519
+ """add_weight_range rejects negative min_weight and max <= min."""
2520
+ with self.assertRaises(ValueError):
2521
+ self.rate_sheet.add_weight_range(min_weight=-1, max_weight=5)
2522
+
2523
+ with self.assertRaises(ValueError):
2524
+ self.rate_sheet.add_weight_range(min_weight=5, max_weight=3)
2525
+
2526
+ with self.assertRaises(ValueError):
2527
+ self.rate_sheet.add_weight_range(min_weight=5, max_weight=5)
2528
+
2529
+ # =========================================================================
2530
+ # SCENARIO 11: Multi-zone per-service edit
2531
+ # =========================================================================
2532
+
2533
+ def test_per_service_edit_with_multiple_zones(self):
2534
+ """When a service has rates in multiple zones, editing a weight range should
2535
+ re-key all zone entries for that service only."""
2536
+
2537
+ # Give Paket a zone_eu entry at 5-10
2538
+ self.rate_sheet.update_service_rate(
2539
+ service_id=self.svc_paket.id,
2540
+ zone_id="zone_eu",
2541
+ rate_data={"rate": 12.99, "min_weight": 5, "max_weight": 10},
2542
+ )
2543
+ self.rate_sheet.refresh_from_db()
2544
+
2545
+ # Now simulate per-service edit: change 5-10 → 5-15 for Paket
2546
+ # Delete old entries for Paket 5-10 (both zones)
2547
+ paket_old = [
2548
+ r for r in self.rate_sheet.service_rates
2549
+ if r["service_id"] == self.svc_paket.id and r["min_weight"] == 5 and r["max_weight"] == 10
2550
+ ]
2551
+ for r in paket_old:
2552
+ self.rate_sheet.remove_service_rate(
2553
+ service_id=r["service_id"], zone_id=r["zone_id"],
2554
+ min_weight=5, max_weight=10,
2555
+ )
2556
+
2557
+ # Add new entries with 5-15
2558
+ for r in paket_old:
2559
+ self.rate_sheet.update_service_rate(
2560
+ service_id=r["service_id"], zone_id=r["zone_id"],
2561
+ rate_data={"rate": r["rate"], "min_weight": 5, "max_weight": 15},
2562
+ )
2563
+ self.rate_sheet.refresh_from_db()
2564
+
2565
+ print(self.rate_sheet.service_rates)
2566
+
2567
+ # Paket should have 5-15 in both zones
2568
+ paket_de = next(
2569
+ r for r in self.rate_sheet.service_rates
2570
+ if r["service_id"] == self.svc_paket.id and r["zone_id"] == "zone_de" and r["min_weight"] == 5
2571
+ )
2572
+ paket_eu = next(
2573
+ r for r in self.rate_sheet.service_rates
2574
+ if r["service_id"] == self.svc_paket.id and r["zone_id"] == "zone_eu" and r["min_weight"] == 5
2575
+ )
2576
+ self.assertEqual(paket_de["max_weight"], 15)
2577
+ self.assertEqual(paket_eu["max_weight"], 15)
2578
+ self.assertEqual(paket_de["rate"], 8.99)
2579
+ self.assertEqual(paket_eu["rate"], 12.99)
2580
+
2581
+ # Kleinpaket completely unaffected
2582
+ klein_rates = [r for r in self.rate_sheet.service_rates if r["service_id"] == self.svc_kleinpaket.id]
2583
+ self.assertEqual(len(klein_rates), 2, "Kleinpaket unchanged")
2584
+
2585
+ # =========================================================================
2586
+ # SCENARIO 12: Deleting all zones' rates for a weight range on one service
2587
+ # =========================================================================
2588
+
2589
+ def test_delete_weight_range_from_one_service_all_zones(self):
2590
+ """When deleting a weight range from a service with multiple zones,
2591
+ each zone's entry must be deleted individually."""
2592
+
2593
+ # Add zone_eu to Paket
2594
+ self.svc_paket.zone_ids = ["zone_de", "zone_eu"]
2595
+ self.svc_paket.save()
2596
+ self.rate_sheet.update_service_rate(
2597
+ service_id=self.svc_paket.id, zone_id="zone_eu",
2598
+ rate_data={"rate": 7.99, "min_weight": 1, "max_weight": 5},
2599
+ )
2600
+ self.rate_sheet.refresh_from_db()
2601
+
2602
+ # Delete Paket's 1-5 range from ALL zones (per-service)
2603
+ paket_1_5 = [
2604
+ r for r in self.rate_sheet.service_rates
2605
+ if r["service_id"] == self.svc_paket.id and r["min_weight"] == 1 and r["max_weight"] == 5
2606
+ ]
2607
+ self.assertEqual(len(paket_1_5), 2, "Paket has 1-5 in 2 zones")
2608
+
2609
+ for r in paket_1_5:
2610
+ self.rate_sheet.remove_service_rate(
2611
+ service_id=r["service_id"], zone_id=r["zone_id"],
2612
+ min_weight=1, max_weight=5,
2613
+ )
2614
+ self.rate_sheet.refresh_from_db()
2615
+
2616
+ print(self.rate_sheet.service_rates)
2617
+
2618
+ # Paket should have no 1-5 entries
2619
+ paket_1_5_after = [
2620
+ r for r in self.rate_sheet.service_rates
2621
+ if r["service_id"] == self.svc_paket.id and r["min_weight"] == 1 and r["max_weight"] == 5
2622
+ ]
2623
+ self.assertEqual(len(paket_1_5_after), 0)
2624
+
2625
+ # Kleinpaket unchanged
2626
+ klein_rates = [r for r in self.rate_sheet.service_rates if r["service_id"] == self.svc_kleinpaket.id]
2627
+ self.assertEqual(len(klein_rates), 2)
2628
+
2629
+
2202
2630
  UPDATE_SERVICE_SURCHARGE_IDS_RESPONSE = {
2203
2631
  "data": {
2204
2632
  "update_service_surcharge_ids": {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karrio_server_graph
3
- Version: 2026.1.4
3
+ Version: 2026.1.5
4
4
  Summary: Multi-carrier shipping API Graph module
5
5
  Author-email: karrio <hello@karrio.io>
6
6
  License-Expression: LGPL-3.0
@@ -16,10 +16,10 @@ karrio/server/graph/migrations/0002_auto_20210512_1353.py,sha256=TnUwR9EP0qp3gJ3
16
16
  karrio/server/graph/migrations/0003_remove_template_customs.py,sha256=XTfeRtpYi8U7C2G2yyEBmWTkkRf4kZlJizl_iznXeOU,280
17
17
  karrio/server/graph/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
18
  karrio/server/graph/schemas/__init__.py,sha256=Kn-I1j3HP3jwZccpz6FL9r1k6b3UEAMVh2kFPCKNS0w,80
19
- karrio/server/graph/schemas/base/__init__.py,sha256=kHpLB7ooSoVZKeRWgOerxDncUtzYcvkqj1mIkL_O_hQ,19558
20
- karrio/server/graph/schemas/base/inputs.py,sha256=_cz9h8WNpU2pn1HAGjzAhanAO4fBj4g-kGc1rTsF5zE,40681
21
- karrio/server/graph/schemas/base/mutations.py,sha256=E6Dbf0jmGY7XC1HNkIpuZXOXkDv-Pdb4pnRDELGzqXg,50154
22
- karrio/server/graph/schemas/base/types.py,sha256=YsWD1PyzdDskPwFn01TKhQTvFP7NRBwO_HaF-t13dmk,80864
19
+ karrio/server/graph/schemas/base/__init__.py,sha256=2DqdhEg-TcpDjs-MBeZH8NU5uAL1393madGheJrgdEY,20745
20
+ karrio/server/graph/schemas/base/inputs.py,sha256=1dY4xU78OpHhTlauEK0vt-KN33Fy_HfQX-vY4No4QHc,42486
21
+ karrio/server/graph/schemas/base/mutations.py,sha256=OLCdkDPewPv4W-XrA7jMzDMm67-W5rD8INzrH7uI1QI,52910
22
+ karrio/server/graph/schemas/base/types.py,sha256=K2qcw5ZIT7CKgnSPCa7vnrh7zI9fwejD43_7AVumQEk,81520
23
23
  karrio/server/graph/templates/graphql/graphiql.html,sha256=MQjQbBqoRE0QLsOUck8SaXo6B2oJO8dT6YZzUqbDan0,3786
24
24
  karrio/server/graph/templates/karrio/email_change_email.html,sha256=gr55F97GYzY27TVKGl49037yd60eSYD0b0GXRlyoco4,552
25
25
  karrio/server/graph/templates/karrio/email_change_email.txt,sha256=NXXuzLR63hn2F1fVAjzmuguptuuTvujwqI7YLSrQoio,431
@@ -30,12 +30,12 @@ karrio/server/graph/tests/test_carrier_connections.py,sha256=JBI_jRfJs9i_GP8fHBU
30
30
  karrio/server/graph/tests/test_metafield.py,sha256=K7Oon0CLEm_MUMbmcu0t2iAZvFN8Wl7Kp4QAWeUXo_Y,12783
31
31
  karrio/server/graph/tests/test_partial_shipments.py,sha256=dPIdIq4wiyovOaIIzbIX69eZnBqCA4ZvBSiGKYADM2g,19031
32
32
  karrio/server/graph/tests/test_pickups.py,sha256=65YkOdWUgEXO9Iweo66vobi96VVUV_wCFo8ZmFXiw2I,11004
33
- karrio/server/graph/tests/test_rate_sheets.py,sha256=hXpXf9S8EBfyteMVZNTFU-GEN3XIFeI6cdCHjSsfYtc,74121
33
+ karrio/server/graph/tests/test_rate_sheets.py,sha256=2SQmzjkLIkFznTuMpmT5TK4oNmyS_o42rZUdPon8zng,93950
34
34
  karrio/server/graph/tests/test_registration.py,sha256=0vCTqlsLc0cl2m78umgfm7grnDgTI_NZJWNUrRUlQBY,7107
35
35
  karrio/server/graph/tests/test_templates.py,sha256=juHtyN2XxnWPeUv-5sgucKLdXjtJa9_MBbb2-SFW5mM,14188
36
36
  karrio/server/graph/tests/test_user_info.py,sha256=K91BL7SgxLWflCZriSVI8Vt5M5RIqmSCHKrgN2w8GmM,1928
37
37
  karrio/server/settings/graph.py,sha256=cz2yQHbp3xCfyFKuUkPEFfkI2fFVggExIY49wGz7mt0,106
38
- karrio_server_graph-2026.1.4.dist-info/METADATA,sha256=DdnvIHdY734E3mP4QIYcjNWOimNyzLwskba30GOayks,740
39
- karrio_server_graph-2026.1.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
40
- karrio_server_graph-2026.1.4.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
41
- karrio_server_graph-2026.1.4.dist-info/RECORD,,
38
+ karrio_server_graph-2026.1.5.dist-info/METADATA,sha256=CQDPu_G4pEpLrCyntJF_CuhGjm1PEXpIAD9BGCDvAqg,740
39
+ karrio_server_graph-2026.1.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
40
+ karrio_server_graph-2026.1.5.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
41
+ karrio_server_graph-2026.1.5.dist-info/RECORD,,