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,1025 @@
1
+ import sys
2
+ import typing
3
+ import inspect
4
+ import functools
5
+ from string import Template
6
+ from concurrent import futures
7
+ from datetime import timedelta, datetime
8
+ from typing import TypeVar, Union, Callable, Any, List, Optional
9
+
10
+ from django.conf import settings
11
+ from django.utils.translation import gettext_lazy as _
12
+ import django_email_verification.confirm as confirm
13
+ import rest_framework_simplejwt.tokens as jwt
14
+ import rest_framework.status as status
15
+ from karrio.server.core.logging import logger
16
+
17
+ import karrio.lib as lib
18
+ from karrio.core.utils import DP, DF
19
+ from karrio.server.core import datatypes, serializers, exceptions
20
+
21
+ T = TypeVar("T")
22
+
23
+
24
+ def identity(value: Union[Any, Callable]) -> Any:
25
+ """
26
+ :param value: function or value desired to be wrapped
27
+ :return: value or callable return
28
+ """
29
+ return value() if callable(value) else value
30
+
31
+
32
+ def execute_gateway_operation(
33
+ operation_name: str,
34
+ callable: Callable[[], T],
35
+ carrier: Any = None,
36
+ context: Any = None,
37
+ ) -> T:
38
+ """Execute a gateway operation with telemetry instrumentation.
39
+
40
+ This function wraps SDK gateway calls (rates, shipments, tracking, etc.)
41
+ with telemetry spans for observability when Sentry is configured.
42
+
43
+ Args:
44
+ operation_name: Name of the operation (e.g., "rates_fetch", "shipment_create")
45
+ callable: The callable that performs the SDK operation
46
+ carrier: Optional carrier instance for context
47
+ context: Optional request context for accessing the tracer
48
+
49
+ Returns:
50
+ The result of the callable
51
+
52
+ Example:
53
+ result = execute_gateway_operation(
54
+ "rates_fetch",
55
+ lambda: karrio.Rating.fetch(request).from_(carrier.gateway).parse(),
56
+ carrier=carrier,
57
+ context=context,
58
+ )
59
+ """
60
+ tracer = _get_tracer_from_context(context)
61
+
62
+ # Build span attributes
63
+ attributes = {}
64
+ if carrier:
65
+ attributes["carrier_name"] = getattr(carrier, "carrier_code", None)
66
+ attributes["carrier_id"] = getattr(carrier, "carrier_id", None)
67
+ attributes["test_mode"] = getattr(carrier, "test_mode", None)
68
+
69
+ span_name = f"karrio_{operation_name}"
70
+
71
+ with tracer.start_span(span_name, attributes=attributes) as span:
72
+ try:
73
+ result = callable()
74
+ span.set_status("ok")
75
+
76
+ # Record success metric
77
+ tracer.record_metric(
78
+ f"karrio_{operation_name}_success",
79
+ 1,
80
+ tags={
81
+ "carrier": attributes.get("carrier_name", "unknown"),
82
+ "test_mode": str(attributes.get("test_mode", False)).lower(),
83
+ },
84
+ )
85
+
86
+ return result
87
+
88
+ except Exception as e:
89
+ span.set_status("error", str(e))
90
+ span.record_exception(e)
91
+
92
+ # Record failure metric
93
+ tracer.record_metric(
94
+ f"karrio_{operation_name}_error",
95
+ 1,
96
+ tags={
97
+ "carrier": attributes.get("carrier_name", "unknown"),
98
+ "error_type": type(e).__name__,
99
+ },
100
+ )
101
+
102
+ raise
103
+
104
+
105
+ def _get_tracer_from_context(context: Any) -> lib.Tracer:
106
+ """Get the tracer from request context or return a default one."""
107
+ if context is None:
108
+ # Try to get from current request via middleware
109
+ try:
110
+ from karrio.server.core.middleware import SessionContext
111
+ request = SessionContext.get_current_request()
112
+ if request and hasattr(request, "tracer"):
113
+ return request.tracer
114
+ except Exception:
115
+ pass
116
+
117
+ # Try to get from context object
118
+ if hasattr(context, "tracer"):
119
+ return context.tracer
120
+
121
+ if hasattr(context, "request") and hasattr(context.request, "tracer"):
122
+ return context.request.tracer
123
+
124
+ # Return a default tracer (with NoOpTelemetry)
125
+ return lib.Tracer()
126
+
127
+
128
+ def failsafe(callable: Callable[[], T], warning: str = None) -> T:
129
+ """This higher order function wraps a callable in a try..except
130
+ scope to capture any exception raised.
131
+ Only use it when you are running something unstable that you
132
+ don't mind if it fails.
133
+ """
134
+ try:
135
+ return callable()
136
+ except Exception as e:
137
+ if warning:
138
+ logger.warning(warning, error=str(e))
139
+ return None
140
+
141
+
142
+ def run_async(callable: Callable[[], Any]) -> futures.Future:
143
+ """This higher order function initiate the execution
144
+ of a callable in a non-blocking thread and return a
145
+ handle for a future response.
146
+ """
147
+ return futures.ThreadPoolExecutor(max_workers=1).submit(callable)
148
+
149
+
150
+ def error_wrapper(func):
151
+ @functools.wraps(func)
152
+ def wrapper(*args, **kwargs):
153
+ try:
154
+ return func(*args, **kwargs)
155
+ except Exception as e:
156
+ logger.exception(e)
157
+ raise e
158
+
159
+ return wrapper
160
+
161
+
162
+ def async_wrapper(func):
163
+ @functools.wraps(func)
164
+ def wrapper(*args, run_synchronous: bool = False, **kwargs):
165
+ def _run():
166
+ return func(*args, **kwargs)
167
+
168
+ if run_synchronous:
169
+ return _run()
170
+
171
+ return run_async(_run)
172
+
173
+ return wrapper
174
+
175
+
176
+ def with_telemetry(operation_name: str = None):
177
+ """Decorator that adds telemetry instrumentation to gateway methods.
178
+
179
+ This decorator wraps gateway methods with telemetry spans for observability.
180
+ When Sentry is configured, it creates spans for each operation with relevant
181
+ context (carrier info, operation type, etc.).
182
+
183
+ Args:
184
+ operation_name: Optional custom operation name. If not provided,
185
+ the function name is used.
186
+
187
+ Usage:
188
+ class Shipments:
189
+ @staticmethod
190
+ @with_telemetry("shipment_create")
191
+ def create(payload: dict, carrier: Carrier = None, **kwargs):
192
+ ...
193
+ """
194
+ def decorator(func):
195
+ @functools.wraps(func)
196
+ def wrapper(*args, **kwargs):
197
+ op_name = operation_name or func.__name__
198
+
199
+ # Extract carrier and context from kwargs if available
200
+ carrier = kwargs.get("carrier")
201
+ context = kwargs.get("context")
202
+
203
+ # Get tracer from context
204
+ tracer = _get_tracer_from_context(context)
205
+
206
+ # Build span attributes
207
+ attributes = {"operation": op_name}
208
+ if carrier:
209
+ attributes["carrier_name"] = getattr(carrier, "carrier_code", None)
210
+ attributes["carrier_id"] = getattr(carrier, "carrier_id", None)
211
+ attributes["test_mode"] = getattr(carrier, "test_mode", None)
212
+
213
+ span_name = f"karrio_{op_name}"
214
+
215
+ with tracer.start_span(span_name, attributes=attributes) as span:
216
+ try:
217
+ result = func(*args, **kwargs)
218
+ span.set_status("ok")
219
+
220
+ # Record success metric
221
+ tracer.record_metric(
222
+ f"karrio_{op_name}_success",
223
+ 1,
224
+ tags={
225
+ "carrier": attributes.get("carrier_name", "unknown") or "unknown",
226
+ },
227
+ )
228
+
229
+ return result
230
+
231
+ except Exception as e:
232
+ span.set_status("error", str(e))
233
+ span.record_exception(e)
234
+
235
+ # Record error metric
236
+ tracer.record_metric(
237
+ f"karrio_{op_name}_error",
238
+ 1,
239
+ tags={
240
+ "carrier": attributes.get("carrier_name", "unknown") or "unknown",
241
+ "error_type": type(e).__name__,
242
+ },
243
+ )
244
+
245
+ raise
246
+
247
+ return wrapper
248
+ return decorator
249
+
250
+
251
+ def tenant_aware(func):
252
+ @functools.wraps(func)
253
+ def wrapper(*args, **kwargs):
254
+ if settings.MULTI_TENANTS:
255
+ import django_tenants.utils as tenant_utils
256
+
257
+ schema = kwargs.get("schema") or "public"
258
+
259
+ with tenant_utils.schema_context(schema):
260
+ return func(*args, **kwargs)
261
+ else:
262
+ return func(*args, **kwargs)
263
+
264
+ return wrapper
265
+
266
+
267
+ def run_on_all_tenants(func):
268
+ @functools.wraps(func)
269
+ def wrapper(*args, **kwargs):
270
+ if settings.MULTI_TENANTS:
271
+ import django_tenants.utils as tenant_utils
272
+
273
+ tenants = tenant_utils.get_tenant_model().objects.exclude(
274
+ schema_name="public"
275
+ )
276
+
277
+ for tenant in tenants:
278
+ with tenant_utils.tenant_context(tenant):
279
+ func(*args, **kwargs, schema=tenant.schema_name)
280
+ else:
281
+ func(*args, **kwargs)
282
+
283
+ return wrapper
284
+
285
+
286
+ def disable_for_loaddata(signal_handler):
287
+ @functools.wraps(signal_handler)
288
+ def wrapper(*args, **kwargs):
289
+ if is_system_loading_data():
290
+ return
291
+
292
+ signal_handler(*args, **kwargs)
293
+
294
+ return wrapper
295
+
296
+
297
+ def skip_on_loadata(func):
298
+ @functools.wraps(func)
299
+ def wrapper(*args, **kwargs):
300
+ if "loaddata" in sys.argv:
301
+ return
302
+
303
+ return func(*args, **kwargs)
304
+
305
+ return wrapper
306
+
307
+
308
+ def skip_on_commands(
309
+ commands: typing.List[str] = ["loaddata", "migrate", "makemigrations"]
310
+ ):
311
+ def _decorator(func):
312
+ @functools.wraps(func)
313
+ def wrapper(*args, **kwargs):
314
+ if any(cmd in sys.argv for cmd in commands):
315
+ return
316
+
317
+ return func(*args, **kwargs)
318
+
319
+ return wrapper
320
+
321
+ return _decorator
322
+
323
+
324
+ def email_setup_required(func):
325
+ @functools.wraps(func)
326
+ def wrapper(*args, **kwargs):
327
+ if not settings.EMAIL_ENABLED:
328
+ raise Exception(_("The email service is not configured."))
329
+
330
+ return func(*args, **kwargs)
331
+
332
+ return wrapper
333
+
334
+
335
+ def post_processing(methods: List[str] = None):
336
+ def class_wrapper(klass):
337
+ setattr(
338
+ klass,
339
+ "post_process_functions",
340
+ getattr(klass, "post_process_functions") or [],
341
+ )
342
+
343
+ for name in methods:
344
+ method = getattr(klass, name)
345
+
346
+ def wrapper(*args, **kwargs):
347
+ result = method(*args, **kwargs)
348
+ processes = klass.post_process_functions
349
+ context = kwargs.get("context")
350
+
351
+ return functools.reduce(
352
+ lambda cummulated_result, process: process(
353
+ context, cummulated_result
354
+ ),
355
+ processes,
356
+ result,
357
+ )
358
+
359
+ setattr(klass, name, wrapper)
360
+
361
+ return klass
362
+
363
+ return class_wrapper
364
+
365
+
366
+ def upper(value_str: Optional[str]) -> Optional[str]:
367
+ if value_str is None:
368
+ return None
369
+
370
+ return value_str.upper().replace("_", " ")
371
+
372
+
373
+ def batch_get_constance_values(keys: List[str]) -> dict:
374
+ """
375
+ Batch fetch multiple configuration values from Django Constance.
376
+
377
+ This function uses Constance's mget() method to fetch all requested
378
+ configuration keys in a single database query, avoiding N+1 query issues.
379
+
380
+ Args:
381
+ keys: List of configuration key names to fetch
382
+
383
+ Returns:
384
+ Dictionary mapping configuration keys to their values
385
+
386
+ Example:
387
+ >>> flags = batch_get_constance_values(['AUDIT_LOGGING', 'ALLOW_SIGNUP'])
388
+ >>> print(flags['AUDIT_LOGGING'])
389
+ True
390
+ """
391
+ from constance import config
392
+
393
+ try:
394
+ # Use mget to fetch all config values in a single query
395
+ # mget returns a generator of (key, value) tuples
396
+ return dict(config._backend.mget(keys))
397
+ except Exception as e:
398
+ logger.warning("Failed to batch fetch constance values, returning empty dict", error=str(e))
399
+ return {}
400
+
401
+
402
+ def compute_tracking_status(
403
+ details: Optional[datatypes.Tracking] = None,
404
+ ) -> serializers.TrackerStatus:
405
+ if details is None:
406
+ return serializers.TrackerStatus.pending
407
+ elif details.delivered:
408
+ return serializers.TrackerStatus.delivered
409
+ elif (len(details.events) == 0) or (
410
+ len(details.events) == 1 and details.events[0].code == "CREATED"
411
+ ):
412
+ return serializers.TrackerStatus.pending
413
+
414
+ if (
415
+ any(details.status or "")
416
+ and serializers.TrackerStatus.map(details.status).value is not None
417
+ ):
418
+ return serializers.TrackerStatus.map(details.status)
419
+
420
+ return serializers.TrackerStatus.in_transit
421
+
422
+
423
+ def is_sdk_message(
424
+ message: Optional[Union[datatypes.Message, List[datatypes.Message]]],
425
+ ) -> bool:
426
+ msg = next(iter(message), None) if isinstance(message, list) else message
427
+
428
+ return "SHIPPING_SDK_" in str(getattr(msg, "code", ""))
429
+
430
+
431
+ def filter_rate_carrier_compatible_gateways(
432
+ carriers: List, carrier_ids: List[str], shipper_country_code: Optional[str] = None
433
+ ) -> List:
434
+ """
435
+ This function filters the carriers based on the capability to "rating"
436
+ and if no explicit carrier list is provided, it will filter out any
437
+ carrier that does not support the shipper's country code.
438
+ Carriers with no account_country_code set are always included.
439
+ """
440
+ _gateways = [
441
+ carrier.gateway
442
+ for carrier in carriers
443
+ if (
444
+ # If explicit carrier list is provided and gateway has "rating" capability.
445
+ ("rating" in carrier.gateway.capabilities and len(carrier_ids) > 0)
446
+ # If no carrier list is provided, and gateway is in the list.
447
+ or (
448
+ # the gateway has "rating" capability.
449
+ "rating" in carrier.gateway.capabilities
450
+ # and no explicit carrier list is provided.
451
+ and len(carrier_ids) == 0
452
+ # and the shipper country code is provided.
453
+ and shipper_country_code is not None
454
+ and (
455
+ # Carriers with no account_country_code work across countries
456
+ not carrier.gateway.settings.account_country_code
457
+ or carrier.gateway.settings.account_country_code
458
+ == shipper_country_code
459
+ )
460
+ )
461
+ )
462
+ ]
463
+
464
+ return ({_.settings.carrier_id: _ for _ in _gateways}).values()
465
+
466
+
467
+ def is_system_loading_data() -> bool:
468
+ try:
469
+ for fr in inspect.stack():
470
+ if inspect.getmodulename(fr[1]) == "loaddata":
471
+ return True
472
+ except:
473
+ pass
474
+
475
+ return False
476
+
477
+
478
+ @email_setup_required
479
+ def send_email(
480
+ emails: List[str],
481
+ subject: str,
482
+ email_template: str,
483
+ context: dict = {},
484
+ text_template: str = None,
485
+ **kwargs,
486
+ ):
487
+ sender = confirm._get_validated_field("EMAIL_FROM_ADDRESS")
488
+ html = confirm.render_to_string(email_template, context)
489
+ text = confirm.render_to_string(text_template or email_template, context)
490
+
491
+ msg = confirm.EmailMultiAlternatives(subject, text, sender, emails)
492
+ msg.attach_alternative(html, "text/html")
493
+ msg.send()
494
+
495
+
496
+ class ConfirmationToken(jwt.Token):
497
+ token_type = "confirmation"
498
+ lifetime = timedelta(hours=2)
499
+
500
+ @classmethod
501
+ def for_data(cls, user, data: dict) -> str:
502
+ token = super().for_user(user)
503
+
504
+ for k, v in data.items():
505
+ token[k] = v
506
+
507
+ return token
508
+
509
+
510
+ class ResourceAccessToken(jwt.Token):
511
+ """JWT token for limited resource access (documents, exports, etc.)."""
512
+
513
+ token_type = "resource_access"
514
+ lifetime = timedelta(minutes=5)
515
+
516
+ @classmethod
517
+ def for_resource(
518
+ cls,
519
+ user,
520
+ resource_type: str,
521
+ resource_ids: List[str],
522
+ access: List[str],
523
+ format: Optional[str] = None,
524
+ org_id: Optional[str] = None,
525
+ test_mode: Optional[bool] = None,
526
+ expires_in: Optional[int] = None,
527
+ ) -> "ResourceAccessToken":
528
+ """Generate a resource access token.
529
+
530
+ Args:
531
+ user: The authenticated user
532
+ resource_type: Type of resource (shipment, manifest, template, document)
533
+ resource_ids: List of resource IDs to grant access to
534
+ access: List of access permissions (label, invoice, manifest, render, etc.)
535
+ format: Document format (pdf, png, zpl)
536
+ org_id: Organization ID for multi-tenant environments
537
+ test_mode: Whether this is test mode
538
+ expires_in: Custom expiration time in seconds
539
+
540
+ Returns:
541
+ ResourceAccessToken instance
542
+ """
543
+ token = cls()
544
+ token["user_id"] = user.id if hasattr(user, "id") else user
545
+ token["resource_type"] = resource_type
546
+ token["resource_ids"] = resource_ids
547
+ token["access"] = access
548
+
549
+ if format:
550
+ token["format"] = format
551
+ if org_id:
552
+ token["org_id"] = org_id
553
+ if test_mode is not None:
554
+ token["test_mode"] = test_mode
555
+ if expires_in:
556
+ token.set_exp(lifetime=timedelta(seconds=expires_in))
557
+
558
+ return token
559
+
560
+ @classmethod
561
+ def decode(cls, token_string: str) -> dict:
562
+ """Decode and validate a resource access token.
563
+
564
+ Args:
565
+ token_string: The JWT token string
566
+
567
+ Returns:
568
+ Dictionary with token claims
569
+
570
+ Raises:
571
+ rest_framework_simplejwt.exceptions.TokenError: If token is invalid
572
+ """
573
+ token = cls(token_string)
574
+ return {
575
+ "user_id": token.get("user_id"),
576
+ "resource_type": token.get("resource_type"),
577
+ "resource_ids": token.get("resource_ids", []),
578
+ "access": token.get("access", []),
579
+ "format": token.get("format"),
580
+ "org_id": token.get("org_id"),
581
+ "test_mode": token.get("test_mode"),
582
+ }
583
+
584
+ @classmethod
585
+ def validate_access(
586
+ cls,
587
+ token_string: str,
588
+ resource_type: str,
589
+ resource_id: str,
590
+ access: str,
591
+ ) -> dict:
592
+ """Validate token grants access to specific resource and action.
593
+
594
+ Args:
595
+ token_string: The JWT token string
596
+ resource_type: Expected resource type
597
+ resource_id: Resource ID to check access for
598
+ access: Access permission to check
599
+
600
+ Returns:
601
+ Token claims if valid
602
+
603
+ Raises:
604
+ rest_framework_simplejwt.exceptions.TokenError: If token is invalid
605
+ PermissionError: If access is not granted
606
+ """
607
+ claims = cls.decode(token_string)
608
+
609
+ if claims["resource_type"] != resource_type:
610
+ raise PermissionError(
611
+ f"Token not valid for resource type: {resource_type}"
612
+ )
613
+
614
+ if resource_id not in claims["resource_ids"]:
615
+ raise PermissionError(
616
+ f"Token not valid for resource: {resource_id}"
617
+ )
618
+
619
+ if access not in claims["access"]:
620
+ raise PermissionError(
621
+ f"Token does not grant access: {access}"
622
+ )
623
+
624
+ return claims
625
+
626
+ @classmethod
627
+ def validate_batch_access(
628
+ cls,
629
+ token_string: str,
630
+ resource_type: str,
631
+ resource_ids: List[str],
632
+ access: str,
633
+ ) -> dict:
634
+ """Validate token grants access to multiple resources.
635
+
636
+ Args:
637
+ token_string: The JWT token string
638
+ resource_type: Expected resource type
639
+ resource_ids: List of resource IDs to check access for
640
+ access: Access permission to check
641
+
642
+ Returns:
643
+ Token claims if valid
644
+
645
+ Raises:
646
+ rest_framework_simplejwt.exceptions.TokenError: If token is invalid
647
+ PermissionError: If access is not granted
648
+ """
649
+ claims = cls.decode(token_string)
650
+
651
+ if claims["resource_type"] != resource_type:
652
+ raise PermissionError(
653
+ f"Token not valid for resource type: {resource_type}"
654
+ )
655
+
656
+ if access not in claims["access"]:
657
+ raise PermissionError(
658
+ f"Token does not grant access: {access}"
659
+ )
660
+
661
+ token_ids = set(claims["resource_ids"])
662
+ request_ids = set(resource_ids)
663
+ if not request_ids.issubset(token_ids):
664
+ missing = request_ids - token_ids
665
+ raise PermissionError(
666
+ f"Token does not grant access to resources: {', '.join(missing)}"
667
+ )
668
+
669
+ return claims
670
+
671
+
672
+ def validate_resource_token(
673
+ request,
674
+ resource_type: str,
675
+ resource_ids: List[str],
676
+ access: str,
677
+ ):
678
+ """Validate resource access token. Returns error response if invalid, None if valid.
679
+
680
+ Args:
681
+ request: The HTTP request object
682
+ resource_type: Expected resource type (shipment, manifest, template, etc.)
683
+ resource_ids: List of resource IDs to validate access for
684
+ access: Required access permission (label, invoice, manifest, render, etc.)
685
+
686
+ Returns:
687
+ HttpResponseForbidden if validation fails, None if valid
688
+
689
+ Example:
690
+ error = validate_resource_token(request, "shipment", [pk], "label")
691
+ if error:
692
+ return error
693
+ """
694
+ from django.http import HttpResponseForbidden
695
+
696
+ token = request.GET.get("token")
697
+
698
+ if not token:
699
+ return HttpResponseForbidden(
700
+ "Access token required. Use /api/tokens to generate one."
701
+ )
702
+
703
+ try:
704
+ ResourceAccessToken.validate_batch_access(
705
+ token_string=token,
706
+ resource_type=resource_type,
707
+ resource_ids=resource_ids,
708
+ access=access,
709
+ )
710
+ return None
711
+ except PermissionError as e:
712
+ return HttpResponseForbidden("You do not have permission to access these resources.")
713
+ except Exception as e:
714
+ logger.warning("Invalid resource access token: %s", str(e))
715
+ return HttpResponseForbidden("Invalid or expired token.")
716
+
717
+
718
+ def require_resource_token(
719
+ resource_type: str,
720
+ access: str,
721
+ get_resource_ids: Callable[..., List[str]],
722
+ ):
723
+ """Decorator for views requiring resource access token validation.
724
+
725
+ Args:
726
+ resource_type: Expected resource type (shipment, manifest, template, etc.)
727
+ access: Required access permission (label, invoice, manifest, render, etc.)
728
+ get_resource_ids: Callable that extracts resource IDs from request and view kwargs.
729
+ Receives (request, **kwargs), returns list of resource IDs.
730
+
731
+ Example:
732
+ @require_resource_token(
733
+ resource_type="document",
734
+ access="batch_labels",
735
+ get_resource_ids=lambda req, **kw: req.GET.get("shipments", "").split(","),
736
+ )
737
+ def get(self, request, **kwargs):
738
+ ...
739
+ """
740
+ def decorator(method):
741
+ @functools.wraps(method)
742
+ def wrapper(self, request, *args, **kwargs):
743
+ resource_ids = get_resource_ids(request, **kwargs)
744
+ error = validate_resource_token(request, resource_type, resource_ids, access)
745
+ if error:
746
+ return error
747
+ return method(self, request, *args, **kwargs)
748
+
749
+ return wrapper
750
+
751
+ return decorator
752
+
753
+
754
+ def app_tracking_query_params(url: str, carrier) -> str:
755
+ hub_flag = f"&hub={carrier.carrier_name}" if carrier.gateway.is_hub else ""
756
+
757
+ return f"{url}{hub_flag}"
758
+
759
+
760
+ def default_tracking_event(
761
+ event_at: datetime = None,
762
+ code: str = None,
763
+ description: str = None,
764
+ ):
765
+ return [
766
+ DP.to_dict(
767
+ datatypes.TrackingEvent(
768
+ date=DF.fdate(event_at or datetime.now()),
769
+ description=(description or "Label created and ready for shipment"),
770
+ location="",
771
+ code=(code or "CREATED"),
772
+ time=DF.ftime(event_at or datetime.now()),
773
+ )
774
+ )
775
+ ]
776
+
777
+
778
+ def get_carrier_tracking_link(carrier, tracking_number: str):
779
+ tracking_url = getattr(carrier.gateway.settings, "tracking_url", None)
780
+
781
+ return tracking_url.format(tracking_number) if tracking_url is not None else None
782
+
783
+
784
+ def process_events(
785
+ response_events: typing.List[datatypes.TrackingEvent],
786
+ current_events: typing.List[dict],
787
+ ) -> typing.List[dict]:
788
+ """Merge new tracking events with existing ones, avoiding duplicates by comparing event hashes.
789
+ Latest events are kept at the top of the list."""
790
+ if not any(response_events):
791
+ return current_events
792
+
793
+ new_events = lib.to_dict(response_events)
794
+
795
+ # If no current events, return new events as-is (already sorted by SDK)
796
+ if not any(current_events):
797
+ return new_events
798
+
799
+ # Merge events: add only new non-duplicate events to existing ones
800
+ current_hashes = {lib.to_json(event) for event in current_events}
801
+ unique_new_events = [
802
+ event for event in new_events if lib.to_json(event) not in current_hashes
803
+ ]
804
+
805
+ # If no new unique events, return current events unchanged
806
+ if not any(unique_new_events):
807
+ return current_events
808
+
809
+ # When merging, we need to re-sort because new events may have timestamps
810
+ # that fall between existing events. We must parse datetimes properly
811
+ # (not use string comparison) to handle 12-hour AM/PM format correctly.
812
+ def try_parse_datetime(value: str, fmt: str) -> typing.Optional[datetime]:
813
+ """Safely attempt to parse a datetime string with a given format."""
814
+ return failsafe(lambda: datetime.strptime(value, fmt))
815
+
816
+ def parse_date(event: dict) -> typing.Optional[datetime]:
817
+ """Parse date from event using multiple format attempts."""
818
+ date_str = event.get("date", "")
819
+ date_formats = ["%Y-%m-%d", "%m/%d/%Y", "%-m/%d/%Y"]
820
+ return (
821
+ functools.reduce(
822
+ lambda acc, fmt: acc or try_parse_datetime(date_str, fmt),
823
+ date_formats,
824
+ None,
825
+ )
826
+ if date_str
827
+ else None
828
+ )
829
+
830
+ def parse_time(event: dict) -> typing.Optional[datetime.time]:
831
+ """Parse time from event using multiple format attempts."""
832
+ time_str = event.get("time", "")
833
+ time_formats = ["%I:%M %p", "%H:%M:%S", "%H:%M", "%I:%M"]
834
+ parsed = (
835
+ functools.reduce(
836
+ lambda acc, fmt: acc or try_parse_datetime(time_str, fmt),
837
+ time_formats,
838
+ None,
839
+ )
840
+ if time_str
841
+ else None
842
+ )
843
+ return parsed.time() if parsed else None
844
+
845
+ def parse_event_datetime(event: dict) -> typing.Optional[datetime]:
846
+ """Parse complete datetime from event date and time."""
847
+ parsed_date = parse_date(event)
848
+ parsed_time = parse_time(event) if parsed_date else None
849
+ return (
850
+ datetime.combine(parsed_date.date(), parsed_time)
851
+ if parsed_date and parsed_time
852
+ else parsed_date
853
+ )
854
+
855
+ def create_sort_key(event: dict) -> tuple:
856
+ """Create sort key: dated events first (by datetime desc), undated last (by original order)."""
857
+ dt = parse_event_datetime(event)
858
+ return (0 if dt else 1, dt if dt else datetime.min)
859
+
860
+ # Merge and sort all events
861
+ merged_events = current_events + unique_new_events
862
+ return sorted(merged_events, key=create_sort_key, reverse=True)
863
+
864
+
865
+ def _get_carrier_for_service(service: str, context=None) -> typing.Optional[str]:
866
+ """Resolve carrier name from service code using karrio references."""
867
+ import karrio.server.core.dataunits as dataunits
868
+
869
+ services_map = dataunits.contextual_reference(context).get("services", {})
870
+
871
+ return next(
872
+ (
873
+ carrier_name
874
+ for carrier_name, services in services_map.items()
875
+ if service in services
876
+ ),
877
+ None,
878
+ )
879
+
880
+
881
+ def apply_rate_selection(payload: typing.Union[dict, typing.Any], **kwargs):
882
+ data = kwargs.get("data") or kwargs
883
+ get = lambda key, default=None: lib.identity(
884
+ payload.get(key, data.get(key, default))
885
+ if isinstance(payload, dict)
886
+ else getattr(payload, key, data.get(key, default))
887
+ )
888
+
889
+ ctx = kwargs.get("context")
890
+ rates = get("rates") or data.get("rates", [])
891
+ options = get("options") or data.get("options", {})
892
+ service = get("service") or data.get("service", None)
893
+ rate_id = get("selected_rate_id") or data.get("selected_rate_id", None)
894
+ selected_rate = get("selected_rate") or data.get("selected_rate", None)
895
+ apply_shipping_rules = lib.identity(
896
+ getattr(settings, "SHIPPING_RULES", False)
897
+ and options.get("apply_shipping_rules", False)
898
+ )
899
+
900
+ if selected_rate:
901
+ kwargs.update(selected_rate=selected_rate)
902
+ return kwargs
903
+
904
+ # Select by id or service if provided
905
+ if rate_id or service:
906
+ kwargs.update(
907
+ selected_rate=next(
908
+ (
909
+ rate
910
+ for rate in rates
911
+ if (rate_id and rate.get("id") == rate_id)
912
+ or (service and rate.get("service") == service)
913
+ ),
914
+ None,
915
+ )
916
+ )
917
+
918
+ # has_alternative_services fallback when no exact match found
919
+ has_alternative_services = options.get("has_alternative_services", False)
920
+
921
+ if (
922
+ kwargs.get("selected_rate") is None
923
+ and has_alternative_services
924
+ and service
925
+ ):
926
+ carrier_name = _get_carrier_for_service(service, ctx)
927
+ fallback_rate = lib.identity(
928
+ next(
929
+ (r for r in rates if r.get("carrier_name") == carrier_name),
930
+ None,
931
+ )
932
+ if carrier_name
933
+ else None
934
+ )
935
+
936
+ kwargs.update(
937
+ selected_rate=lib.identity(
938
+ {
939
+ **fallback_rate,
940
+ "service": service,
941
+ "meta": {
942
+ **(fallback_rate.get("meta") or {}),
943
+ "has_alternative_services": True,
944
+ },
945
+ }
946
+ if fallback_rate
947
+ else None
948
+ )
949
+ )
950
+
951
+ return kwargs
952
+
953
+ # Apply shipping rules if enabled and no selected rate is provided
954
+ if apply_shipping_rules:
955
+ # Import rules engine only when needed
956
+ import karrio.server.automation.models as automation_models
957
+ import karrio.server.automation.services.rules_engine as engine
958
+
959
+ # Get active shipping rules
960
+ active_rules = list(
961
+ automation_models.ShippingRule.access_by(ctx).filter(is_active=True)
962
+ )
963
+
964
+ # Always run rule evaluation for activity tracking
965
+ if active_rules:
966
+ _, rule_selected_rate, rule_activity = engine.process_shipping_rules(
967
+ shipment=payload,
968
+ rules=active_rules,
969
+ context=ctx,
970
+ )
971
+
972
+ kwargs.update(
973
+ selected_rate=rule_selected_rate,
974
+ rule_activity=rule_activity,
975
+ )
976
+
977
+ return kwargs
978
+
979
+
980
+ def require_selected_rate(func):
981
+ """
982
+ Decorator for rate selection process.
983
+ - Checks if shipping rules are enabled
984
+ - Evaluates and applies rules to modify service if needed
985
+ - Augments response metadata with applied rules
986
+ """
987
+
988
+ @functools.wraps(func)
989
+ def wrapper(payload, **kwargs):
990
+
991
+ kwargs = apply_rate_selection(payload, **kwargs)
992
+
993
+ if kwargs.get("selected_rate") is None:
994
+ raise exceptions.APIException(
995
+ "The service you selected is not available for this shipment.",
996
+ code="service_unavailable",
997
+ status_code=status.HTTP_400_BAD_REQUEST,
998
+ )
999
+
1000
+ # Execute original function
1001
+ result = func(payload, **kwargs)
1002
+
1003
+ if isinstance(result, datatypes.Shipment) and kwargs.get("rule_activity"):
1004
+ return lib.to_object(
1005
+ datatypes.Shipment,
1006
+ {
1007
+ **lib.to_dict(result),
1008
+ "meta": {
1009
+ **(result.meta or {}),
1010
+ **({"rule_activity": kwargs.get("rule_activity")}),
1011
+ },
1012
+ },
1013
+ )
1014
+
1015
+ if hasattr(result, "save") and kwargs.get("rule_activity"):
1016
+ result.meta = {
1017
+ **(result.meta or {}),
1018
+ **({"rule_activity": kwargs.get("rule_activity")}),
1019
+ }
1020
+ result.save()
1021
+ return result
1022
+
1023
+ return result
1024
+
1025
+ return wrapper