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.
- karrio/server/conf.py +54 -0
- karrio/server/core/__init__.py +3 -0
- karrio/server/core/admin.py +1 -0
- karrio/server/core/apps.py +10 -0
- karrio/server/core/authentication.py +347 -0
- karrio/server/core/config.py +31 -0
- karrio/server/core/context_processors.py +12 -0
- karrio/server/core/datatypes.py +394 -0
- karrio/server/core/dataunits.py +187 -0
- karrio/server/core/exceptions.py +404 -0
- karrio/server/core/fields.py +12 -0
- karrio/server/core/filters.py +837 -0
- karrio/server/core/gateway.py +1011 -0
- karrio/server/core/logging.py +403 -0
- karrio/server/core/management/commands/cli.py +19 -0
- karrio/server/core/management/commands/create_oauth_client.py +41 -0
- karrio/server/core/management/commands/runserver.py +5 -0
- karrio/server/core/middleware.py +197 -0
- karrio/server/core/migrations/0001_initial.py +28 -0
- karrio/server/core/migrations/0002_apilogindex.py +69 -0
- karrio/server/core/migrations/0003_apilogindex_test_mode.py +62 -0
- karrio/server/core/migrations/0004_metafield.py +74 -0
- karrio/server/core/migrations/0005_alter_metafield_type_alter_metafield_value.py +23 -0
- karrio/server/core/migrations/0006_add_api_log_requested_at_index.py +22 -0
- karrio/server/core/migrations/__init__.py +0 -0
- karrio/server/core/models/__init__.py +48 -0
- karrio/server/core/models/base.py +103 -0
- karrio/server/core/models/entity.py +24 -0
- karrio/server/core/models/metafield.py +144 -0
- karrio/server/core/models/third_party.py +21 -0
- karrio/server/core/oauth_validators.py +170 -0
- karrio/server/core/permissions.py +36 -0
- karrio/server/core/renderers.py +11 -0
- karrio/server/core/router.py +3 -0
- karrio/server/core/serializers.py +1971 -0
- karrio/server/core/signals.py +55 -0
- karrio/server/core/telemetry.py +573 -0
- karrio/server/core/tests.py +99 -0
- karrio/server/core/tests_resource_token.py +411 -0
- karrio/server/core/urls.py +12 -0
- karrio/server/core/utils.py +1025 -0
- karrio/server/core/validators.py +264 -0
- karrio/server/core/views/__init__.py +2 -0
- karrio/server/core/views/api.py +133 -0
- karrio/server/core/views/metadata.py +44 -0
- karrio/server/core/views/oauth.py +75 -0
- karrio/server/core/views/references.py +82 -0
- karrio/server/core/views/schema.py +310 -0
- karrio/server/filters/__init__.py +2 -0
- karrio/server/filters/abstract.py +26 -0
- karrio/server/iam/__init__.py +0 -0
- karrio/server/iam/admin.py +3 -0
- karrio/server/iam/apps.py +21 -0
- karrio/server/iam/migrations/0001_initial.py +33 -0
- karrio/server/iam/migrations/__init__.py +0 -0
- karrio/server/iam/models.py +48 -0
- karrio/server/iam/permissions.py +155 -0
- karrio/server/iam/serializers.py +54 -0
- karrio/server/iam/signals.py +18 -0
- karrio/server/iam/tests.py +3 -0
- karrio/server/iam/views.py +3 -0
- karrio/server/openapi.py +75 -0
- karrio/server/providers/__init__.py +1 -0
- karrio/server/providers/admin.py +364 -0
- karrio/server/providers/apps.py +10 -0
- karrio/server/providers/management/commands/migrate_rate_sheets.py +101 -0
- karrio/server/providers/migrations/0001_initial.py +140 -0
- karrio/server/providers/migrations/0002_carrier_active.py +18 -0
- karrio/server/providers/migrations/0003_auto_20201230_0820.py +24 -0
- karrio/server/providers/migrations/0004_auto_20210212_0554.py +178 -0
- karrio/server/providers/migrations/0005_auto_20210212_0555.py +18 -0
- karrio/server/providers/migrations/0006_australiapostsettings.py +29 -0
- karrio/server/providers/migrations/0007_auto_20210213_0206.py +21 -0
- karrio/server/providers/migrations/0008_auto_20210214_0409.py +30 -0
- karrio/server/providers/migrations/0009_auto_20210308_0302.py +18 -0
- karrio/server/providers/migrations/0010_auto_20210409_0852.py +32 -0
- karrio/server/providers/migrations/0011_auto_20210409_0853.py +21 -0
- karrio/server/providers/migrations/0012_alter_carrier_options.py +17 -0
- karrio/server/providers/migrations/0013_tntsettings.py +30 -0
- karrio/server/providers/migrations/0014_auto_20210612_1608.py +46 -0
- karrio/server/providers/migrations/0015_auto_20210615_1601.py +28 -0
- karrio/server/providers/migrations/0016_alter_purolatorsettings_user_token.py +18 -0
- karrio/server/providers/migrations/0017_auto_20210805_0359.py +1293 -0
- karrio/server/providers/migrations/0018_alter_fedexsettings_user_key.py +18 -0
- karrio/server/providers/migrations/0019_dhlpolandsettings_servicelevel.py +65 -0
- karrio/server/providers/migrations/0020_genericsettings_labeltemplate.py +52 -0
- karrio/server/providers/migrations/0021_auto_20211231_2353.py +40 -0
- karrio/server/providers/migrations/0022_carrier_metadata.py +18 -0
- karrio/server/providers/migrations/0023_auto_20220124_1916.py +27 -0
- karrio/server/providers/migrations/0024_alter_genericsettings_custom_carrier_name.py +19 -0
- karrio/server/providers/migrations/0025_alter_servicelevel_service_code.py +19 -0
- karrio/server/providers/migrations/0026_auto_20220208_0132.py +59 -0
- karrio/server/providers/migrations/0027_auto_20220304_1340.py +29 -0
- karrio/server/providers/migrations/0028_auto_20220323_1500.py +33 -0
- karrio/server/providers/migrations/0029_easypostsettings.py +27 -0
- karrio/server/providers/migrations/0030_amazonmwssettings.py +29 -0
- karrio/server/providers/migrations/0031_delete_amazonmwssettings.py +18 -0
- karrio/server/providers/migrations/0032_alter_carrier_test.py +18 -0
- karrio/server/providers/migrations/0033_auto_20220708_1350.py +22 -0
- karrio/server/providers/migrations/0034_amazonmwssettings_dpdhlsettings.py +47 -0
- karrio/server/providers/migrations/0035_alter_carrier_capabilities.py +43 -0
- karrio/server/providers/migrations/0036_upsfreightsettings.py +31 -0
- karrio/server/providers/migrations/0037_chronopostsettings.py +29 -0
- karrio/server/providers/migrations/0038_alter_genericsettings_label_template.py +19 -0
- karrio/server/providers/migrations/0039_auto_20220906_0612.py +23 -0
- karrio/server/providers/migrations/0040_dpdhlsettings_services.py +18 -0
- karrio/server/providers/migrations/0041_auto_20221105_0705.py +38 -0
- karrio/server/providers/migrations/0042_auto_20221215_1642.py +23 -0
- karrio/server/providers/migrations/0043_alter_genericsettings_account_number_and_more.py +39 -0
- karrio/server/providers/migrations/0044_carrier_carrier_capabilities.py +64 -0
- karrio/server/providers/migrations/0045_alter_carrier_active_alter_carrier_carrier_id.py +31 -0
- karrio/server/providers/migrations/0046_remove_dpdhlsettings_signature_and_more.py +41 -0
- karrio/server/providers/migrations/0047_dpdsettings.py +286 -0
- karrio/server/providers/migrations/0048_servicelevel_min_weight_servicelevel_transit_days_and_more.py +64 -0
- karrio/server/providers/migrations/0049_boxknightsettings_geodissettings_lapostesettings_and_more.py +156 -0
- karrio/server/providers/migrations/0050_carrier_is_system_alter_carrier_metadata_and_more.py +106 -0
- karrio/server/providers/migrations/0051_rename_username_upssettings_client_id_and_more.py +31 -0
- karrio/server/providers/migrations/0052_alter_upssettings_account_number_and_more.py +20 -0
- karrio/server/providers/migrations/0053_locate2usettings.py +281 -0
- karrio/server/providers/migrations/0054_zoom2usettings.py +280 -0
- karrio/server/providers/migrations/0055_rename_amazonmwssettings_amazonshippingsettings_and_more.py +44 -0
- karrio/server/providers/migrations/0056_asendiaussettings_geodissettings_code_client_and_more.py +75 -0
- karrio/server/providers/migrations/0057_alter_servicelevel_weight_unit_belgianpostsettings.py +51 -0
- karrio/server/providers/migrations/0058_alliedexpresssettings.py +38 -0
- karrio/server/providers/migrations/0059_ratesheet.py +81 -0
- karrio/server/providers/migrations/0060_belgianpostsettings_rate_sheet_and_more.py +73 -0
- karrio/server/providers/migrations/0061_alliedexpresssettings_service_type.py +17 -0
- karrio/server/providers/migrations/0062_sendlesettings_account_country_code.py +257 -0
- karrio/server/providers/migrations/0063_servicelevel_metadata.py +25 -0
- karrio/server/providers/migrations/0064_alliedexpresslocalsettings.py +43 -0
- karrio/server/providers/migrations/0065_servicelevel_carrier_service_code_and_more.py +66 -0
- karrio/server/providers/migrations/0066_rename_fedexsettings_fedexwssettings_and_more.py +28 -0
- karrio/server/providers/migrations/0067_fedexsettings.py +283 -0
- karrio/server/providers/migrations/0068_fedexsettings_track_api_key_and_more.py +38 -0
- karrio/server/providers/migrations/0069_alter_canadapostsettings_contract_id_and_more.py +23 -0
- karrio/server/providers/migrations/0070_tgesettings_alter_carrier_capabilities.py +65 -0
- karrio/server/providers/migrations/0071_alter_tgesettings_my_toll_token.py +18 -0
- karrio/server/providers/migrations/0072_rename_eshippersettings_eshipperxmlsettings_and_more.py +28 -0
- karrio/server/providers/migrations/0073_delete_eshipperxmlsettings.py +41 -0
- karrio/server/providers/migrations/0074_eshippersettings.py +38 -0
- karrio/server/providers/migrations/0075_haypostsettings.py +40 -0
- karrio/server/providers/migrations/0076_rename_customer_registration_id_uspsinternationalsettings_account_number_and_more.py +125 -0
- karrio/server/providers/migrations/0077_uspswtinternationalsettings_uspswtsettings_and_more.py +165 -0
- karrio/server/providers/migrations/0078_auto_20240813_1552.py +120 -0
- karrio/server/providers/migrations/0079_alter_carrier_options_alter_ratesheet_created_by.py +31 -0
- karrio/server/providers/migrations/0080_alter_aramexsettings_account_country_code_and_more.py +3025 -0
- karrio/server/providers/migrations/0081_remove_alliedexpresssettings_carrier_ptr_and_more.py +338 -0
- karrio/server/providers/migrations/0082_add_zone_identifiers.py +50 -0
- karrio/server/providers/migrations/0083_add_optimized_rate_sheet_structure.py +33 -0
- karrio/server/providers/migrations/0084_alter_servicelevel_currency.py +168 -0
- karrio/server/providers/migrations/0085_populate_dhl_parcel_de_oauth_credentials.py +82 -0
- karrio/server/providers/migrations/0086_rename_dhl_parcel_de_customer_number_to_billing_number.py +71 -0
- karrio/server/providers/migrations/__init__.py +0 -0
- karrio/server/providers/models/__init__.py +16 -0
- karrio/server/providers/models/carrier.py +387 -0
- karrio/server/providers/models/config.py +30 -0
- karrio/server/providers/models/service.py +192 -0
- karrio/server/providers/models/sheet.py +287 -0
- karrio/server/providers/models/template.py +39 -0
- karrio/server/providers/models/utils.py +58 -0
- karrio/server/providers/router.py +3 -0
- karrio/server/providers/serializers/__init__.py +3 -0
- karrio/server/providers/serializers/base.py +538 -0
- karrio/server/providers/signals.py +25 -0
- karrio/server/providers/templates/providers/oauth_callback.html +105 -0
- karrio/server/providers/tests/__init__.py +5 -0
- karrio/server/providers/tests/test_connections.py +895 -0
- karrio/server/providers/urls.py +11 -0
- karrio/server/providers/views/__init__.py +0 -0
- karrio/server/providers/views/carriers.py +267 -0
- karrio/server/providers/views/connections.py +496 -0
- karrio/server/samples.py +352 -0
- karrio/server/serializers/__init__.py +2 -0
- karrio/server/serializers/abstract.py +602 -0
- karrio/server/tracing/__init__.py +0 -0
- karrio/server/tracing/admin.py +63 -0
- karrio/server/tracing/apps.py +8 -0
- karrio/server/tracing/migrations/0001_initial.py +41 -0
- karrio/server/tracing/migrations/0002_auto_20220710_1307.py +22 -0
- karrio/server/tracing/migrations/0003_auto_20221105_0317.py +43 -0
- karrio/server/tracing/migrations/0004_tracingrecord_carrier_account_idx.py +24 -0
- karrio/server/tracing/migrations/0005_optimise_tracingrecord_request_log_idx.py +25 -0
- karrio/server/tracing/migrations/0006_alter_tracingrecord_options_and_more.py +49 -0
- karrio/server/tracing/migrations/0007_tracingrecord_tracing_created_at_idx.py +19 -0
- karrio/server/tracing/migrations/__init__.py +0 -0
- karrio/server/tracing/models.py +82 -0
- karrio/server/tracing/tests.py +3 -0
- karrio/server/tracing/utils.py +109 -0
- karrio/server/user/__init__.py +0 -0
- karrio/server/user/admin.py +96 -0
- karrio/server/user/apps.py +7 -0
- karrio/server/user/forms.py +35 -0
- karrio/server/user/migrations/0001_initial.py +41 -0
- karrio/server/user/migrations/0002_token.py +29 -0
- karrio/server/user/migrations/0003_token_test_mode.py +20 -0
- karrio/server/user/migrations/0004_group.py +26 -0
- karrio/server/user/migrations/0005_token_label.py +21 -0
- karrio/server/user/migrations/0006_workspaceconfig.py +63 -0
- karrio/server/user/migrations/0007_user_metadata.py +25 -0
- karrio/server/user/migrations/__init__.py +0 -0
- karrio/server/user/models.py +218 -0
- karrio/server/user/serializers.py +47 -0
- karrio/server/user/templates/registration/login.html +108 -0
- karrio/server/user/templates/registration/registration_confirm_email.html +10 -0
- karrio/server/user/templates/registration/registration_confirm_email.txt +3 -0
- karrio/server/user/tests.py +3 -0
- karrio/server/user/urls.py +10 -0
- karrio/server/user/utils.py +60 -0
- karrio/server/user/views.py +9 -0
- karrio_server_core-2025.5.dist-info/METADATA +32 -0
- karrio_server_core-2025.5.dist-info/RECORD +213 -0
- karrio_server_core-2025.5.dist-info/WHEEL +5 -0
- 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
|