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,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 empty, settings
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(serializer: typing.Type[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
- logger.exception(e)
200
- raise e
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
- if (
214
- context.org is not None
215
- and hasattr(entity, "org")
216
- and hasattr(entity.org, "exists")
217
- and not entity.org.exists()
218
- ):
219
- entity.link = entity.__class__.link.related.related_model.objects.create(
220
- org=context.org, item=entity
221
- )
222
- entity.save(
223
- update_fields=(["created_at"] if hasattr(entity, "created_at") else [])
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({**getattr(entity, key, {}), **payload.get(key, {})})
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
@@ -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("successfully saved tracing records...")
66
+ logger.info("Tracing records saved successfully", record_count=len(saved_records))
70
67
  except Exception as e:
71
- logger.error(e, exc_info=False)
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([f"record: {record.key}", record.metadata])
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("> tracing records saved...")
101
+ logger.info("Tracing records saved successfully", record_count=len(saved_records))
105
102
 
106
103
 
107
104
  def set_tracing_context(**kwargs):
@@ -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
- if (
82
- conf.settings.MULTI_ORGANIZATIONS
83
- and ctx is not None
84
- and hasattr(ctx, "org")
85
- and ctx.org is not None
86
- ):
87
- org_user = ctx.org.organization_users.filter(user_id=self.pk)
88
- if org_user.exists():
89
- try:
90
- context_permission = iam.ContextPermission.objects.get(
91
- object_pk=org_user.first().pk,
92
- content_type=ContentType.objects.get_for_model(
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
- return _permissions
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
@@ -32,6 +32,7 @@ class TokenSerializer(serializers.Serializer):
32
32
  if queyset.exists():
33
33
  return queyset.first()
34
34
 
35
+ # Handle multi-organization mode
35
36
  if org_id is not None and conf.settings.MULTI_ORGANIZATIONS:
36
37
  import karrio.server.orgs.models as orgs
37
38