karrio-server-core 2025.5__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 (213) hide show
  1. karrio/server/conf.py +54 -0
  2. karrio/server/core/__init__.py +3 -0
  3. karrio/server/core/admin.py +1 -0
  4. karrio/server/core/apps.py +10 -0
  5. karrio/server/core/authentication.py +347 -0
  6. karrio/server/core/config.py +31 -0
  7. karrio/server/core/context_processors.py +12 -0
  8. karrio/server/core/datatypes.py +394 -0
  9. karrio/server/core/dataunits.py +187 -0
  10. karrio/server/core/exceptions.py +404 -0
  11. karrio/server/core/fields.py +12 -0
  12. karrio/server/core/filters.py +837 -0
  13. karrio/server/core/gateway.py +1011 -0
  14. karrio/server/core/logging.py +403 -0
  15. karrio/server/core/management/commands/cli.py +19 -0
  16. karrio/server/core/management/commands/create_oauth_client.py +41 -0
  17. karrio/server/core/management/commands/runserver.py +5 -0
  18. karrio/server/core/middleware.py +197 -0
  19. karrio/server/core/migrations/0001_initial.py +28 -0
  20. karrio/server/core/migrations/0002_apilogindex.py +69 -0
  21. karrio/server/core/migrations/0003_apilogindex_test_mode.py +62 -0
  22. karrio/server/core/migrations/0004_metafield.py +74 -0
  23. karrio/server/core/migrations/0005_alter_metafield_type_alter_metafield_value.py +23 -0
  24. karrio/server/core/migrations/0006_add_api_log_requested_at_index.py +22 -0
  25. karrio/server/core/migrations/__init__.py +0 -0
  26. karrio/server/core/models/__init__.py +48 -0
  27. karrio/server/core/models/base.py +103 -0
  28. karrio/server/core/models/entity.py +24 -0
  29. karrio/server/core/models/metafield.py +144 -0
  30. karrio/server/core/models/third_party.py +21 -0
  31. karrio/server/core/oauth_validators.py +170 -0
  32. karrio/server/core/permissions.py +36 -0
  33. karrio/server/core/renderers.py +11 -0
  34. karrio/server/core/router.py +3 -0
  35. karrio/server/core/serializers.py +1971 -0
  36. karrio/server/core/signals.py +55 -0
  37. karrio/server/core/telemetry.py +573 -0
  38. karrio/server/core/tests.py +99 -0
  39. karrio/server/core/tests_resource_token.py +411 -0
  40. karrio/server/core/urls.py +12 -0
  41. karrio/server/core/utils.py +1025 -0
  42. karrio/server/core/validators.py +264 -0
  43. karrio/server/core/views/__init__.py +2 -0
  44. karrio/server/core/views/api.py +133 -0
  45. karrio/server/core/views/metadata.py +44 -0
  46. karrio/server/core/views/oauth.py +75 -0
  47. karrio/server/core/views/references.py +82 -0
  48. karrio/server/core/views/schema.py +310 -0
  49. karrio/server/filters/__init__.py +2 -0
  50. karrio/server/filters/abstract.py +26 -0
  51. karrio/server/iam/__init__.py +0 -0
  52. karrio/server/iam/admin.py +3 -0
  53. karrio/server/iam/apps.py +21 -0
  54. karrio/server/iam/migrations/0001_initial.py +33 -0
  55. karrio/server/iam/migrations/__init__.py +0 -0
  56. karrio/server/iam/models.py +48 -0
  57. karrio/server/iam/permissions.py +155 -0
  58. karrio/server/iam/serializers.py +54 -0
  59. karrio/server/iam/signals.py +18 -0
  60. karrio/server/iam/tests.py +3 -0
  61. karrio/server/iam/views.py +3 -0
  62. karrio/server/openapi.py +75 -0
  63. karrio/server/providers/__init__.py +1 -0
  64. karrio/server/providers/admin.py +364 -0
  65. karrio/server/providers/apps.py +10 -0
  66. karrio/server/providers/management/commands/migrate_rate_sheets.py +101 -0
  67. karrio/server/providers/migrations/0001_initial.py +140 -0
  68. karrio/server/providers/migrations/0002_carrier_active.py +18 -0
  69. karrio/server/providers/migrations/0003_auto_20201230_0820.py +24 -0
  70. karrio/server/providers/migrations/0004_auto_20210212_0554.py +178 -0
  71. karrio/server/providers/migrations/0005_auto_20210212_0555.py +18 -0
  72. karrio/server/providers/migrations/0006_australiapostsettings.py +29 -0
  73. karrio/server/providers/migrations/0007_auto_20210213_0206.py +21 -0
  74. karrio/server/providers/migrations/0008_auto_20210214_0409.py +30 -0
  75. karrio/server/providers/migrations/0009_auto_20210308_0302.py +18 -0
  76. karrio/server/providers/migrations/0010_auto_20210409_0852.py +32 -0
  77. karrio/server/providers/migrations/0011_auto_20210409_0853.py +21 -0
  78. karrio/server/providers/migrations/0012_alter_carrier_options.py +17 -0
  79. karrio/server/providers/migrations/0013_tntsettings.py +30 -0
  80. karrio/server/providers/migrations/0014_auto_20210612_1608.py +46 -0
  81. karrio/server/providers/migrations/0015_auto_20210615_1601.py +28 -0
  82. karrio/server/providers/migrations/0016_alter_purolatorsettings_user_token.py +18 -0
  83. karrio/server/providers/migrations/0017_auto_20210805_0359.py +1293 -0
  84. karrio/server/providers/migrations/0018_alter_fedexsettings_user_key.py +18 -0
  85. karrio/server/providers/migrations/0019_dhlpolandsettings_servicelevel.py +65 -0
  86. karrio/server/providers/migrations/0020_genericsettings_labeltemplate.py +52 -0
  87. karrio/server/providers/migrations/0021_auto_20211231_2353.py +40 -0
  88. karrio/server/providers/migrations/0022_carrier_metadata.py +18 -0
  89. karrio/server/providers/migrations/0023_auto_20220124_1916.py +27 -0
  90. karrio/server/providers/migrations/0024_alter_genericsettings_custom_carrier_name.py +19 -0
  91. karrio/server/providers/migrations/0025_alter_servicelevel_service_code.py +19 -0
  92. karrio/server/providers/migrations/0026_auto_20220208_0132.py +59 -0
  93. karrio/server/providers/migrations/0027_auto_20220304_1340.py +29 -0
  94. karrio/server/providers/migrations/0028_auto_20220323_1500.py +33 -0
  95. karrio/server/providers/migrations/0029_easypostsettings.py +27 -0
  96. karrio/server/providers/migrations/0030_amazonmwssettings.py +29 -0
  97. karrio/server/providers/migrations/0031_delete_amazonmwssettings.py +18 -0
  98. karrio/server/providers/migrations/0032_alter_carrier_test.py +18 -0
  99. karrio/server/providers/migrations/0033_auto_20220708_1350.py +22 -0
  100. karrio/server/providers/migrations/0034_amazonmwssettings_dpdhlsettings.py +47 -0
  101. karrio/server/providers/migrations/0035_alter_carrier_capabilities.py +43 -0
  102. karrio/server/providers/migrations/0036_upsfreightsettings.py +31 -0
  103. karrio/server/providers/migrations/0037_chronopostsettings.py +29 -0
  104. karrio/server/providers/migrations/0038_alter_genericsettings_label_template.py +19 -0
  105. karrio/server/providers/migrations/0039_auto_20220906_0612.py +23 -0
  106. karrio/server/providers/migrations/0040_dpdhlsettings_services.py +18 -0
  107. karrio/server/providers/migrations/0041_auto_20221105_0705.py +38 -0
  108. karrio/server/providers/migrations/0042_auto_20221215_1642.py +23 -0
  109. karrio/server/providers/migrations/0043_alter_genericsettings_account_number_and_more.py +39 -0
  110. karrio/server/providers/migrations/0044_carrier_carrier_capabilities.py +64 -0
  111. karrio/server/providers/migrations/0045_alter_carrier_active_alter_carrier_carrier_id.py +31 -0
  112. karrio/server/providers/migrations/0046_remove_dpdhlsettings_signature_and_more.py +41 -0
  113. karrio/server/providers/migrations/0047_dpdsettings.py +286 -0
  114. karrio/server/providers/migrations/0048_servicelevel_min_weight_servicelevel_transit_days_and_more.py +64 -0
  115. karrio/server/providers/migrations/0049_boxknightsettings_geodissettings_lapostesettings_and_more.py +156 -0
  116. karrio/server/providers/migrations/0050_carrier_is_system_alter_carrier_metadata_and_more.py +106 -0
  117. karrio/server/providers/migrations/0051_rename_username_upssettings_client_id_and_more.py +31 -0
  118. karrio/server/providers/migrations/0052_alter_upssettings_account_number_and_more.py +20 -0
  119. karrio/server/providers/migrations/0053_locate2usettings.py +281 -0
  120. karrio/server/providers/migrations/0054_zoom2usettings.py +280 -0
  121. karrio/server/providers/migrations/0055_rename_amazonmwssettings_amazonshippingsettings_and_more.py +44 -0
  122. karrio/server/providers/migrations/0056_asendiaussettings_geodissettings_code_client_and_more.py +75 -0
  123. karrio/server/providers/migrations/0057_alter_servicelevel_weight_unit_belgianpostsettings.py +51 -0
  124. karrio/server/providers/migrations/0058_alliedexpresssettings.py +38 -0
  125. karrio/server/providers/migrations/0059_ratesheet.py +81 -0
  126. karrio/server/providers/migrations/0060_belgianpostsettings_rate_sheet_and_more.py +73 -0
  127. karrio/server/providers/migrations/0061_alliedexpresssettings_service_type.py +17 -0
  128. karrio/server/providers/migrations/0062_sendlesettings_account_country_code.py +257 -0
  129. karrio/server/providers/migrations/0063_servicelevel_metadata.py +25 -0
  130. karrio/server/providers/migrations/0064_alliedexpresslocalsettings.py +43 -0
  131. karrio/server/providers/migrations/0065_servicelevel_carrier_service_code_and_more.py +66 -0
  132. karrio/server/providers/migrations/0066_rename_fedexsettings_fedexwssettings_and_more.py +28 -0
  133. karrio/server/providers/migrations/0067_fedexsettings.py +283 -0
  134. karrio/server/providers/migrations/0068_fedexsettings_track_api_key_and_more.py +38 -0
  135. karrio/server/providers/migrations/0069_alter_canadapostsettings_contract_id_and_more.py +23 -0
  136. karrio/server/providers/migrations/0070_tgesettings_alter_carrier_capabilities.py +65 -0
  137. karrio/server/providers/migrations/0071_alter_tgesettings_my_toll_token.py +18 -0
  138. karrio/server/providers/migrations/0072_rename_eshippersettings_eshipperxmlsettings_and_more.py +28 -0
  139. karrio/server/providers/migrations/0073_delete_eshipperxmlsettings.py +41 -0
  140. karrio/server/providers/migrations/0074_eshippersettings.py +38 -0
  141. karrio/server/providers/migrations/0075_haypostsettings.py +40 -0
  142. karrio/server/providers/migrations/0076_rename_customer_registration_id_uspsinternationalsettings_account_number_and_more.py +125 -0
  143. karrio/server/providers/migrations/0077_uspswtinternationalsettings_uspswtsettings_and_more.py +165 -0
  144. karrio/server/providers/migrations/0078_auto_20240813_1552.py +120 -0
  145. karrio/server/providers/migrations/0079_alter_carrier_options_alter_ratesheet_created_by.py +31 -0
  146. karrio/server/providers/migrations/0080_alter_aramexsettings_account_country_code_and_more.py +3025 -0
  147. karrio/server/providers/migrations/0081_remove_alliedexpresssettings_carrier_ptr_and_more.py +338 -0
  148. karrio/server/providers/migrations/0082_add_zone_identifiers.py +50 -0
  149. karrio/server/providers/migrations/0083_add_optimized_rate_sheet_structure.py +33 -0
  150. karrio/server/providers/migrations/0084_alter_servicelevel_currency.py +168 -0
  151. karrio/server/providers/migrations/0085_populate_dhl_parcel_de_oauth_credentials.py +82 -0
  152. karrio/server/providers/migrations/0086_rename_dhl_parcel_de_customer_number_to_billing_number.py +71 -0
  153. karrio/server/providers/migrations/__init__.py +0 -0
  154. karrio/server/providers/models/__init__.py +16 -0
  155. karrio/server/providers/models/carrier.py +387 -0
  156. karrio/server/providers/models/config.py +30 -0
  157. karrio/server/providers/models/service.py +192 -0
  158. karrio/server/providers/models/sheet.py +287 -0
  159. karrio/server/providers/models/template.py +39 -0
  160. karrio/server/providers/models/utils.py +58 -0
  161. karrio/server/providers/router.py +3 -0
  162. karrio/server/providers/serializers/__init__.py +3 -0
  163. karrio/server/providers/serializers/base.py +538 -0
  164. karrio/server/providers/signals.py +25 -0
  165. karrio/server/providers/templates/providers/oauth_callback.html +105 -0
  166. karrio/server/providers/tests/__init__.py +5 -0
  167. karrio/server/providers/tests/test_connections.py +895 -0
  168. karrio/server/providers/urls.py +11 -0
  169. karrio/server/providers/views/__init__.py +0 -0
  170. karrio/server/providers/views/carriers.py +267 -0
  171. karrio/server/providers/views/connections.py +496 -0
  172. karrio/server/samples.py +352 -0
  173. karrio/server/serializers/__init__.py +2 -0
  174. karrio/server/serializers/abstract.py +602 -0
  175. karrio/server/tracing/__init__.py +0 -0
  176. karrio/server/tracing/admin.py +63 -0
  177. karrio/server/tracing/apps.py +8 -0
  178. karrio/server/tracing/migrations/0001_initial.py +41 -0
  179. karrio/server/tracing/migrations/0002_auto_20220710_1307.py +22 -0
  180. karrio/server/tracing/migrations/0003_auto_20221105_0317.py +43 -0
  181. karrio/server/tracing/migrations/0004_tracingrecord_carrier_account_idx.py +24 -0
  182. karrio/server/tracing/migrations/0005_optimise_tracingrecord_request_log_idx.py +25 -0
  183. karrio/server/tracing/migrations/0006_alter_tracingrecord_options_and_more.py +49 -0
  184. karrio/server/tracing/migrations/0007_tracingrecord_tracing_created_at_idx.py +19 -0
  185. karrio/server/tracing/migrations/__init__.py +0 -0
  186. karrio/server/tracing/models.py +82 -0
  187. karrio/server/tracing/tests.py +3 -0
  188. karrio/server/tracing/utils.py +109 -0
  189. karrio/server/user/__init__.py +0 -0
  190. karrio/server/user/admin.py +96 -0
  191. karrio/server/user/apps.py +7 -0
  192. karrio/server/user/forms.py +35 -0
  193. karrio/server/user/migrations/0001_initial.py +41 -0
  194. karrio/server/user/migrations/0002_token.py +29 -0
  195. karrio/server/user/migrations/0003_token_test_mode.py +20 -0
  196. karrio/server/user/migrations/0004_group.py +26 -0
  197. karrio/server/user/migrations/0005_token_label.py +21 -0
  198. karrio/server/user/migrations/0006_workspaceconfig.py +63 -0
  199. karrio/server/user/migrations/0007_user_metadata.py +25 -0
  200. karrio/server/user/migrations/__init__.py +0 -0
  201. karrio/server/user/models.py +218 -0
  202. karrio/server/user/serializers.py +47 -0
  203. karrio/server/user/templates/registration/login.html +108 -0
  204. karrio/server/user/templates/registration/registration_confirm_email.html +10 -0
  205. karrio/server/user/templates/registration/registration_confirm_email.txt +3 -0
  206. karrio/server/user/tests.py +3 -0
  207. karrio/server/user/urls.py +10 -0
  208. karrio/server/user/utils.py +60 -0
  209. karrio/server/user/views.py +9 -0
  210. karrio_server_core-2025.5.dist-info/METADATA +32 -0
  211. karrio_server_core-2025.5.dist-info/RECORD +213 -0
  212. karrio_server_core-2025.5.dist-info/WHEEL +5 -0
  213. karrio_server_core-2025.5.dist-info/top_level.txt +2 -0
@@ -0,0 +1,404 @@
1
+ import re
2
+ import typing
3
+ from rest_framework.response import Response
4
+ from rest_framework import status, exceptions
5
+ from rest_framework.views import exception_handler
6
+ from django.core.exceptions import ObjectDoesNotExist
7
+ from django.utils.translation import gettext_lazy as _
8
+ from karrio.server.core.logging import logger
9
+
10
+ import karrio.lib as lib
11
+ import karrio.core.errors as sdk
12
+ from karrio.server.core.datatypes import Error, Message
13
+
14
+
15
+ class ValidationError(exceptions.ValidationError, sdk.ValidationError):
16
+ pass
17
+
18
+
19
+ class APIException(exceptions.APIException):
20
+ default_status_code = status.HTTP_400_BAD_REQUEST
21
+ default_detail = _("Invalid input.")
22
+ default_code = "failure"
23
+
24
+ def __init__(self, detail=None, code=None, status_code=None):
25
+ if detail is None:
26
+ detail = self.default_detail
27
+ if code is None:
28
+ code = self.default_code
29
+ if status_code is None:
30
+ status_code = self.default_status_code
31
+
32
+ self.status_code = status_code
33
+ self.code = code
34
+ self.detail = detail
35
+
36
+
37
+ class IndexedAPIException(APIException):
38
+ def __init__(self, index=None, **kwargs):
39
+ super().__init__(**kwargs)
40
+ self.index = index
41
+
42
+
43
+ class APIExceptions(APIException):
44
+ pass
45
+
46
+
47
+ def custom_exception_handler(exc, context):
48
+ from django.conf import settings
49
+
50
+ # Extract request details and log exception
51
+ request_details = _get_request_details(context)
52
+ _log_exception(exc, request_details, debug=getattr(settings, "DEBUG", False))
53
+
54
+ # Capture exception to telemetry (Sentry/OTEL/Datadog)
55
+ # This ensures handled exceptions are still tracked in APM
56
+ _capture_exception_to_telemetry(exc, request_details, context)
57
+
58
+ response = exception_handler(exc, context)
59
+ detail = getattr(exc, "detail", None)
60
+ messages = message_handler(exc)
61
+ status_code = getattr(exc, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR)
62
+ code = get_code(exc)
63
+
64
+ if isinstance(exc, exceptions.ValidationError) or isinstance(
65
+ exc, sdk.ValidationError
66
+ ):
67
+ formatted_errors = _format_validation_errors(detail) if detail else None
68
+ return Response(
69
+ messages
70
+ or dict(
71
+ errors=lib.to_dict(
72
+ formatted_errors
73
+ or [
74
+ Error(
75
+ code=code or "validation",
76
+ message=detail if isinstance(detail, str) else None,
77
+ details=(detail if not isinstance(detail, str) else None),
78
+ )
79
+ ]
80
+ )
81
+ ),
82
+ status=status.HTTP_400_BAD_REQUEST,
83
+ headers=getattr(response, "headers", None),
84
+ )
85
+
86
+ if isinstance(exc, ObjectDoesNotExist):
87
+ resource_name = _get_resource_name(exc)
88
+ message = f"{resource_name} not found" if resource_name else (
89
+ detail if isinstance(detail, str) else "Resource not found"
90
+ )
91
+ return Response(
92
+ dict(
93
+ errors=lib.to_dict(
94
+ [
95
+ Error(
96
+ code=code or "not_found",
97
+ message=message,
98
+ details=(detail if not isinstance(detail, str) else None),
99
+ )
100
+ ]
101
+ )
102
+ ),
103
+ status=status.HTTP_404_NOT_FOUND,
104
+ headers=getattr(response, "headers", None),
105
+ )
106
+
107
+ if isinstance(exc, APIExceptions):
108
+ errors = error_handler(exc)
109
+ if errors is not None:
110
+ return Response(
111
+ lib.to_dict(errors),
112
+ status=status_code,
113
+ headers=getattr(response, "headers", None),
114
+ )
115
+
116
+ if isinstance(exc, APIException) or isinstance(exc, exceptions.APIException):
117
+ return Response(
118
+ messages
119
+ or dict(
120
+ errors=lib.to_dict(
121
+ [
122
+ Error(
123
+ code=code,
124
+ message=detail if isinstance(detail, str) else None,
125
+ details=(detail if not isinstance(detail, str) else None),
126
+ )
127
+ ]
128
+ )
129
+ ),
130
+ status=status_code,
131
+ headers=getattr(response, "headers", None),
132
+ )
133
+
134
+ elif isinstance(exc, Exception):
135
+ message, *_ = list(exc.args)
136
+ return Response(
137
+ dict(errors=lib.to_dict([Error(code=code, message=message)])),
138
+ status=status_code,
139
+ headers=getattr(response, "headers", None),
140
+ )
141
+
142
+ return response
143
+
144
+
145
+ def message_handler(exc) -> typing.Optional[dict]:
146
+ if (
147
+ hasattr(exc, "detail")
148
+ and isinstance(exc.detail, list)
149
+ and len(exc.detail) > 0
150
+ and isinstance(exc.detail[0], Message)
151
+ ):
152
+ return dict(
153
+ messages=lib.to_dict(
154
+ [
155
+ dict(
156
+ code=msg.code,
157
+ message=msg.message,
158
+ details=msg.details,
159
+ carrier_id=msg.carrier_id,
160
+ carrier_name=msg.carrier_name,
161
+ )
162
+ for msg in typing.cast(typing.List[Message], exc.detail)
163
+ ]
164
+ )
165
+ )
166
+
167
+ return None
168
+
169
+
170
+ def error_handler(exc) -> typing.Optional[dict]:
171
+ if (
172
+ hasattr(exc, "detail")
173
+ and isinstance(exc.detail, list)
174
+ and len(exc.detail) > 0
175
+ and isinstance(exc.detail[0], Exception)
176
+ ):
177
+ errors: typing.List[dict] = []
178
+
179
+ for error in exc.detail:
180
+ message, *_ = list(exc.args)
181
+ detail = getattr(error, "detail", None)
182
+ index = getattr(error, "index", None)
183
+ code = get_code(error) or "error"
184
+ errors.append(
185
+ dict(
186
+ index=index,
187
+ code=code,
188
+ message=(
189
+ (detail if isinstance(detail, str) else None)
190
+ if detail
191
+ else message
192
+ ),
193
+ details=(detail if not isinstance(detail, str) else None),
194
+ )
195
+ )
196
+
197
+ return dict(errors=errors)
198
+
199
+ return None
200
+
201
+
202
+ def get_code(exc):
203
+ from karrio.server.core.utils import failsafe
204
+
205
+ if hasattr(exc, "get_codes"):
206
+ return (
207
+ failsafe(lambda: exc.get_codes())
208
+ or getattr(exc, "code", None)
209
+ or getattr(exc, "default_code", None)
210
+ )
211
+
212
+ return getattr(exc, "default_code", None)
213
+
214
+
215
+ def _get_request_details(context: dict) -> dict:
216
+ """Extract request details from context for logging."""
217
+ request = context.get("view", None) and context.get("view").request
218
+
219
+ if not request:
220
+ return {}
221
+
222
+ return {
223
+ "method": getattr(request, "method", None),
224
+ "path": getattr(request, "path", None),
225
+ "user": str(getattr(request, "user", None)),
226
+ "user_id": getattr(getattr(request, "user", None), "id", None),
227
+ "query_params": dict(getattr(request, "GET", {})),
228
+ "content_type": getattr(request, "content_type", None),
229
+ }
230
+
231
+
232
+ def _capture_exception_to_telemetry(exc: Exception, request_details: dict, context: dict):
233
+ """Capture exception to APM telemetry (Sentry/OTEL/Datadog).
234
+
235
+ This ensures that handled exceptions (which return proper HTTP responses)
236
+ are still tracked in external APM tools for visibility and alerting.
237
+ """
238
+ from karrio.server.core.utils import failsafe
239
+
240
+ def _capture():
241
+ from karrio.server.core.telemetry import get_telemetry_for_request
242
+
243
+ telemetry = get_telemetry_for_request()
244
+ status_code = getattr(exc, "status_code", 500)
245
+
246
+ # Build context for the exception
247
+ exc_context = {
248
+ "exception_type": type(exc).__name__,
249
+ "status_code": status_code,
250
+ **{k: str(v) if isinstance(v, (dict, list)) else v for k, v in request_details.items()},
251
+ }
252
+
253
+ # Add carrier info if available in exception detail
254
+ detail = getattr(exc, "detail", None)
255
+ if isinstance(detail, list) and len(detail) > 0:
256
+ first = detail[0]
257
+ if hasattr(first, "carrier_name"):
258
+ exc_context["carrier_name"] = first.carrier_name
259
+ if hasattr(first, "carrier_id"):
260
+ exc_context["carrier_id"] = first.carrier_id
261
+
262
+ # Build tags
263
+ tags = {
264
+ "error_type": type(exc).__name__,
265
+ "status_code": str(status_code),
266
+ "error_class": "client" if status_code < 500 else "server",
267
+ }
268
+
269
+ # Capture to telemetry
270
+ telemetry.capture_exception(exc, context=exc_context, tags=tags)
271
+
272
+ # Record error metric
273
+ telemetry.record_metric(
274
+ "karrio.api.exception",
275
+ 1,
276
+ tags={
277
+ "exception_type": type(exc).__name__,
278
+ "status_code": str(status_code),
279
+ "path": request_details.get("path", "unknown"),
280
+ },
281
+ metric_type="counter",
282
+ )
283
+
284
+ failsafe(_capture)
285
+
286
+
287
+ def _log_exception(exc: Exception, request_details: dict, debug: bool = False):
288
+ """Log exception with appropriate detail level based on environment."""
289
+ exc_type = type(exc).__name__
290
+ exc_message = str(exc)
291
+
292
+ # Build context dict - convert dicts to strings to avoid format string issues
293
+ context = {
294
+ "exception_type": exc_type,
295
+ "exception_message": exc_message,
296
+ }
297
+
298
+ # Add request details, flattening nested structures
299
+ for key, value in request_details.items():
300
+ if isinstance(value, (dict, list)):
301
+ # Convert to string to avoid KeyError when loguru formats the message
302
+ context[key] = str(value)
303
+ else:
304
+ context[key] = value
305
+
306
+ if debug:
307
+ # In development, log with full traceback for better debugging
308
+ # Use positional args to avoid format string issues with curly braces in exception messages
309
+ logger.opt(exception=exc).error(
310
+ "Exception in request: {} - {}",
311
+ exc_type,
312
+ exc_message,
313
+ **context,
314
+ )
315
+ else:
316
+ # In production, log without full traceback but with context
317
+ logger.error(
318
+ "Exception in request: {}",
319
+ exc_type,
320
+ **context,
321
+ )
322
+
323
+
324
+ def _get_resource_name(exc: ObjectDoesNotExist) -> typing.Optional[str]:
325
+ """Extract resource name from ObjectDoesNotExist exception."""
326
+ exc_class_name = type(exc).__name__
327
+
328
+ # Handle Model.DoesNotExist pattern (e.g., Address.DoesNotExist -> Address)
329
+ if exc_class_name == "DoesNotExist" and hasattr(exc, "args") and exc.args:
330
+ match = re.search(r"(\w+) matching query", str(exc.args[0]))
331
+ if match:
332
+ return match.group(1)
333
+
334
+ # Handle ObjectDoesNotExist with model info in class hierarchy
335
+ for cls in type(exc).__mro__:
336
+ if cls.__name__ not in ("DoesNotExist", "ObjectDoesNotExist", "Exception", "BaseException", "object"):
337
+ return cls.__name__
338
+
339
+ return None
340
+
341
+
342
+ def _format_validation_errors(
343
+ detail: typing.Any,
344
+ prefix: str = "",
345
+ ) -> typing.Optional[typing.List[Error]]:
346
+ """Format validation errors with items[index].field pattern for list errors."""
347
+ if detail is None:
348
+ return None
349
+
350
+ if isinstance(detail, str):
351
+ return [Error(code="validation", message=detail)]
352
+
353
+ def _build_path(base: str, key: str) -> str:
354
+ return f"{base}.{key}" if base else key
355
+
356
+ def _build_index_path(base: str, index: int, field: str = None) -> str:
357
+ index_part = f"{base}[{index}]" if base else f"items[{index}]"
358
+ return f"{index_part}.{field}" if field else index_part
359
+
360
+ def _flatten_errors(data: typing.Any, path: str = "") -> typing.List[Error]:
361
+ if data is None:
362
+ return []
363
+
364
+ if isinstance(data, str):
365
+ message = f"{path}: {data}" if path else data
366
+ return [Error(code="validation", message=message)]
367
+
368
+ if isinstance(data, dict):
369
+ return [
370
+ err
371
+ for key, value in data.items()
372
+ for err in _flatten_errors(value, _build_path(path, key))
373
+ ]
374
+
375
+ if isinstance(data, list):
376
+ has_indexed_items = any(isinstance(item, dict) for item in data)
377
+ if has_indexed_items:
378
+ return [
379
+ err
380
+ for index, item in enumerate(data)
381
+ if item # skip empty dicts for list rows without errors
382
+ for err in (
383
+ [
384
+ nested_err
385
+ for field, field_errors in item.items()
386
+ for nested_err in _flatten_errors(
387
+ field_errors, _build_index_path(path, index, field)
388
+ )
389
+ ]
390
+ if isinstance(item, dict)
391
+ else _flatten_errors(item, _build_index_path(path, index))
392
+ )
393
+ ]
394
+ return [
395
+ Error(code="validation", message=f"{path}: {item}" if path else str(item))
396
+ for item in data
397
+ if item
398
+ ]
399
+
400
+ message = f"{path}: {data}" if path else str(data)
401
+ return [Error(code="validation", message=message)]
402
+
403
+ errors = _flatten_errors(detail, prefix)
404
+ return errors if errors else None
@@ -0,0 +1,12 @@
1
+ from django import forms
2
+ from django.db import models
3
+
4
+
5
+ class MultiChoiceField(models.JSONField):
6
+ def formfield(self, **kwargs):
7
+ defaults = {"choices_form_class": forms.TypedMultipleChoiceField}
8
+ defaults.update(kwargs)
9
+ return super().formfield(**defaults)
10
+
11
+ def validate(self, value, model_instance):
12
+ pass