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
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import io
|
|
2
2
|
import base64
|
|
3
|
-
import logging
|
|
4
3
|
import re
|
|
5
4
|
from django.http import JsonResponse
|
|
6
5
|
from django.urls import path, re_path
|
|
@@ -15,9 +14,8 @@ import karrio.server.openapi as openapi
|
|
|
15
14
|
import karrio.server.samples as samples
|
|
16
15
|
import karrio.server.core.views.api as api
|
|
17
16
|
import karrio.server.providers.models as models
|
|
17
|
+
from karrio.server.core.logging import logger
|
|
18
18
|
from karrio.server.core import datatypes, dataunits, serializers
|
|
19
|
-
|
|
20
|
-
logger = logging.getLogger(__name__)
|
|
21
19
|
ENDPOINT_ID = "&&" # This endpoint id is used to make operation ids unique make sure not to duplicate
|
|
22
20
|
|
|
23
21
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import logging
|
|
2
1
|
import django.urls as urls
|
|
3
2
|
import rest_framework.status as status
|
|
4
3
|
import rest_framework.request as request
|
|
@@ -14,7 +13,6 @@ import karrio.server.core.gateway as gateway
|
|
|
14
13
|
import karrio.server.providers.models as models
|
|
15
14
|
import karrio.server.providers.serializers as serializers
|
|
16
15
|
|
|
17
|
-
logger = logging.getLogger(__name__)
|
|
18
16
|
ENDPOINT_ID = "&&&" # This endpoint id is used to make operation ids unique make sure not to duplicate
|
|
19
17
|
CarrierConnectionList = serializers.PaginatedResult(
|
|
20
18
|
"CarrierConnectionList", serializers.CarrierConnection
|
|
@@ -162,6 +160,298 @@ class CarrierConnectionDetail(api.APIView):
|
|
|
162
160
|
return response.Response(serializers.CarrierConnection(connection).data)
|
|
163
161
|
|
|
164
162
|
|
|
163
|
+
class ConnectionWebhookRegister(api.APIView):
|
|
164
|
+
@openapi.extend_schema(
|
|
165
|
+
exclude=True,
|
|
166
|
+
tags=["Connections"],
|
|
167
|
+
operation_id=f"{ENDPOINT_ID}webhook-register",
|
|
168
|
+
extensions={"x-operationId": "webhookRegister"},
|
|
169
|
+
summary="Register a webhook for a carrier connection",
|
|
170
|
+
request=serializers.WebhookRegisterData(),
|
|
171
|
+
responses={
|
|
172
|
+
200: serializers.WebhookOperationResponse(),
|
|
173
|
+
400: serializers.ErrorResponse(),
|
|
174
|
+
424: serializers.ErrorMessages(),
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
def post(self, request: request.Request, pk: str):
|
|
178
|
+
"""Register a webhook endpoint for a carrier connection."""
|
|
179
|
+
connection = models.Carrier.access_by(request).get(pk=pk)
|
|
180
|
+
webhook_url = request.build_absolute_uri(f"/v1/connections/webhook/{pk}/events")
|
|
181
|
+
|
|
182
|
+
webhook_details = (
|
|
183
|
+
serializers.WebhookRegisterSerializer.map(
|
|
184
|
+
connection,
|
|
185
|
+
data={"webhook_url": webhook_url, **request.data},
|
|
186
|
+
context=request,
|
|
187
|
+
)
|
|
188
|
+
.save()
|
|
189
|
+
.instance
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
updated = lib.identity(
|
|
193
|
+
serializers.CarrierConnectionModelSerializer.map(
|
|
194
|
+
connection,
|
|
195
|
+
data=dict(
|
|
196
|
+
config=dict(
|
|
197
|
+
webhook_id=webhook_details.webhook_identifier,
|
|
198
|
+
webhook_secret=webhook_details.secret,
|
|
199
|
+
webhook_url=webhook_url,
|
|
200
|
+
)
|
|
201
|
+
),
|
|
202
|
+
context=request,
|
|
203
|
+
)
|
|
204
|
+
.save()
|
|
205
|
+
.instance
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return response.Response(
|
|
209
|
+
serializers.WebhookOperationResponse(
|
|
210
|
+
dict(
|
|
211
|
+
carrier_name=updated.carrier_name,
|
|
212
|
+
carrier_id=updated.carrier_id,
|
|
213
|
+
operation="Webhook registration",
|
|
214
|
+
success=True,
|
|
215
|
+
)
|
|
216
|
+
).data,
|
|
217
|
+
status=status.HTTP_200_OK,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class ConnectionWebhookDeregister(api.APIView):
|
|
222
|
+
@openapi.extend_schema(
|
|
223
|
+
exclude=True,
|
|
224
|
+
tags=["Connections"],
|
|
225
|
+
operation_id=f"{ENDPOINT_ID}webhook-deregister",
|
|
226
|
+
extensions={"x-operationId": "webhookDeregister"},
|
|
227
|
+
summary="Deregister a webhook for a carrier connection",
|
|
228
|
+
responses={
|
|
229
|
+
200: serializers.WebhookOperationResponse(),
|
|
230
|
+
400: serializers.ErrorResponse(),
|
|
231
|
+
424: serializers.ErrorMessages(),
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
def post(self, request: request.Request, pk: str):
|
|
235
|
+
"""Deregister a webhook endpoint from a carrier connection."""
|
|
236
|
+
connection = models.Carrier.access_by(request).get(pk=pk)
|
|
237
|
+
|
|
238
|
+
serializers.WebhookDeregisterSerializer.map(
|
|
239
|
+
connection,
|
|
240
|
+
data=dict(webhook_id=connection.config.get("webhook_id")),
|
|
241
|
+
context=request,
|
|
242
|
+
).save()
|
|
243
|
+
|
|
244
|
+
updated = lib.identity(
|
|
245
|
+
serializers.CarrierConnectionModelSerializer.map(
|
|
246
|
+
connection,
|
|
247
|
+
data=dict(
|
|
248
|
+
config=dict(
|
|
249
|
+
webhook_id=None,
|
|
250
|
+
webhook_secret=None,
|
|
251
|
+
webhook_url=None,
|
|
252
|
+
)
|
|
253
|
+
),
|
|
254
|
+
context=request,
|
|
255
|
+
)
|
|
256
|
+
.save()
|
|
257
|
+
.instance
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return response.Response(
|
|
261
|
+
serializers.WebhookOperationResponse(
|
|
262
|
+
dict(
|
|
263
|
+
operation="Webhook deregistration",
|
|
264
|
+
success=True,
|
|
265
|
+
carrier_name=updated.carrier_name,
|
|
266
|
+
carrier_id=updated.carrier_id,
|
|
267
|
+
)
|
|
268
|
+
).data,
|
|
269
|
+
status=status.HTTP_200_OK,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class ConnectionWebhookDisconnect(api.APIView):
|
|
274
|
+
@openapi.extend_schema(
|
|
275
|
+
exclude=True,
|
|
276
|
+
tags=["Connections"],
|
|
277
|
+
operation_id=f"{ENDPOINT_ID}webhook-disconnect",
|
|
278
|
+
extensions={"x-operationId": "webhookDisconnect"},
|
|
279
|
+
summary="Force disconnect a webhook for a carrier connection",
|
|
280
|
+
responses={
|
|
281
|
+
200: serializers.WebhookOperationResponse(),
|
|
282
|
+
400: serializers.ErrorResponse(),
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
def post(self, request: request.Request, pk: str):
|
|
286
|
+
"""Force disconnect a webhook from a carrier connection (local only)."""
|
|
287
|
+
connection = models.Carrier.access_by(request).get(pk=pk)
|
|
288
|
+
|
|
289
|
+
updated = lib.identity(
|
|
290
|
+
serializers.CarrierConnectionModelSerializer.map(
|
|
291
|
+
connection,
|
|
292
|
+
data=dict(
|
|
293
|
+
config=dict(
|
|
294
|
+
webhook_id=None,
|
|
295
|
+
webhook_secret=None,
|
|
296
|
+
webhook_url=None,
|
|
297
|
+
)
|
|
298
|
+
),
|
|
299
|
+
context=request,
|
|
300
|
+
)
|
|
301
|
+
.save()
|
|
302
|
+
.instance
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
return response.Response(
|
|
306
|
+
serializers.WebhookOperationResponse(
|
|
307
|
+
dict(
|
|
308
|
+
operation="Webhook disconnect",
|
|
309
|
+
success=True,
|
|
310
|
+
carrier_name=updated.carrier_name,
|
|
311
|
+
carrier_id=updated.carrier_id,
|
|
312
|
+
)
|
|
313
|
+
).data,
|
|
314
|
+
status=status.HTTP_200_OK,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class ConnectionWebhookEvent(api.APIView):
|
|
319
|
+
"""Handle inbound webhook events from carriers."""
|
|
320
|
+
|
|
321
|
+
throttle_classes: list = []
|
|
322
|
+
permission_classes: list = []
|
|
323
|
+
authentication_classes: list = []
|
|
324
|
+
|
|
325
|
+
@openapi.extend_schema(
|
|
326
|
+
exclude=True,
|
|
327
|
+
tags=["Connections"],
|
|
328
|
+
operation_id=f"{ENDPOINT_ID}webhook-event",
|
|
329
|
+
extensions={"x-operationId": "webhookEvent"},
|
|
330
|
+
summary="Handle carrier webhook events",
|
|
331
|
+
responses={
|
|
332
|
+
200: serializers.OperationConfirmation(),
|
|
333
|
+
},
|
|
334
|
+
)
|
|
335
|
+
def post(self, request: request.Request, pk: str):
|
|
336
|
+
"""Handle inbound webhook events from a carrier via POST."""
|
|
337
|
+
data, http_status = serializers.WebhookEventSerializer.process_event(
|
|
338
|
+
request, pk
|
|
339
|
+
)
|
|
340
|
+
return response.Response(data, status=http_status)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class ConnectionOauthAuthorize(api.APIView):
|
|
344
|
+
@openapi.extend_schema(
|
|
345
|
+
exclude=True,
|
|
346
|
+
tags=["Connections"],
|
|
347
|
+
operation_id=f"{ENDPOINT_ID}oauth-authorize",
|
|
348
|
+
extensions={"x-operationId": "oauthAuthorize"},
|
|
349
|
+
summary="Handle an OAuth authorize",
|
|
350
|
+
request=serializers.OAuthAuthorizeData(),
|
|
351
|
+
)
|
|
352
|
+
def post(self, request: request.Request, carrier_name: str):
|
|
353
|
+
"""Handle an OAuth authorize."""
|
|
354
|
+
|
|
355
|
+
[output, messages] = gateway.Hooks.on_oauth_authorize(
|
|
356
|
+
serializers.OAuthAuthorizeData.map(
|
|
357
|
+
data={
|
|
358
|
+
"redirect_uri": request.build_absolute_uri(
|
|
359
|
+
f"/v1/connections/oauth/{carrier_name}/callback"
|
|
360
|
+
),
|
|
361
|
+
**request.data,
|
|
362
|
+
}
|
|
363
|
+
).data,
|
|
364
|
+
carrier_name=carrier_name,
|
|
365
|
+
test_mode=request.test_mode,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Include the frontend_url for the callback to redirect to
|
|
369
|
+
frontend_url = request.data.get("frontend_url")
|
|
370
|
+
|
|
371
|
+
return response.Response(
|
|
372
|
+
dict(
|
|
373
|
+
operation="OAuth authorize",
|
|
374
|
+
request=lib.to_dict(output),
|
|
375
|
+
messages=lib.to_dict(messages),
|
|
376
|
+
frontend_url=frontend_url,
|
|
377
|
+
),
|
|
378
|
+
status=status.HTTP_200_OK,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class ConnectionOauthCallback(api.APIView):
|
|
383
|
+
@openapi.extend_schema(
|
|
384
|
+
exclude=True,
|
|
385
|
+
tags=["Connections"],
|
|
386
|
+
operation_id=f"{ENDPOINT_ID}oauth-callback",
|
|
387
|
+
extensions={"x-operationId": "oauthCallback"},
|
|
388
|
+
summary="Handle an OAuth callback",
|
|
389
|
+
request=serializers.OAuthCallbackData(),
|
|
390
|
+
responses={
|
|
391
|
+
200: serializers.OperationConfirmation(),
|
|
392
|
+
},
|
|
393
|
+
)
|
|
394
|
+
def get(self, request: request.Request, carrier_name: str):
|
|
395
|
+
"""Handle an OAuth callback via GET."""
|
|
396
|
+
return self._handle_callback(request, carrier_name)
|
|
397
|
+
|
|
398
|
+
@openapi.extend_schema(
|
|
399
|
+
exclude=True,
|
|
400
|
+
tags=["Connections"],
|
|
401
|
+
operation_id=f"{ENDPOINT_ID}oauth-callback-post",
|
|
402
|
+
extensions={"x-operationId": "oauthCallbackPost"},
|
|
403
|
+
summary="Handle an OAuth callback via POST",
|
|
404
|
+
request=serializers.OAuthCallbackData(),
|
|
405
|
+
responses={
|
|
406
|
+
200: serializers.OperationConfirmation(),
|
|
407
|
+
},
|
|
408
|
+
)
|
|
409
|
+
def post(self, request: request.Request, carrier_name: str):
|
|
410
|
+
"""Handle an OAuth callback via POST."""
|
|
411
|
+
return self._handle_callback(request, carrier_name)
|
|
412
|
+
|
|
413
|
+
def _handle_callback(self, request: request.Request, carrier_name: str):
|
|
414
|
+
"""Handle OAuth callback processing.
|
|
415
|
+
|
|
416
|
+
Returns JSON with OAuth result for the frontend to process.
|
|
417
|
+
When called from a browser popup, renders template or redirects to frontend.
|
|
418
|
+
"""
|
|
419
|
+
import json
|
|
420
|
+
import base64
|
|
421
|
+
from django.shortcuts import render
|
|
422
|
+
from django.http import HttpResponseRedirect
|
|
423
|
+
|
|
424
|
+
result, frontend_url = serializers.OAuthCallbackSerializer.process_callback(
|
|
425
|
+
request, carrier_name
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
accept_header = request.headers.get("Accept", "")
|
|
429
|
+
|
|
430
|
+
if "text/html" in accept_header and frontend_url:
|
|
431
|
+
result_encoded = base64.b64encode(
|
|
432
|
+
json.dumps(result).encode("utf-8")
|
|
433
|
+
).decode("utf-8")
|
|
434
|
+
return HttpResponseRedirect(f"{frontend_url}?oauth_result={result_encoded}")
|
|
435
|
+
|
|
436
|
+
if "text/html" in accept_header:
|
|
437
|
+
error_message = (
|
|
438
|
+
result["messages"][0]["message"]
|
|
439
|
+
if result["messages"]
|
|
440
|
+
else "An error occurred during authorization."
|
|
441
|
+
)
|
|
442
|
+
return render(
|
|
443
|
+
request._request,
|
|
444
|
+
"providers/oauth_callback.html",
|
|
445
|
+
dict(
|
|
446
|
+
success=result["success"],
|
|
447
|
+
error_message=error_message,
|
|
448
|
+
result_json=json.dumps(result),
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
return response.Response(result, status=status.HTTP_200_OK)
|
|
453
|
+
|
|
454
|
+
|
|
165
455
|
urlpatterns = [
|
|
166
456
|
urls.path(
|
|
167
457
|
"connections",
|
|
@@ -173,4 +463,34 @@ urlpatterns = [
|
|
|
173
463
|
CarrierConnectionDetail.as_view(),
|
|
174
464
|
name="carrier-connection-details",
|
|
175
465
|
),
|
|
466
|
+
urls.path(
|
|
467
|
+
"connections/oauth/<str:carrier_name>/authorize",
|
|
468
|
+
ConnectionOauthAuthorize.as_view(),
|
|
469
|
+
name="connection-oauth-authorize",
|
|
470
|
+
),
|
|
471
|
+
urls.path(
|
|
472
|
+
"connections/oauth/<str:carrier_name>/callback",
|
|
473
|
+
ConnectionOauthCallback.as_view(),
|
|
474
|
+
name="connection-oauth-callback",
|
|
475
|
+
),
|
|
476
|
+
urls.path(
|
|
477
|
+
"connections/webhook/<str:pk>/events",
|
|
478
|
+
ConnectionWebhookEvent.as_view(),
|
|
479
|
+
name="connection-webhook-event",
|
|
480
|
+
),
|
|
481
|
+
urls.path(
|
|
482
|
+
"connections/webhook/<str:pk>/register",
|
|
483
|
+
ConnectionWebhookRegister.as_view(),
|
|
484
|
+
name="connection-webhook-register",
|
|
485
|
+
),
|
|
486
|
+
urls.path(
|
|
487
|
+
"connections/webhook/<str:pk>/deregister",
|
|
488
|
+
ConnectionWebhookDeregister.as_view(),
|
|
489
|
+
name="connection-webhook-deregister",
|
|
490
|
+
),
|
|
491
|
+
urls.path(
|
|
492
|
+
"connections/webhook/<str:pk>/disconnect",
|
|
493
|
+
ConnectionWebhookDisconnect.as_view(),
|
|
494
|
+
name="connection-webhook-disconnect",
|
|
495
|
+
),
|
|
176
496
|
]
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import yaml
|
|
2
2
|
import pydoc
|
|
3
3
|
import typing
|
|
4
|
-
import logging
|
|
5
4
|
from django.db import models
|
|
6
|
-
from django.conf import
|
|
5
|
+
from django.conf import settings
|
|
7
6
|
from django.db import transaction
|
|
8
|
-
from rest_framework import serializers
|
|
9
7
|
from django.forms.models import model_to_dict
|
|
10
8
|
from drf_spectacular.types import OpenApiTypes
|
|
9
|
+
from rest_framework import serializers, request
|
|
11
10
|
|
|
12
11
|
import karrio.lib as lib
|
|
12
|
+
from karrio.server.core.logging import logger
|
|
13
13
|
|
|
14
|
-
logger = logging.getLogger(__name__)
|
|
15
14
|
T = typing.TypeVar("T")
|
|
16
15
|
|
|
17
16
|
|
|
@@ -24,6 +23,9 @@ class Context(typing.NamedTuple):
|
|
|
24
23
|
return getattr(self, item)
|
|
25
24
|
|
|
26
25
|
|
|
26
|
+
RequestContext = typing.Union[Context, dict, request.Request]
|
|
27
|
+
|
|
28
|
+
|
|
27
29
|
class DecoratedSerializer:
|
|
28
30
|
def __init__(
|
|
29
31
|
self,
|
|
@@ -157,7 +159,9 @@ def PaginatedResult(serializer_name: str, content_serializer: typing.Type[Serial
|
|
|
157
159
|
)
|
|
158
160
|
|
|
159
161
|
|
|
160
|
-
def owned_model_serializer(
|
|
162
|
+
def owned_model_serializer(
|
|
163
|
+
serializer: typing.Type[typing.Union[Serializer, ModelSerializer]],
|
|
164
|
+
):
|
|
161
165
|
class MetaSerializer(serializer): # type: ignore
|
|
162
166
|
context: dict = {}
|
|
163
167
|
|
|
@@ -196,8 +200,15 @@ def owned_model_serializer(serializer: typing.Type[Serializer]):
|
|
|
196
200
|
instance = super().create(payload, context=self.__context)
|
|
197
201
|
link_org(instance, self.__context) # Link to organization if supported
|
|
198
202
|
except Exception as e:
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
# Log exception with full traceback for debugging
|
|
204
|
+
meta = getattr(self.__class__, "Meta", None)
|
|
205
|
+
model_name = getattr(
|
|
206
|
+
getattr(meta, "model", None), "__name__", "Unknown"
|
|
207
|
+
)
|
|
208
|
+
logger.exception(
|
|
209
|
+
f"Failed to create {model_name} instance using {self.__class__.__name__}: {str(e)}"
|
|
210
|
+
)
|
|
211
|
+
raise
|
|
201
212
|
|
|
202
213
|
return instance
|
|
203
214
|
|
|
@@ -210,18 +221,22 @@ def owned_model_serializer(serializer: typing.Type[Serializer]):
|
|
|
210
221
|
|
|
211
222
|
|
|
212
223
|
def link_org(entity: ModelSerializer, context: Context):
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
224
|
+
from django.utils.functional import SimpleLazyObject
|
|
225
|
+
|
|
226
|
+
# Evaluate org from context (handles SimpleLazyObject)
|
|
227
|
+
org = (
|
|
228
|
+
context.org if not isinstance(context.org, SimpleLazyObject)
|
|
229
|
+
else (context.org if context.org else None)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Check if entity can be linked to org
|
|
233
|
+
entity_org = getattr(entity, "org", None)
|
|
234
|
+
has_org_relation = entity_org is not None and hasattr(entity_org, "exists")
|
|
235
|
+
should_link = org is not None and has_org_relation and not entity_org.exists()
|
|
236
|
+
|
|
237
|
+
if should_link:
|
|
238
|
+
entity.link = entity.__class__.link.related.related_model.objects.create(org=org, item=entity)
|
|
239
|
+
entity.save(update_fields=(["created_at"] if hasattr(entity, "created_at") else []))
|
|
225
240
|
|
|
226
241
|
|
|
227
242
|
def bulk_link_org(entities: typing.List[models.Model], context: Context):
|
|
@@ -409,6 +424,80 @@ def is_field_optional(model, field_name: str) -> bool:
|
|
|
409
424
|
return False
|
|
410
425
|
|
|
411
426
|
|
|
427
|
+
def deep_merge_remove_nulls(base: dict, updates: dict) -> dict:
|
|
428
|
+
"""Deep merge two dictionaries, removing keys with null values from updates.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
base: The base dictionary (existing data)
|
|
432
|
+
updates: The updates dictionary (new data with potential nulls to remove)
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Merged dictionary with null values removed
|
|
436
|
+
|
|
437
|
+
Examples:
|
|
438
|
+
>>> base = {"a": 1, "b": {"c": 2, "d": 3}}
|
|
439
|
+
>>> updates = {"b": {"c": null, "e": 4}}
|
|
440
|
+
>>> deep_merge_remove_nulls(base, updates)
|
|
441
|
+
{"a": 1, "b": {"d": 3, "e": 4}} # c removed due to null
|
|
442
|
+
"""
|
|
443
|
+
result = base.copy()
|
|
444
|
+
|
|
445
|
+
for key, value in updates.items():
|
|
446
|
+
if value is None:
|
|
447
|
+
# Explicit null means remove the key
|
|
448
|
+
result.pop(key, None)
|
|
449
|
+
elif isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
450
|
+
# Both are dicts: recursively merge
|
|
451
|
+
result[key] = deep_merge_remove_nulls(result[key], value)
|
|
452
|
+
else:
|
|
453
|
+
# Overwrite with new value
|
|
454
|
+
result[key] = value
|
|
455
|
+
|
|
456
|
+
return result
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def process_nested_dictionaries_mutations(
|
|
460
|
+
keys: typing.List[str], payload: dict, entity
|
|
461
|
+
) -> dict:
|
|
462
|
+
"""Process nested dictionary mutations with deep merge and null removal.
|
|
463
|
+
|
|
464
|
+
This function is designed for complex nested JSON fields where you need:
|
|
465
|
+
- Deep merging of nested objects
|
|
466
|
+
- Removal of keys when explicit null is sent
|
|
467
|
+
- Preservation of unaffected nested keys
|
|
468
|
+
|
|
469
|
+
Use this for fields like shipping rule actions/conditions that have nested extensions.
|
|
470
|
+
For simple flat dictionaries, use process_dictionaries_mutations instead.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
keys: List of field names to process
|
|
474
|
+
payload: Input data from mutation
|
|
475
|
+
entity: Existing entity instance
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
Updated payload with deep merged values
|
|
479
|
+
|
|
480
|
+
Examples:
|
|
481
|
+
Existing: {"actions": {"select_service": {"carrier": "ups"}, "extensions": {"old": "data"}}}
|
|
482
|
+
Update: {"actions": {"extensions": {"new": "data"}}}
|
|
483
|
+
Result: {"actions": {"select_service": {"carrier": "ups"}, "extensions": {"old": "data", "new": "data"}}}
|
|
484
|
+
"""
|
|
485
|
+
data = payload.copy()
|
|
486
|
+
|
|
487
|
+
for key in [k for k in keys if k in payload]:
|
|
488
|
+
existing_value = getattr(entity, key, None) or {}
|
|
489
|
+
new_value = payload.get(key)
|
|
490
|
+
|
|
491
|
+
if new_value is None:
|
|
492
|
+
# Explicit null means clear the entire field
|
|
493
|
+
data[key] = {}
|
|
494
|
+
else:
|
|
495
|
+
# Deep merge with null removal
|
|
496
|
+
data[key] = deep_merge_remove_nulls(existing_value, new_value)
|
|
497
|
+
|
|
498
|
+
return data
|
|
499
|
+
|
|
500
|
+
|
|
412
501
|
def process_dictionaries_mutations(
|
|
413
502
|
keys: typing.List[str], payload: dict, entity
|
|
414
503
|
) -> dict:
|
|
@@ -417,8 +506,10 @@ def process_dictionaries_mutations(
|
|
|
417
506
|
"""
|
|
418
507
|
data = payload.copy()
|
|
419
508
|
|
|
420
|
-
for key in [k for k in keys if k in payload]:
|
|
421
|
-
value = lib.to_dict(
|
|
509
|
+
for key in [k for k in keys if k in payload and payload.get(k) is not None]:
|
|
510
|
+
value = lib.to_dict(
|
|
511
|
+
{**(getattr(entity, key, None) or {}), **(payload.get(key, None) or {})}
|
|
512
|
+
)
|
|
422
513
|
data.update({key: value})
|
|
423
514
|
|
|
424
515
|
return data
|
karrio/server/tracing/utils.py
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
|
|
3
1
|
import karrio.lib as lib
|
|
4
2
|
import karrio.server.conf as conf
|
|
5
3
|
import karrio.server.core.utils as utils
|
|
6
4
|
import karrio.server.tracing.models as models
|
|
7
5
|
import karrio.server.serializers as serializers
|
|
8
|
-
|
|
9
|
-
logger = logging.getLogger(__name__)
|
|
6
|
+
from karrio.server.core.logging import logger
|
|
10
7
|
|
|
11
8
|
|
|
12
9
|
@utils.error_wrapper
|
|
@@ -66,9 +63,9 @@ def save_tracing_records(context, tracer: lib.Tracer = None, schema: str = None)
|
|
|
66
63
|
if getattr(context, "org", None) is not None:
|
|
67
64
|
serializers.bulk_link_org(saved_records, context)
|
|
68
65
|
|
|
69
|
-
logger.info("
|
|
66
|
+
logger.info("Tracing records saved successfully", record_count=len(saved_records))
|
|
70
67
|
except Exception as e:
|
|
71
|
-
logger.error(
|
|
68
|
+
logger.error("Failed to save tracing records", error=str(e))
|
|
72
69
|
|
|
73
70
|
persist_records(schema=schema)
|
|
74
71
|
|
|
@@ -84,7 +81,7 @@ def bulk_save_tracing_records(tracer: lib.Tracer, context=None):
|
|
|
84
81
|
records = []
|
|
85
82
|
|
|
86
83
|
for record in tracer.records:
|
|
87
|
-
logger.debug(
|
|
84
|
+
logger.debug("Processing tracing record", record_key=record.key, metadata=record.metadata)
|
|
88
85
|
records.append(
|
|
89
86
|
models.TracingRecord(
|
|
90
87
|
key=record.key,
|
|
@@ -101,7 +98,7 @@ def bulk_save_tracing_records(tracer: lib.Tracer, context=None):
|
|
|
101
98
|
if getattr(context, "org", None) is not None:
|
|
102
99
|
serializers.bulk_link_org(saved_records, context)
|
|
103
100
|
|
|
104
|
-
logger.info("
|
|
101
|
+
logger.info("Tracing records saved successfully", record_count=len(saved_records))
|
|
105
102
|
|
|
106
103
|
|
|
107
104
|
def set_tracing_context(**kwargs):
|
karrio/server/user/models.py
CHANGED
|
@@ -74,45 +74,47 @@ class User(auth.AbstractUser):
|
|
|
74
74
|
import karrio.server.conf as conf
|
|
75
75
|
import karrio.server.iam.models as iam
|
|
76
76
|
import karrio.server.core.middleware as middleware
|
|
77
|
+
from django.utils.functional import SimpleLazyObject
|
|
77
78
|
|
|
78
79
|
ctx = middleware.SessionContext.get_current_request()
|
|
79
|
-
_permissions = []
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
org_user.first()
|
|
94
|
-
),
|
|
95
|
-
)
|
|
96
|
-
_permissions = list(
|
|
97
|
-
context_permission.groups.all().values_list("name", flat=True)
|
|
98
|
-
)
|
|
99
|
-
except iam.ContextPermission.DoesNotExist:
|
|
100
|
-
pass
|
|
101
|
-
|
|
102
|
-
if not any(_permissions):
|
|
103
|
-
_permissions = list(self.groups.all().values_list("name", flat=True))
|
|
104
|
-
|
|
105
|
-
if not any(_permissions) and self.is_superuser:
|
|
106
|
-
return list(Group.objects.all().values_list("name", flat=True))
|
|
107
|
-
|
|
108
|
-
if not any(_permissions) and self.is_staff:
|
|
109
|
-
return list(
|
|
110
|
-
Group.objects.exclude(
|
|
111
|
-
name__in=["manage_system", "manage_team", "manage_org_owner"]
|
|
112
|
-
).values_list("name", flat=True)
|
|
81
|
+
# Helper to evaluate org safely
|
|
82
|
+
def get_org():
|
|
83
|
+
return (
|
|
84
|
+
None if not all([
|
|
85
|
+
conf.settings.MULTI_ORGANIZATIONS,
|
|
86
|
+
ctx is not None,
|
|
87
|
+
hasattr(ctx, "org"),
|
|
88
|
+
ctx.org is not None,
|
|
89
|
+
]) else (
|
|
90
|
+
ctx.org if not isinstance(ctx.org, SimpleLazyObject)
|
|
91
|
+
else (ctx.org if ctx.org else None)
|
|
92
|
+
)
|
|
113
93
|
)
|
|
114
94
|
|
|
115
|
-
|
|
95
|
+
# Helper to get context permissions
|
|
96
|
+
def get_context_perms():
|
|
97
|
+
org = get_org()
|
|
98
|
+
org_user = org.organization_users.filter(user_id=self.pk).first() if org else None
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
context_permission = iam.ContextPermission.objects.get(
|
|
102
|
+
object_pk=org_user.pk,
|
|
103
|
+
content_type=ContentType.objects.get_for_model(org_user),
|
|
104
|
+
) if org_user else None
|
|
105
|
+
|
|
106
|
+
return list(context_permission.groups.all().values_list("name", flat=True)) if context_permission else []
|
|
107
|
+
except iam.ContextPermission.DoesNotExist:
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
# Functional chain of permission resolution
|
|
111
|
+
_permissions = get_context_perms() or list(self.groups.all().values_list("name", flat=True))
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
list(Group.objects.all().values_list("name", flat=True)) if self.is_superuser and not _permissions
|
|
115
|
+
else list(Group.objects.exclude(name__in=["manage_system", "manage_team", "manage_org_owner"]).values_list("name", flat=True)) if self.is_staff and not _permissions
|
|
116
|
+
else _permissions
|
|
117
|
+
)
|
|
116
118
|
|
|
117
119
|
|
|
118
120
|
@core.register_model
|