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.
- karrio/server/core/authentication.py +38 -20
- karrio/server/core/config.py +31 -0
- karrio/server/core/datatypes.py +30 -4
- karrio/server/core/dataunits.py +26 -7
- karrio/server/core/exceptions.py +287 -17
- karrio/server/core/filters.py +14 -0
- karrio/server/core/gateway.py +284 -11
- karrio/server/core/logging.py +403 -0
- karrio/server/core/middleware.py +104 -2
- karrio/server/core/models/base.py +34 -1
- karrio/server/core/oauth_validators.py +2 -3
- karrio/server/core/permissions.py +1 -2
- karrio/server/core/serializers.py +154 -7
- karrio/server/core/signals.py +22 -28
- karrio/server/core/telemetry.py +573 -0
- karrio/server/core/tests/__init__.py +27 -0
- karrio/server/core/{tests.py → tests/base.py} +6 -7
- karrio/server/core/tests/test_exception_level.py +159 -0
- karrio/server/core/tests/test_resource_token.py +593 -0
- karrio/server/core/utils.py +688 -38
- karrio/server/core/validators.py +144 -222
- karrio/server/core/views/oauth.py +13 -12
- karrio/server/core/views/references.py +2 -2
- karrio/server/iam/apps.py +1 -4
- karrio/server/iam/migrations/0002_setup_carrier_permission_groups.py +103 -0
- karrio/server/iam/migrations/0003_remove_permission_groups.py +91 -0
- karrio/server/iam/permissions.py +7 -134
- karrio/server/iam/serializers.py +9 -3
- karrio/server/iam/signals.py +2 -4
- karrio/server/providers/admin.py +1 -1
- karrio/server/providers/migrations/0085_populate_dhl_parcel_de_oauth_credentials.py +82 -0
- karrio/server/providers/migrations/0086_rename_dhl_parcel_de_customer_number_to_billing_number.py +71 -0
- karrio/server/providers/migrations/0087_alter_carrier_capabilities.py +38 -0
- karrio/server/providers/migrations/0088_servicelevel_surcharges.py +24 -0
- karrio/server/providers/migrations/0089_servicelevel_cost_max_volume.py +31 -0
- karrio/server/providers/migrations/0090_ratesheet_surcharges_servicelevel_zone_surcharge_ids.py +47 -0
- karrio/server/providers/migrations/0091_migrate_legacy_zones_surcharges.py +154 -0
- karrio/server/providers/models/carrier.py +101 -29
- karrio/server/providers/models/service.py +182 -125
- karrio/server/providers/models/sheet.py +342 -198
- karrio/server/providers/serializers/base.py +263 -2
- karrio/server/providers/signals.py +2 -4
- karrio/server/providers/templates/providers/oauth_callback.html +105 -0
- karrio/server/providers/tests/__init__.py +5 -0
- karrio/server/providers/tests/test_connections.py +895 -0
- karrio/server/providers/views/carriers.py +1 -3
- karrio/server/providers/views/connections.py +322 -2
- karrio/server/serializers/abstract.py +112 -21
- karrio/server/tracing/utils.py +5 -8
- karrio/server/user/models.py +36 -34
- karrio/server/user/serializers.py +1 -0
- {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/METADATA +2 -2
- {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/RECORD +55 -38
- karrio/server/providers/tests.py +0 -3
- {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/WHEEL +0 -0
- {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/top_level.txt +0 -0
karrio/server/core/exceptions.py
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
157
|
+
status=response_status,
|
|
92
158
|
headers=getattr(response, "headers", None),
|
|
93
159
|
)
|
|
94
160
|
|
|
95
161
|
if isinstance(exc, APIExceptions):
|
|
96
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
karrio/server/core/filters.py
CHANGED
|
@@ -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) |
|