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,39 +1,86 @@
1
+ import re
1
2
  import typing
2
- import logging
3
- import traceback
4
3
  from rest_framework.response import Response
5
4
  from rest_framework import status, exceptions
6
5
  from rest_framework.views import exception_handler
7
6
  from django.core.exceptions import ObjectDoesNotExist
8
7
  from django.utils.translation import gettext_lazy as _
8
+ from karrio.server.core.logging import logger
9
9
 
10
10
  import karrio.lib as lib
11
11
  import karrio.core.errors as sdk
12
12
  from karrio.server.core.datatypes import Error, Message
13
13
 
14
- logger = logging.getLogger(__name__)
15
-
16
14
 
17
15
  class ValidationError(exceptions.ValidationError, sdk.ValidationError):
18
16
  pass
19
17
 
20
18
 
19
+ # Default error levels based on HTTP status codes
20
+ # These can be overridden by setting the `level` attribute on exceptions
21
+ ERROR_LEVEL_DEFAULTS = {
22
+ # 4xx Client Errors
23
+ 400: "error", # Bad Request
24
+ 401: "error", # Unauthorized
25
+ 403: "error", # Forbidden
26
+ 404: "warning", # Not Found - often informational
27
+ 405: "error", # Method Not Allowed
28
+ 409: "error", # Conflict
29
+ 422: "error", # Unprocessable Entity
30
+ 429: "warning", # Too Many Requests - rate limiting
31
+ # 5xx Server Errors
32
+ 500: "error", # Internal Server Error
33
+ 502: "error", # Bad Gateway
34
+ 503: "warning", # Service Unavailable - temporary
35
+ 504: "error", # Gateway Timeout
36
+ }
37
+
38
+
39
+ def get_default_level(status_code: int, exc: typing.Optional[Exception] = None) -> str:
40
+ """Get the default error level based on status code.
41
+
42
+ Priority:
43
+ 1. Exception's explicit `level` attribute (if set)
44
+ 2. Status code mapping from ERROR_LEVEL_DEFAULTS
45
+ 3. Default to "error" for 4xx/5xx, "info" for others
46
+ """
47
+ # Check if exception has an explicit level set
48
+ if exc is not None and hasattr(exc, "level") and exc.level is not None:
49
+ return exc.level
50
+
51
+ # Use status code mapping
52
+ if status_code in ERROR_LEVEL_DEFAULTS:
53
+ return ERROR_LEVEL_DEFAULTS[status_code]
54
+
55
+ # Default based on status code range
56
+ if 400 <= status_code < 500:
57
+ return "error"
58
+ elif status_code >= 500:
59
+ return "error"
60
+
61
+ return "info"
62
+
63
+
21
64
  class APIException(exceptions.APIException):
22
65
  default_status_code = status.HTTP_400_BAD_REQUEST
23
66
  default_detail = _("Invalid input.")
24
67
  default_code = "failure"
68
+ default_level = None # None means use status code default
25
69
 
26
- def __init__(self, detail=None, code=None, status_code=None):
70
+ def __init__(self, detail=None, code=None, status_code=None, level=None):
27
71
  if detail is None:
28
72
  detail = self.default_detail
29
73
  if code is None:
30
74
  code = self.default_code
31
75
  if status_code is None:
32
76
  status_code = self.default_status_code
77
+ if level is None:
78
+ level = self.default_level
33
79
 
34
80
  self.status_code = status_code
35
81
  self.code = code
36
82
  self.detail = detail
83
+ self.level = level
37
84
 
38
85
 
39
86
  class IndexedAPIException(APIException):
@@ -47,61 +94,84 @@ class APIExceptions(APIException):
47
94
 
48
95
 
49
96
  def custom_exception_handler(exc, context):
50
- logging.error(exc)
97
+ from django.conf import settings
98
+
99
+ # Extract request details and log exception
100
+ request_details = _get_request_details(context)
101
+ _log_exception(exc, request_details, debug=getattr(settings, "DEBUG", False))
102
+
103
+ # Capture exception to telemetry (Sentry/OTEL/Datadog)
104
+ # This ensures handled exceptions are still tracked in APM
105
+ _capture_exception_to_telemetry(exc, request_details, context)
51
106
 
52
107
  response = exception_handler(exc, context)
53
108
  detail = getattr(exc, "detail", None)
54
109
  messages = message_handler(exc)
55
- status_code = getattr(exc, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR)
56
110
  code = get_code(exc)
57
111
 
58
112
  if isinstance(exc, exceptions.ValidationError) or isinstance(
59
113
  exc, sdk.ValidationError
60
114
  ):
115
+ response_status = status.HTTP_400_BAD_REQUEST
116
+ level = get_default_level(response_status, exc)
117
+ formatted_errors = _format_validation_errors(detail, level=level) if detail else None
61
118
  return Response(
62
119
  messages
63
120
  or dict(
64
121
  errors=lib.to_dict(
65
- [
122
+ formatted_errors
123
+ or [
66
124
  Error(
67
125
  code=code or "validation",
68
126
  message=detail if isinstance(detail, str) else None,
127
+ level=level,
69
128
  details=(detail if not isinstance(detail, str) else None),
70
129
  )
71
130
  ]
72
131
  )
73
132
  ),
74
- status=status.HTTP_400_BAD_REQUEST,
133
+ status=response_status,
75
134
  headers=getattr(response, "headers", None),
76
135
  )
77
136
 
78
137
  if isinstance(exc, ObjectDoesNotExist):
138
+ response_status = status.HTTP_404_NOT_FOUND
139
+ level = get_default_level(response_status, exc)
140
+ resource_name = _get_resource_name(exc)
141
+ message = f"{resource_name} not found" if resource_name else (
142
+ detail if isinstance(detail, str) else "Resource not found"
143
+ )
79
144
  return Response(
80
145
  dict(
81
146
  errors=lib.to_dict(
82
147
  [
83
148
  Error(
84
149
  code=code or "not_found",
85
- message=detail if isinstance(detail, str) else None,
150
+ message=message,
151
+ level=level,
86
152
  details=(detail if not isinstance(detail, str) else None),
87
153
  )
88
154
  ]
89
155
  )
90
156
  ),
91
- status=status.HTTP_404_NOT_FOUND,
157
+ status=response_status,
92
158
  headers=getattr(response, "headers", None),
93
159
  )
94
160
 
95
161
  if isinstance(exc, APIExceptions):
96
- errors = error_handler(exc)
162
+ response_status = getattr(exc, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR)
163
+ level = get_default_level(response_status, exc)
164
+ errors = error_handler(exc, level=level)
97
165
  if errors is not None:
98
166
  return Response(
99
167
  lib.to_dict(errors),
100
- status=status_code,
168
+ status=response_status,
101
169
  headers=getattr(response, "headers", None),
102
170
  )
103
171
 
104
172
  if isinstance(exc, APIException) or isinstance(exc, exceptions.APIException):
173
+ response_status = getattr(exc, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR)
174
+ level = get_default_level(response_status, exc)
105
175
  return Response(
106
176
  messages
107
177
  or dict(
@@ -110,20 +180,23 @@ def custom_exception_handler(exc, context):
110
180
  Error(
111
181
  code=code,
112
182
  message=detail if isinstance(detail, str) else None,
183
+ level=level,
113
184
  details=(detail if not isinstance(detail, str) else None),
114
185
  )
115
186
  ]
116
187
  )
117
188
  ),
118
- status=status_code,
189
+ status=response_status,
119
190
  headers=getattr(response, "headers", None),
120
191
  )
121
192
 
122
193
  elif isinstance(exc, Exception):
194
+ response_status = getattr(exc, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR)
195
+ level = get_default_level(response_status, exc)
123
196
  message, *_ = list(exc.args)
124
197
  return Response(
125
- dict(errors=lib.to_dict([Error(code=code, message=message)])),
126
- status=status_code,
198
+ dict(errors=lib.to_dict([Error(code=code, message=message, level=level)])),
199
+ status=response_status,
127
200
  headers=getattr(response, "headers", None),
128
201
  )
129
202
 
@@ -143,6 +216,7 @@ def message_handler(exc) -> typing.Optional[dict]:
143
216
  dict(
144
217
  code=msg.code,
145
218
  message=msg.message,
219
+ level=msg.level,
146
220
  details=msg.details,
147
221
  carrier_id=msg.carrier_id,
148
222
  carrier_name=msg.carrier_name,
@@ -155,7 +229,7 @@ def message_handler(exc) -> typing.Optional[dict]:
155
229
  return None
156
230
 
157
231
 
158
- def error_handler(exc) -> typing.Optional[dict]:
232
+ def error_handler(exc, level: str = "error") -> typing.Optional[dict]:
159
233
  if (
160
234
  hasattr(exc, "detail")
161
235
  and isinstance(exc.detail, list)
@@ -169,6 +243,8 @@ def error_handler(exc) -> typing.Optional[dict]:
169
243
  detail = getattr(error, "detail", None)
170
244
  index = getattr(error, "index", None)
171
245
  code = get_code(error) or "error"
246
+ # Allow individual errors to override the level
247
+ error_level = getattr(error, "level", None) or level
172
248
  errors.append(
173
249
  dict(
174
250
  index=index,
@@ -178,6 +254,7 @@ def error_handler(exc) -> typing.Optional[dict]:
178
254
  if detail
179
255
  else message
180
256
  ),
257
+ level=error_level,
181
258
  details=(detail if not isinstance(detail, str) else None),
182
259
  )
183
260
  )
@@ -198,3 +275,196 @@ def get_code(exc):
198
275
  )
199
276
 
200
277
  return getattr(exc, "default_code", None)
278
+
279
+
280
+ def _get_request_details(context: dict) -> dict:
281
+ """Extract request details from context for logging."""
282
+ request = context.get("view", None) and context.get("view").request
283
+
284
+ if not request:
285
+ return {}
286
+
287
+ return {
288
+ "method": getattr(request, "method", None),
289
+ "path": getattr(request, "path", None),
290
+ "user": str(getattr(request, "user", None)),
291
+ "user_id": getattr(getattr(request, "user", None), "id", None),
292
+ "query_params": dict(getattr(request, "GET", {})),
293
+ "content_type": getattr(request, "content_type", None),
294
+ }
295
+
296
+
297
+ def _capture_exception_to_telemetry(exc: Exception, request_details: dict, context: dict):
298
+ """Capture exception to APM telemetry (Sentry/OTEL/Datadog).
299
+
300
+ This ensures that handled exceptions (which return proper HTTP responses)
301
+ are still tracked in external APM tools for visibility and alerting.
302
+ """
303
+ from karrio.server.core.utils import failsafe
304
+
305
+ def _capture():
306
+ from karrio.server.core.telemetry import get_telemetry_for_request
307
+
308
+ telemetry = get_telemetry_for_request()
309
+ status_code = getattr(exc, "status_code", 500)
310
+
311
+ # Build context for the exception
312
+ exc_context = {
313
+ "exception_type": type(exc).__name__,
314
+ "status_code": status_code,
315
+ **{k: str(v) if isinstance(v, (dict, list)) else v for k, v in request_details.items()},
316
+ }
317
+
318
+ # Add carrier info if available in exception detail
319
+ detail = getattr(exc, "detail", None)
320
+ if isinstance(detail, list) and len(detail) > 0:
321
+ first = detail[0]
322
+ if hasattr(first, "carrier_name"):
323
+ exc_context["carrier_name"] = first.carrier_name
324
+ if hasattr(first, "carrier_id"):
325
+ exc_context["carrier_id"] = first.carrier_id
326
+
327
+ # Build tags
328
+ tags = {
329
+ "error_type": type(exc).__name__,
330
+ "status_code": str(status_code),
331
+ "error_class": "client" if status_code < 500 else "server",
332
+ }
333
+
334
+ # Capture to telemetry
335
+ telemetry.capture_exception(exc, context=exc_context, tags=tags)
336
+
337
+ # Record error metric
338
+ telemetry.record_metric(
339
+ "karrio.api.exception",
340
+ 1,
341
+ tags={
342
+ "exception_type": type(exc).__name__,
343
+ "status_code": str(status_code),
344
+ "path": request_details.get("path", "unknown"),
345
+ },
346
+ metric_type="counter",
347
+ )
348
+
349
+ failsafe(_capture)
350
+
351
+
352
+ def _log_exception(exc: Exception, request_details: dict, debug: bool = False):
353
+ """Log exception with appropriate detail level based on environment."""
354
+ exc_type = type(exc).__name__
355
+ exc_message = str(exc)
356
+
357
+ # Build context dict - convert dicts to strings to avoid format string issues
358
+ context = {
359
+ "exception_type": exc_type,
360
+ "exception_message": exc_message,
361
+ }
362
+
363
+ # Add request details, flattening nested structures
364
+ for key, value in request_details.items():
365
+ if isinstance(value, (dict, list)):
366
+ # Convert to string to avoid KeyError when loguru formats the message
367
+ context[key] = str(value)
368
+ else:
369
+ context[key] = value
370
+
371
+ if debug:
372
+ # In development, log with full traceback for better debugging
373
+ # Use positional args to avoid format string issues with curly braces in exception messages
374
+ logger.opt(exception=exc).error(
375
+ "Exception in request: {} - {}",
376
+ exc_type,
377
+ exc_message,
378
+ **context,
379
+ )
380
+ else:
381
+ # In production, log without full traceback but with context
382
+ logger.error(
383
+ "Exception in request: {}",
384
+ exc_type,
385
+ **context,
386
+ )
387
+
388
+
389
+ def _get_resource_name(exc: ObjectDoesNotExist) -> typing.Optional[str]:
390
+ """Extract resource name from ObjectDoesNotExist exception."""
391
+ exc_class_name = type(exc).__name__
392
+
393
+ # Handle Model.DoesNotExist pattern (e.g., Address.DoesNotExist -> Address)
394
+ if exc_class_name == "DoesNotExist" and hasattr(exc, "args") and exc.args:
395
+ match = re.search(r"(\w+) matching query", str(exc.args[0]))
396
+ if match:
397
+ return match.group(1)
398
+
399
+ # Handle ObjectDoesNotExist with model info in class hierarchy
400
+ for cls in type(exc).__mro__:
401
+ if cls.__name__ not in ("DoesNotExist", "ObjectDoesNotExist", "Exception", "BaseException", "object"):
402
+ return cls.__name__
403
+
404
+ return None
405
+
406
+
407
+ def _format_validation_errors(
408
+ detail: typing.Any,
409
+ prefix: str = "",
410
+ level: str = "error",
411
+ ) -> typing.Optional[typing.List[Error]]:
412
+ """Format validation errors with items[index].field pattern for list errors."""
413
+ if detail is None:
414
+ return None
415
+
416
+ if isinstance(detail, str):
417
+ return [Error(code="validation", message=detail, level=level)]
418
+
419
+ def _build_path(base: str, key: str) -> str:
420
+ return f"{base}.{key}" if base else key
421
+
422
+ def _build_index_path(base: str, index: int, field: str = None) -> str:
423
+ index_part = f"{base}[{index}]" if base else f"items[{index}]"
424
+ return f"{index_part}.{field}" if field else index_part
425
+
426
+ def _flatten_errors(data: typing.Any, path: str = "") -> typing.List[Error]:
427
+ if data is None:
428
+ return []
429
+
430
+ if isinstance(data, str):
431
+ message = f"{path}: {data}" if path else data
432
+ return [Error(code="validation", message=message, level=level)]
433
+
434
+ if isinstance(data, dict):
435
+ return [
436
+ err
437
+ for key, value in data.items()
438
+ for err in _flatten_errors(value, _build_path(path, key))
439
+ ]
440
+
441
+ if isinstance(data, list):
442
+ has_indexed_items = any(isinstance(item, dict) for item in data)
443
+ if has_indexed_items:
444
+ return [
445
+ err
446
+ for index, item in enumerate(data)
447
+ if item # skip empty dicts for list rows without errors
448
+ for err in (
449
+ [
450
+ nested_err
451
+ for field, field_errors in item.items()
452
+ for nested_err in _flatten_errors(
453
+ field_errors, _build_index_path(path, index, field)
454
+ )
455
+ ]
456
+ if isinstance(item, dict)
457
+ else _flatten_errors(item, _build_index_path(path, index))
458
+ )
459
+ ]
460
+ return [
461
+ Error(code="validation", message=f"{path}: {item}" if path else str(item), level=level)
462
+ for item in data
463
+ if item
464
+ ]
465
+
466
+ message = f"{path}: {data}" if path else str(data)
467
+ return [Error(code="validation", message=message, level=level)]
468
+
469
+ errors = _flatten_errors(detail, prefix)
470
+ return errors if errors else None
@@ -601,6 +601,10 @@ class TrackerFilters(filters.FilterSet):
601
601
 
602
602
 
603
603
  class LogFilter(filters.FilterSet):
604
+ query = filters.CharFilter(
605
+ method="query_filter",
606
+ help_text="search in entity_id, path, and other log fields",
607
+ )
604
608
  api_endpoint = filters.CharFilter(field_name="path", lookup_expr="icontains")
605
609
  remote_addr = filters.CharFilter(field_name="remote_addr", lookup_expr="exact")
606
610
  date_after = filters.DateTimeFilter(field_name="requested_at", lookup_expr="gte")
@@ -641,6 +645,16 @@ class LogFilter(filters.FilterSet):
641
645
 
642
646
  return queryset
643
647
 
648
+ def query_filter(self, queryset, name, value):
649
+ return queryset.filter(
650
+ models.Q(entity_id__icontains=value) |
651
+ models.Q(data__icontains=value) |
652
+ models.Q(path__icontains=value) |
653
+ models.Q(remote_addr__icontains=value) |
654
+ models.Q(host__icontains=value) |
655
+ models.Q(method__icontains=value)
656
+ )
657
+
644
658
  def keyword_filter(self, queryset, name, value):
645
659
  return queryset.filter(
646
660
  models.Q(entity_id__icontains=value) |