karrio-server-core 2025.5rc31__py3-none-any.whl → 2026.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. karrio/server/core/authentication.py +38 -20
  2. karrio/server/core/config.py +31 -0
  3. karrio/server/core/datatypes.py +30 -4
  4. karrio/server/core/dataunits.py +26 -7
  5. karrio/server/core/exceptions.py +287 -17
  6. karrio/server/core/filters.py +14 -0
  7. karrio/server/core/gateway.py +284 -11
  8. karrio/server/core/logging.py +403 -0
  9. karrio/server/core/middleware.py +104 -2
  10. karrio/server/core/models/base.py +34 -1
  11. karrio/server/core/oauth_validators.py +2 -3
  12. karrio/server/core/permissions.py +1 -2
  13. karrio/server/core/serializers.py +154 -7
  14. karrio/server/core/signals.py +22 -28
  15. karrio/server/core/telemetry.py +573 -0
  16. karrio/server/core/tests/__init__.py +27 -0
  17. karrio/server/core/{tests.py → tests/base.py} +6 -7
  18. karrio/server/core/tests/test_exception_level.py +159 -0
  19. karrio/server/core/tests/test_resource_token.py +593 -0
  20. karrio/server/core/utils.py +688 -38
  21. karrio/server/core/validators.py +144 -222
  22. karrio/server/core/views/oauth.py +13 -12
  23. karrio/server/core/views/references.py +2 -2
  24. karrio/server/iam/apps.py +1 -4
  25. karrio/server/iam/migrations/0002_setup_carrier_permission_groups.py +103 -0
  26. karrio/server/iam/migrations/0003_remove_permission_groups.py +91 -0
  27. karrio/server/iam/permissions.py +7 -134
  28. karrio/server/iam/serializers.py +9 -3
  29. karrio/server/iam/signals.py +2 -4
  30. karrio/server/providers/admin.py +1 -1
  31. karrio/server/providers/migrations/0085_populate_dhl_parcel_de_oauth_credentials.py +82 -0
  32. karrio/server/providers/migrations/0086_rename_dhl_parcel_de_customer_number_to_billing_number.py +71 -0
  33. karrio/server/providers/migrations/0087_alter_carrier_capabilities.py +38 -0
  34. karrio/server/providers/migrations/0088_servicelevel_surcharges.py +24 -0
  35. karrio/server/providers/migrations/0089_servicelevel_cost_max_volume.py +31 -0
  36. karrio/server/providers/migrations/0090_ratesheet_surcharges_servicelevel_zone_surcharge_ids.py +47 -0
  37. karrio/server/providers/migrations/0091_migrate_legacy_zones_surcharges.py +154 -0
  38. karrio/server/providers/models/carrier.py +101 -29
  39. karrio/server/providers/models/service.py +182 -125
  40. karrio/server/providers/models/sheet.py +342 -198
  41. karrio/server/providers/serializers/base.py +263 -2
  42. karrio/server/providers/signals.py +2 -4
  43. karrio/server/providers/templates/providers/oauth_callback.html +105 -0
  44. karrio/server/providers/tests/__init__.py +5 -0
  45. karrio/server/providers/tests/test_connections.py +895 -0
  46. karrio/server/providers/views/carriers.py +1 -3
  47. karrio/server/providers/views/connections.py +322 -2
  48. karrio/server/serializers/abstract.py +112 -21
  49. karrio/server/tracing/utils.py +5 -8
  50. karrio/server/user/models.py +36 -34
  51. karrio/server/user/serializers.py +1 -0
  52. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/METADATA +2 -2
  53. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/RECORD +55 -38
  54. karrio/server/providers/tests.py +0 -3
  55. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/WHEEL +0 -0
  56. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,6 @@
1
1
  import typing
2
2
  import django.db.transaction as transaction
3
+ from rest_framework import status as http_status
3
4
 
4
5
  import karrio.lib as lib
5
6
  import karrio.references as references
@@ -7,8 +8,9 @@ import karrio.server.openapi as openapi
7
8
  import karrio.server.core.utils as utils
8
9
  import karrio.server.serializers as serializers
9
10
  import karrio.server.core.dataunits as dataunits
11
+ import karrio.server.core.exceptions as exceptions
10
12
  import karrio.server.providers.models as providers
11
- from karrio.server.core.serializers import CARRIERS
13
+ from karrio.server.core.serializers import CARRIERS, Message
12
14
 
13
15
 
14
16
  def generate_carrier_serializers() -> typing.Dict[str, serializers.Serializer]:
@@ -201,7 +203,7 @@ class CarrierConnectionModelSerializer(serializers.ModelSerializer):
201
203
  context: serializers.Context,
202
204
  **kwargs,
203
205
  ) -> providers.Carrier:
204
- config = validated_data.pop("config")
206
+ config = validated_data.pop("config", None)
205
207
  carrier_name = validated_data.pop("carrier_name")
206
208
  default_capabilities = references.get_carrier_capabilities(carrier_name)
207
209
  capabilities = lib.identity(
@@ -275,3 +277,262 @@ class CarrierConnectionModelSerializer(serializers.ModelSerializer):
275
277
  )
276
278
 
277
279
  return super().update(instance, validated_data, **kwargs)
280
+
281
+
282
+ # =============================================================================
283
+ # Webhook Management Serializers
284
+ # =============================================================================
285
+
286
+
287
+ class WebhookOperationResponse(serializers.Serializer):
288
+ """Response serializer for webhook operations."""
289
+
290
+ operation = serializers.CharField(help_text="The operation performed")
291
+ success = serializers.BooleanField(help_text="Whether the operation was successful")
292
+ carrier_name = serializers.CharField(help_text="The carrier name")
293
+ carrier_id = serializers.CharField(help_text="The carrier connection ID")
294
+ messages = Message(
295
+ required=False,
296
+ many=True,
297
+ help_text="Operation messages or errors",
298
+ )
299
+
300
+
301
+ class WebhookRegisterData(serializers.Serializer):
302
+ """Request serializer for webhook registration."""
303
+
304
+ enabled_events = serializers.StringListField(
305
+ required=False,
306
+ default=["*"],
307
+ help_text="Events to subscribe to. Defaults to all events.",
308
+ )
309
+ description = serializers.CharField(
310
+ required=False,
311
+ allow_blank=True,
312
+ help_text="Description for the webhook registration.",
313
+ )
314
+
315
+
316
+ class WebhookRegisterSerializer(serializers.Serializer):
317
+ """Handles webhook registration with carriers. Returns webhook details on success."""
318
+
319
+ webhook_url = serializers.URLField(
320
+ required=True,
321
+ help_text="The URL to receive webhook events.",
322
+ )
323
+ description = serializers.CharField(
324
+ required=False,
325
+ allow_blank=True,
326
+ help_text="Description for the webhook registration.",
327
+ )
328
+
329
+ @utils.error_wrapper
330
+ def update(self, connection: providers.Carrier, validated_data: dict, **kwargs):
331
+ import karrio.server.core.gateway as gateway
332
+
333
+ webhook_url = validated_data["webhook_url"]
334
+ description = validated_data.get(
335
+ "description", f"Karrio webhook for {connection.carrier_id}"
336
+ )
337
+
338
+ webhook_details, messages = gateway.Webhooks.register(
339
+ dict(url=webhook_url, description=description),
340
+ carrier=connection,
341
+ **kwargs,
342
+ )
343
+
344
+ if webhook_details is None:
345
+ raise exceptions.APIException(
346
+ detail=messages,
347
+ status_code=http_status.HTTP_424_FAILED_DEPENDENCY,
348
+ )
349
+
350
+ return webhook_details
351
+
352
+
353
+ class WebhookDeregisterSerializer(serializers.Serializer):
354
+ """Handles webhook deregistration from carriers. Returns confirmation on success."""
355
+
356
+ webhook_id = serializers.CharField(
357
+ required=True,
358
+ help_text="The webhook ID to deregister.",
359
+ )
360
+
361
+ @utils.error_wrapper
362
+ def update(self, connection: providers.Carrier, validated_data: dict, **kwargs):
363
+ import karrio.server.core.gateway as gateway
364
+
365
+ confirmation, messages = gateway.Webhooks.unregister(
366
+ payload=dict(webhook_id=validated_data["webhook_id"]),
367
+ carrier=connection,
368
+ )
369
+
370
+ if not (confirmation and confirmation.success):
371
+ raise exceptions.APIException(
372
+ detail=messages,
373
+ status_code=http_status.HTTP_424_FAILED_DEPENDENCY,
374
+ )
375
+
376
+ return confirmation
377
+
378
+
379
+ # =============================================================================
380
+ # OAuth Callback Serializers
381
+ # =============================================================================
382
+
383
+
384
+ class OAuthAuthorizeData(serializers.Serializer):
385
+ """Request serializer for OAuth authorization."""
386
+
387
+ frontend_url = serializers.CharField(
388
+ required=False,
389
+ allow_blank=True,
390
+ help_text="Frontend URL to redirect to after OAuth callback.",
391
+ )
392
+
393
+
394
+ class OAuthCallbackData(serializers.Serializer):
395
+ """Request serializer for OAuth callback data."""
396
+
397
+ query = serializers.PlainDictField(
398
+ required=False,
399
+ default={},
400
+ help_text="Query parameters from the OAuth callback.",
401
+ )
402
+ body = serializers.PlainDictField(
403
+ required=False,
404
+ default={},
405
+ help_text="Body data from the OAuth callback.",
406
+ )
407
+ headers = serializers.PlainDictField(
408
+ required=False,
409
+ default={},
410
+ help_text="Headers from the OAuth callback.",
411
+ )
412
+ url = serializers.CharField(
413
+ required=False,
414
+ help_text="The full callback URL.",
415
+ )
416
+
417
+
418
+ class OAuthCallbackSerializer(serializers.Serializer):
419
+ """Handles OAuth callback processing logic."""
420
+
421
+ @staticmethod
422
+ def process_callback(
423
+ request,
424
+ carrier_name: str,
425
+ ) -> dict:
426
+ """Process OAuth callback and return result dict."""
427
+ import json
428
+ import base64
429
+ import karrio.lib as lib
430
+ import karrio.server.core.gateway as gateway
431
+
432
+ payload = OAuthCallbackData.map(
433
+ data=dict(
434
+ query=request.query_params.dict(),
435
+ body=(
436
+ request.data.dict()
437
+ if hasattr(request.data, "dict")
438
+ else dict(request.data or {})
439
+ ),
440
+ headers=dict(request.headers),
441
+ url=request.build_absolute_uri(),
442
+ )
443
+ ).data
444
+
445
+ [output, messages] = gateway.Hooks.on_oauth_callback(
446
+ payload=payload,
447
+ carrier_name=carrier_name,
448
+ test_mode=request.test_mode,
449
+ context=request,
450
+ )
451
+
452
+ result = dict(
453
+ type="oauth_callback",
454
+ success=output is not None,
455
+ carrier_name=carrier_name,
456
+ credentials=lib.to_dict(output) if output else None,
457
+ messages=lib.to_dict(messages),
458
+ state=request.query_params.get("state"),
459
+ )
460
+
461
+ frontend_url = None
462
+ state = request.query_params.get("state")
463
+ if state:
464
+ try:
465
+ state_data = json.loads(base64.b64decode(state).decode("utf-8"))
466
+ frontend_url = state_data.get("frontend_url")
467
+ except Exception:
468
+ pass
469
+
470
+ return result, frontend_url
471
+
472
+
473
+ # =============================================================================
474
+ # Webhook Event Serializers
475
+ # =============================================================================
476
+
477
+
478
+ class WebhookEventSerializer(serializers.Serializer):
479
+ """Handles webhook event processing logic."""
480
+
481
+ @staticmethod
482
+ def process_event(request, pk: str) -> tuple:
483
+ """
484
+ Process webhook event and return response data and status code.
485
+
486
+ Returns:
487
+ tuple: (response_data, http_status_code)
488
+ """
489
+ import django.db.models as django
490
+ import karrio.lib as lib
491
+ import karrio.server.core.gateway as gateway
492
+
493
+ try:
494
+ connection = providers.Carrier.objects.get(pk=pk)
495
+ except providers.Carrier.DoesNotExist:
496
+ return (
497
+ dict(
498
+ operation="Webhook event",
499
+ success=False,
500
+ messages=[{"message": f"Connection not found: {pk}"}],
501
+ ),
502
+ http_status.HTTP_404_NOT_FOUND,
503
+ )
504
+
505
+ event, messages = gateway.Hooks.on_webhook_event(
506
+ payload=dict(
507
+ url=request.build_absolute_uri(),
508
+ body=request.data,
509
+ query=dict(request.query_params),
510
+ headers=dict(request.headers),
511
+ ),
512
+ carrier=connection,
513
+ )
514
+
515
+ if event and event.tracking:
516
+ import karrio.server.manager.models as manager_models
517
+ import karrio.server.manager.serializers.tracking as tracking_serializers
518
+
519
+ tracker = manager_models.Tracking.objects.filter(
520
+ django.Q(tracking_number=event.tracking.tracking_number)
521
+ | django.Q(tracking_carrier=connection)
522
+ ).first()
523
+
524
+ if tracker:
525
+ tracking_serializers.update_tracker(
526
+ tracker, lib.to_dict(event.tracking)
527
+ )
528
+
529
+ return (
530
+ dict(
531
+ operation="Webhook event",
532
+ success=len(messages) == 0,
533
+ carrier_name=connection.carrier_name,
534
+ carrier_id=connection.carrier_id,
535
+ messages=lib.to_dict(messages),
536
+ ),
537
+ http_status.HTTP_200_OK,
538
+ )
@@ -1,17 +1,15 @@
1
- import logging
2
1
  from django.db.models import signals
3
2
 
4
3
  import karrio.references as ref
5
4
  import karrio.server.core.utils as utils
5
+ from karrio.server.core.logging import logger
6
6
  import karrio.server.providers.models as models
7
7
 
8
- logger = logging.getLogger(__name__)
9
-
10
8
 
11
9
  def register_signals():
12
10
  signals.post_save.connect(carrier_changed, sender=models.Carrier)
13
11
 
14
- logger.info("karrio.providers signals registered...")
12
+ logger.info("Karrio providers signals registered")
15
13
 
16
14
 
17
15
  @utils.disable_for_loaddata
@@ -0,0 +1,105 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>OAuth Callback</title>
5
+ <style>
6
+ body {
7
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
8
+ display: flex;
9
+ justify-content: center;
10
+ align-items: center;
11
+ height: 100vh;
12
+ margin: 0;
13
+ background: #f5f5f5;
14
+ }
15
+ .container {
16
+ text-align: center;
17
+ padding: 40px;
18
+ background: white;
19
+ border-radius: 8px;
20
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
21
+ max-width: 400px;
22
+ }
23
+ .success { color: #16a34a; }
24
+ .error { color: #dc2626; }
25
+ .icon {
26
+ font-size: 48px;
27
+ margin-bottom: 16px;
28
+ }
29
+ h2 { margin-bottom: 10px; }
30
+ p { color: #666; margin-bottom: 20px; }
31
+ .close-hint {
32
+ font-size: 12px;
33
+ color: #999;
34
+ }
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <div class="container">
39
+ <div class="icon">{% if success %}✓{% else %}✗{% endif %}</div>
40
+ <h2 class="{% if success %}success{% else %}error{% endif %}">
41
+ {% if success %}Authorization Successful{% else %}Authorization Failed{% endif %}
42
+ </h2>
43
+ <p>{% if success %}You can close this window and return to the application.{% else %}{{ error_message }}{% endif %}</p>
44
+ <p class="close-hint">This window will close automatically...</p>
45
+ </div>
46
+ <script>
47
+ (async function() {
48
+ var result = {{ result_json|safe }};
49
+
50
+ /**
51
+ * Encrypts data using AES-GCM with Web Crypto API.
52
+ * Returns an object containing the encrypted data, IV, and key (all base64 encoded).
53
+ */
54
+ async function encryptData(data) {
55
+ // Generate a random 256-bit key
56
+ var key = await crypto.subtle.generateKey(
57
+ { name: "AES-GCM", length: 256 },
58
+ true, // extractable - needed to export the key
59
+ ["encrypt", "decrypt"]
60
+ );
61
+
62
+ // Generate a random 96-bit IV (recommended for AES-GCM)
63
+ var iv = crypto.getRandomValues(new Uint8Array(12));
64
+
65
+ // Encode the data as UTF-8
66
+ var encoder = new TextEncoder();
67
+ var encodedData = encoder.encode(data);
68
+
69
+ // Encrypt the data
70
+ var encryptedBuffer = await crypto.subtle.encrypt(
71
+ { name: "AES-GCM", iv: iv },
72
+ key,
73
+ encodedData
74
+ );
75
+
76
+ // Export the key for storage
77
+ var exportedKey = await crypto.subtle.exportKey("raw", key);
78
+
79
+ // Convert to base64 for storage
80
+ var ciphertext = btoa(String.fromCharCode.apply(null, new Uint8Array(encryptedBuffer)));
81
+ var ivBase64 = btoa(String.fromCharCode.apply(null, iv));
82
+ var keyBase64 = btoa(String.fromCharCode.apply(null, new Uint8Array(exportedKey)));
83
+
84
+ return { ciphertext: ciphertext, iv: ivBase64, key: keyBase64 };
85
+ }
86
+
87
+ // Store encrypted result in localStorage for the opener to read
88
+ // Using localStorage (not sessionStorage) because popup windows have
89
+ // separate sessionStorage contexts from their opener window.
90
+ // Data is encrypted with AES-GCM for security.
91
+ try {
92
+ var encrypted = await encryptData(JSON.stringify(result));
93
+ localStorage.setItem('karrio_oauth_result', JSON.stringify(encrypted));
94
+ } catch (e) {
95
+ console.error('Failed to encrypt/store OAuth result:', e);
96
+ }
97
+
98
+ // Try to close the window after a short delay
99
+ setTimeout(function() {
100
+ window.close();
101
+ }, 2000);
102
+ })();
103
+ </script>
104
+ </body>
105
+ </html>
@@ -0,0 +1,5 @@
1
+ import logging
2
+
3
+ logging.disable(logging.CRITICAL)
4
+
5
+ from karrio.server.providers.tests.test_connections import *