karrio-server-core 2025.5rc31__py3-none-any.whl → 2026.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- karrio/server/core/authentication.py +38 -20
- karrio/server/core/config.py +31 -0
- karrio/server/core/datatypes.py +30 -4
- karrio/server/core/dataunits.py +26 -7
- karrio/server/core/exceptions.py +287 -17
- karrio/server/core/filters.py +14 -0
- karrio/server/core/gateway.py +284 -11
- karrio/server/core/logging.py +403 -0
- karrio/server/core/middleware.py +104 -2
- karrio/server/core/models/base.py +34 -1
- karrio/server/core/oauth_validators.py +2 -3
- karrio/server/core/permissions.py +1 -2
- karrio/server/core/serializers.py +154 -7
- karrio/server/core/signals.py +22 -28
- karrio/server/core/telemetry.py +573 -0
- karrio/server/core/tests/__init__.py +27 -0
- karrio/server/core/{tests.py → tests/base.py} +6 -7
- karrio/server/core/tests/test_exception_level.py +159 -0
- karrio/server/core/tests/test_resource_token.py +593 -0
- karrio/server/core/utils.py +688 -38
- karrio/server/core/validators.py +144 -222
- karrio/server/core/views/oauth.py +13 -12
- karrio/server/core/views/references.py +2 -2
- karrio/server/iam/apps.py +1 -4
- karrio/server/iam/migrations/0002_setup_carrier_permission_groups.py +103 -0
- karrio/server/iam/migrations/0003_remove_permission_groups.py +91 -0
- karrio/server/iam/permissions.py +7 -134
- karrio/server/iam/serializers.py +9 -3
- karrio/server/iam/signals.py +2 -4
- karrio/server/providers/admin.py +1 -1
- karrio/server/providers/migrations/0085_populate_dhl_parcel_de_oauth_credentials.py +82 -0
- karrio/server/providers/migrations/0086_rename_dhl_parcel_de_customer_number_to_billing_number.py +71 -0
- karrio/server/providers/migrations/0087_alter_carrier_capabilities.py +38 -0
- karrio/server/providers/migrations/0088_servicelevel_surcharges.py +24 -0
- karrio/server/providers/migrations/0089_servicelevel_cost_max_volume.py +31 -0
- karrio/server/providers/migrations/0090_ratesheet_surcharges_servicelevel_zone_surcharge_ids.py +47 -0
- karrio/server/providers/migrations/0091_migrate_legacy_zones_surcharges.py +154 -0
- karrio/server/providers/models/carrier.py +101 -29
- karrio/server/providers/models/service.py +182 -125
- karrio/server/providers/models/sheet.py +342 -198
- karrio/server/providers/serializers/base.py +263 -2
- karrio/server/providers/signals.py +2 -4
- karrio/server/providers/templates/providers/oauth_callback.html +105 -0
- karrio/server/providers/tests/__init__.py +5 -0
- karrio/server/providers/tests/test_connections.py +895 -0
- karrio/server/providers/views/carriers.py +1 -3
- karrio/server/providers/views/connections.py +322 -2
- karrio/server/serializers/abstract.py +112 -21
- karrio/server/tracing/utils.py +5 -8
- karrio/server/user/models.py +36 -34
- karrio/server/user/serializers.py +1 -0
- {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/METADATA +2 -2
- {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/RECORD +55 -38
- karrio/server/providers/tests.py +0 -3
- {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/WHEEL +0 -0
- {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/top_level.txt +0 -0
karrio/server/core/utils.py
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import sys
|
|
2
2
|
import typing
|
|
3
3
|
import inspect
|
|
4
|
-
import logging
|
|
5
4
|
import functools
|
|
6
|
-
from string import Template
|
|
7
5
|
from concurrent import futures
|
|
8
6
|
from datetime import timedelta, datetime
|
|
9
7
|
from typing import TypeVar, Union, Callable, Any, List, Optional
|
|
@@ -13,13 +11,13 @@ from django.utils.translation import gettext_lazy as _
|
|
|
13
11
|
import django_email_verification.confirm as confirm
|
|
14
12
|
import rest_framework_simplejwt.tokens as jwt
|
|
15
13
|
import rest_framework.status as status
|
|
14
|
+
from karrio.server.core.logging import logger
|
|
16
15
|
|
|
17
16
|
import karrio.lib as lib
|
|
18
17
|
from karrio.core.utils import DP, DF
|
|
19
18
|
from karrio.server.core import datatypes, serializers, exceptions
|
|
20
19
|
|
|
21
20
|
T = TypeVar("T")
|
|
22
|
-
logger = logging.getLogger(__name__)
|
|
23
21
|
|
|
24
22
|
|
|
25
23
|
def identity(value: Union[Any, Callable]) -> Any:
|
|
@@ -30,6 +28,103 @@ def identity(value: Union[Any, Callable]) -> Any:
|
|
|
30
28
|
return value() if callable(value) else value
|
|
31
29
|
|
|
32
30
|
|
|
31
|
+
def execute_gateway_operation(
|
|
32
|
+
operation_name: str,
|
|
33
|
+
callable: Callable[[], T],
|
|
34
|
+
carrier: Any = None,
|
|
35
|
+
context: Any = None,
|
|
36
|
+
) -> T:
|
|
37
|
+
"""Execute a gateway operation with telemetry instrumentation.
|
|
38
|
+
|
|
39
|
+
This function wraps SDK gateway calls (rates, shipments, tracking, etc.)
|
|
40
|
+
with telemetry spans for observability when Sentry is configured.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
operation_name: Name of the operation (e.g., "rates_fetch", "shipment_create")
|
|
44
|
+
callable: The callable that performs the SDK operation
|
|
45
|
+
carrier: Optional carrier instance for context
|
|
46
|
+
context: Optional request context for accessing the tracer
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The result of the callable
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
result = execute_gateway_operation(
|
|
53
|
+
"rates_fetch",
|
|
54
|
+
lambda: karrio.Rating.fetch(request).from_(carrier.gateway).parse(),
|
|
55
|
+
carrier=carrier,
|
|
56
|
+
context=context,
|
|
57
|
+
)
|
|
58
|
+
"""
|
|
59
|
+
tracer = _get_tracer_from_context(context)
|
|
60
|
+
|
|
61
|
+
# Build span attributes
|
|
62
|
+
attributes = {}
|
|
63
|
+
if carrier:
|
|
64
|
+
attributes["carrier_name"] = getattr(carrier, "carrier_code", None)
|
|
65
|
+
attributes["carrier_id"] = getattr(carrier, "carrier_id", None)
|
|
66
|
+
attributes["test_mode"] = getattr(carrier, "test_mode", None)
|
|
67
|
+
|
|
68
|
+
span_name = f"karrio_{operation_name}"
|
|
69
|
+
|
|
70
|
+
with tracer.start_span(span_name, attributes=attributes) as span:
|
|
71
|
+
try:
|
|
72
|
+
result = callable()
|
|
73
|
+
span.set_status("ok")
|
|
74
|
+
|
|
75
|
+
# Record success metric
|
|
76
|
+
tracer.record_metric(
|
|
77
|
+
f"karrio_{operation_name}_success",
|
|
78
|
+
1,
|
|
79
|
+
tags={
|
|
80
|
+
"carrier": attributes.get("carrier_name", "unknown"),
|
|
81
|
+
"test_mode": str(attributes.get("test_mode", False)).lower(),
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
except Exception as e:
|
|
88
|
+
span.set_status("error", str(e))
|
|
89
|
+
span.record_exception(e)
|
|
90
|
+
|
|
91
|
+
# Record failure metric
|
|
92
|
+
tracer.record_metric(
|
|
93
|
+
f"karrio_{operation_name}_error",
|
|
94
|
+
1,
|
|
95
|
+
tags={
|
|
96
|
+
"carrier": attributes.get("carrier_name", "unknown"),
|
|
97
|
+
"error_type": type(e).__name__,
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _get_tracer_from_context(context: Any) -> lib.Tracer:
|
|
105
|
+
"""Get the tracer from request context or return a default one."""
|
|
106
|
+
if context is None:
|
|
107
|
+
# Try to get from current request via middleware
|
|
108
|
+
try:
|
|
109
|
+
from karrio.server.core.middleware import SessionContext
|
|
110
|
+
|
|
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
|
+
|
|
33
128
|
def failsafe(callable: Callable[[], T], warning: str = None) -> T:
|
|
34
129
|
"""This higher order function wraps a callable in a try..except
|
|
35
130
|
scope to capture any exception raised.
|
|
@@ -40,7 +135,7 @@ def failsafe(callable: Callable[[], T], warning: str = None) -> T:
|
|
|
40
135
|
return callable()
|
|
41
136
|
except Exception as e:
|
|
42
137
|
if warning:
|
|
43
|
-
logger.warning(
|
|
138
|
+
logger.warning(warning, error=str(e))
|
|
44
139
|
return None
|
|
45
140
|
|
|
46
141
|
|
|
@@ -78,6 +173,85 @@ def async_wrapper(func):
|
|
|
78
173
|
return wrapper
|
|
79
174
|
|
|
80
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
|
+
|
|
195
|
+
def decorator(func):
|
|
196
|
+
@functools.wraps(func)
|
|
197
|
+
def wrapper(*args, **kwargs):
|
|
198
|
+
op_name = operation_name or func.__name__
|
|
199
|
+
|
|
200
|
+
# Extract carrier and context from kwargs if available
|
|
201
|
+
carrier = kwargs.get("carrier")
|
|
202
|
+
context = kwargs.get("context")
|
|
203
|
+
|
|
204
|
+
# Get tracer from context
|
|
205
|
+
tracer = _get_tracer_from_context(context)
|
|
206
|
+
|
|
207
|
+
# Build span attributes
|
|
208
|
+
attributes = {"operation": op_name}
|
|
209
|
+
if carrier:
|
|
210
|
+
attributes["carrier_name"] = getattr(carrier, "carrier_code", None)
|
|
211
|
+
attributes["carrier_id"] = getattr(carrier, "carrier_id", None)
|
|
212
|
+
attributes["test_mode"] = getattr(carrier, "test_mode", None)
|
|
213
|
+
|
|
214
|
+
span_name = f"karrio_{op_name}"
|
|
215
|
+
|
|
216
|
+
with tracer.start_span(span_name, attributes=attributes) as span:
|
|
217
|
+
try:
|
|
218
|
+
result = func(*args, **kwargs)
|
|
219
|
+
span.set_status("ok")
|
|
220
|
+
|
|
221
|
+
# Record success metric
|
|
222
|
+
tracer.record_metric(
|
|
223
|
+
f"karrio_{op_name}_success",
|
|
224
|
+
1,
|
|
225
|
+
tags={
|
|
226
|
+
"carrier": attributes.get("carrier_name", "unknown")
|
|
227
|
+
or "unknown",
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
except Exception as e:
|
|
234
|
+
span.set_status("error", str(e))
|
|
235
|
+
span.record_exception(e)
|
|
236
|
+
|
|
237
|
+
# Record error metric
|
|
238
|
+
tracer.record_metric(
|
|
239
|
+
f"karrio_{op_name}_error",
|
|
240
|
+
1,
|
|
241
|
+
tags={
|
|
242
|
+
"carrier": attributes.get("carrier_name", "unknown")
|
|
243
|
+
or "unknown",
|
|
244
|
+
"error_type": type(e).__name__,
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
raise
|
|
249
|
+
|
|
250
|
+
return wrapper
|
|
251
|
+
|
|
252
|
+
return decorator
|
|
253
|
+
|
|
254
|
+
|
|
81
255
|
def tenant_aware(func):
|
|
82
256
|
@functools.wraps(func)
|
|
83
257
|
def wrapper(*args, **kwargs):
|
|
@@ -200,6 +374,37 @@ def upper(value_str: Optional[str]) -> Optional[str]:
|
|
|
200
374
|
return value_str.upper().replace("_", " ")
|
|
201
375
|
|
|
202
376
|
|
|
377
|
+
def batch_get_constance_values(keys: List[str]) -> dict:
|
|
378
|
+
"""
|
|
379
|
+
Batch fetch multiple configuration values from Django Constance.
|
|
380
|
+
|
|
381
|
+
This function uses Constance's mget() method to fetch all requested
|
|
382
|
+
configuration keys in a single database query, avoiding N+1 query issues.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
keys: List of configuration key names to fetch
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
Dictionary mapping configuration keys to their values
|
|
389
|
+
|
|
390
|
+
Example:
|
|
391
|
+
>>> flags = batch_get_constance_values(['AUDIT_LOGGING', 'ALLOW_SIGNUP'])
|
|
392
|
+
>>> print(flags['AUDIT_LOGGING'])
|
|
393
|
+
True
|
|
394
|
+
"""
|
|
395
|
+
from constance import config
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
# Use mget to fetch all config values in a single query
|
|
399
|
+
# mget returns a generator of (key, value) tuples
|
|
400
|
+
return dict(config._backend.mget(keys))
|
|
401
|
+
except Exception as e:
|
|
402
|
+
logger.warning(
|
|
403
|
+
"Failed to batch fetch constance values, returning empty dict", error=str(e)
|
|
404
|
+
)
|
|
405
|
+
return {}
|
|
406
|
+
|
|
407
|
+
|
|
203
408
|
def compute_tracking_status(
|
|
204
409
|
details: Optional[datatypes.Tracking] = None,
|
|
205
410
|
) -> serializers.TrackerStatus:
|
|
@@ -222,7 +427,7 @@ def compute_tracking_status(
|
|
|
222
427
|
|
|
223
428
|
|
|
224
429
|
def is_sdk_message(
|
|
225
|
-
message: Optional[Union[datatypes.Message, List[datatypes.Message]]]
|
|
430
|
+
message: Optional[Union[datatypes.Message, List[datatypes.Message]]],
|
|
226
431
|
) -> bool:
|
|
227
432
|
msg = next(iter(message), None) if isinstance(message, list) else message
|
|
228
433
|
|
|
@@ -236,14 +441,15 @@ def filter_rate_carrier_compatible_gateways(
|
|
|
236
441
|
This function filters the carriers based on the capability to "rating"
|
|
237
442
|
and if no explicit carrier list is provided, it will filter out any
|
|
238
443
|
carrier that does not support the shipper's country code.
|
|
444
|
+
Carriers with no account_country_code set are always included.
|
|
239
445
|
"""
|
|
240
446
|
_gateways = [
|
|
241
447
|
carrier.gateway
|
|
242
448
|
for carrier in carriers
|
|
243
449
|
if (
|
|
244
|
-
# If
|
|
450
|
+
# If explicit carrier list is provided and gateway has "rating" capability.
|
|
245
451
|
("rating" in carrier.gateway.capabilities and len(carrier_ids) > 0)
|
|
246
|
-
# If
|
|
452
|
+
# If no carrier list is provided, and gateway is in the list.
|
|
247
453
|
or (
|
|
248
454
|
# the gateway has "rating" capability.
|
|
249
455
|
"rating" in carrier.gateway.capabilities
|
|
@@ -252,9 +458,10 @@ def filter_rate_carrier_compatible_gateways(
|
|
|
252
458
|
# and the shipper country code is provided.
|
|
253
459
|
and shipper_country_code is not None
|
|
254
460
|
and (
|
|
255
|
-
|
|
461
|
+
# Carriers with no account_country_code work across countries
|
|
462
|
+
not carrier.gateway.settings.account_country_code
|
|
463
|
+
or carrier.gateway.settings.account_country_code
|
|
256
464
|
== shipper_country_code
|
|
257
|
-
or carrier.gateway.settings.account_country_code is None
|
|
258
465
|
)
|
|
259
466
|
)
|
|
260
467
|
)
|
|
@@ -306,6 +513,259 @@ class ConfirmationToken(jwt.Token):
|
|
|
306
513
|
return token
|
|
307
514
|
|
|
308
515
|
|
|
516
|
+
class ResourceAccessToken(jwt.Token):
|
|
517
|
+
"""JWT token for limited resource access (documents, exports, etc.)."""
|
|
518
|
+
|
|
519
|
+
token_type = "resource_access"
|
|
520
|
+
lifetime = timedelta(minutes=5)
|
|
521
|
+
|
|
522
|
+
@classmethod
|
|
523
|
+
def for_resource(
|
|
524
|
+
cls,
|
|
525
|
+
user,
|
|
526
|
+
resource_type: str,
|
|
527
|
+
resource_ids: List[str],
|
|
528
|
+
access: List[str],
|
|
529
|
+
format: Optional[str] = None,
|
|
530
|
+
org_id: Optional[str] = None,
|
|
531
|
+
test_mode: Optional[bool] = None,
|
|
532
|
+
expires_in: Optional[int] = None,
|
|
533
|
+
) -> "ResourceAccessToken":
|
|
534
|
+
"""Generate a resource access token.
|
|
535
|
+
|
|
536
|
+
Args:
|
|
537
|
+
user: The authenticated user
|
|
538
|
+
resource_type: Type of resource (shipment, manifest, template, document)
|
|
539
|
+
resource_ids: List of resource IDs to grant access to
|
|
540
|
+
access: List of access permissions (label, invoice, manifest, render, etc.)
|
|
541
|
+
format: Document format (pdf, png, zpl)
|
|
542
|
+
org_id: Organization ID for multi-tenant environments
|
|
543
|
+
test_mode: Whether this is test mode
|
|
544
|
+
expires_in: Custom expiration time in seconds
|
|
545
|
+
|
|
546
|
+
Returns:
|
|
547
|
+
ResourceAccessToken instance
|
|
548
|
+
"""
|
|
549
|
+
token = cls()
|
|
550
|
+
token["user_id"] = user.id if hasattr(user, "id") else user
|
|
551
|
+
token["resource_type"] = resource_type
|
|
552
|
+
token["resource_ids"] = resource_ids
|
|
553
|
+
token["access"] = access
|
|
554
|
+
|
|
555
|
+
if format:
|
|
556
|
+
token["format"] = format
|
|
557
|
+
if org_id:
|
|
558
|
+
token["org_id"] = org_id
|
|
559
|
+
if test_mode is not None:
|
|
560
|
+
token["test_mode"] = test_mode
|
|
561
|
+
if expires_in:
|
|
562
|
+
token.set_exp(lifetime=timedelta(seconds=expires_in))
|
|
563
|
+
|
|
564
|
+
return token
|
|
565
|
+
|
|
566
|
+
@classmethod
|
|
567
|
+
def decode(cls, token_string: str) -> dict:
|
|
568
|
+
"""Decode and validate a resource access token.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
token_string: The JWT token string
|
|
572
|
+
|
|
573
|
+
Returns:
|
|
574
|
+
Dictionary with token claims
|
|
575
|
+
|
|
576
|
+
Raises:
|
|
577
|
+
rest_framework_simplejwt.exceptions.TokenError: If token is invalid
|
|
578
|
+
"""
|
|
579
|
+
token = cls(token_string)
|
|
580
|
+
return {
|
|
581
|
+
"user_id": token.get("user_id"),
|
|
582
|
+
"resource_type": token.get("resource_type"),
|
|
583
|
+
"resource_ids": token.get("resource_ids", []),
|
|
584
|
+
"access": token.get("access", []),
|
|
585
|
+
"format": token.get("format"),
|
|
586
|
+
"org_id": token.get("org_id"),
|
|
587
|
+
"test_mode": token.get("test_mode"),
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
@classmethod
|
|
591
|
+
def validate_access(
|
|
592
|
+
cls,
|
|
593
|
+
token_string: str,
|
|
594
|
+
resource_type: str,
|
|
595
|
+
resource_id: str,
|
|
596
|
+
access: str,
|
|
597
|
+
) -> dict:
|
|
598
|
+
"""Validate token grants access to specific resource and action.
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
token_string: The JWT token string
|
|
602
|
+
resource_type: Expected resource type
|
|
603
|
+
resource_id: Resource ID to check access for
|
|
604
|
+
access: Access permission to check
|
|
605
|
+
|
|
606
|
+
Returns:
|
|
607
|
+
Token claims if valid
|
|
608
|
+
|
|
609
|
+
Raises:
|
|
610
|
+
rest_framework_simplejwt.exceptions.TokenError: If token is invalid
|
|
611
|
+
PermissionError: If access is not granted
|
|
612
|
+
"""
|
|
613
|
+
claims = cls.decode(token_string)
|
|
614
|
+
|
|
615
|
+
if claims["resource_type"] != resource_type:
|
|
616
|
+
raise PermissionError(f"Token not valid for resource type: {resource_type}")
|
|
617
|
+
|
|
618
|
+
if resource_id not in claims["resource_ids"]:
|
|
619
|
+
raise PermissionError(f"Token not valid for resource: {resource_id}")
|
|
620
|
+
|
|
621
|
+
if access not in claims["access"]:
|
|
622
|
+
raise PermissionError(f"Token does not grant access: {access}")
|
|
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(f"Token not valid for resource type: {resource_type}")
|
|
653
|
+
|
|
654
|
+
if access not in claims["access"]:
|
|
655
|
+
raise PermissionError(f"Token does not grant access: {access}")
|
|
656
|
+
|
|
657
|
+
token_ids = set(claims["resource_ids"])
|
|
658
|
+
request_ids = set(resource_ids)
|
|
659
|
+
if not request_ids.issubset(token_ids):
|
|
660
|
+
missing = request_ids - token_ids
|
|
661
|
+
raise PermissionError(
|
|
662
|
+
f"Token does not grant access to resources: {', '.join(missing)}"
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
return claims
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def validate_resource_token(
|
|
669
|
+
request,
|
|
670
|
+
resource_type: str,
|
|
671
|
+
resource_ids: List[str],
|
|
672
|
+
access: str,
|
|
673
|
+
):
|
|
674
|
+
"""Validate resource access token, skipping if user is already authenticated.
|
|
675
|
+
|
|
676
|
+
If the request has an authenticated user (via API token, JWT, etc.),
|
|
677
|
+
the resource token check is skipped. Otherwise, it validates the
|
|
678
|
+
resource access token from the query parameter.
|
|
679
|
+
|
|
680
|
+
Note: For this to work with non-DRF views, use the `APITokenAuthMixin`
|
|
681
|
+
on your view class to run DRF authentication before this function is called.
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
request: The HTTP request object
|
|
685
|
+
resource_type: Expected resource type (shipment, manifest, template, etc.)
|
|
686
|
+
resource_ids: List of resource IDs to validate access for
|
|
687
|
+
access: Required access permission (label, invoice, manifest, render, etc.)
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
HttpResponseForbidden if validation fails, None if valid
|
|
691
|
+
|
|
692
|
+
Example:
|
|
693
|
+
error = validate_resource_token(request, "shipment", [pk], "label")
|
|
694
|
+
if error:
|
|
695
|
+
return error
|
|
696
|
+
"""
|
|
697
|
+
from django.http import HttpResponseForbidden
|
|
698
|
+
from django.contrib.auth.models import AnonymousUser
|
|
699
|
+
|
|
700
|
+
# Skip resource token check if user is already authenticated
|
|
701
|
+
if hasattr(request, "user") and request.user and not isinstance(request.user, AnonymousUser):
|
|
702
|
+
if request.user.is_authenticated:
|
|
703
|
+
return None
|
|
704
|
+
|
|
705
|
+
# Fall back to resource access token validation
|
|
706
|
+
token = request.GET.get("token")
|
|
707
|
+
|
|
708
|
+
if not token:
|
|
709
|
+
return HttpResponseForbidden(
|
|
710
|
+
"Access token required. Use /api/tokens to generate one, or provide API token in Authorization header."
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
ResourceAccessToken.validate_batch_access(
|
|
715
|
+
token_string=token,
|
|
716
|
+
resource_type=resource_type,
|
|
717
|
+
resource_ids=resource_ids,
|
|
718
|
+
access=access,
|
|
719
|
+
)
|
|
720
|
+
return None
|
|
721
|
+
except PermissionError as e:
|
|
722
|
+
return HttpResponseForbidden(
|
|
723
|
+
"You do not have permission to access these resources."
|
|
724
|
+
)
|
|
725
|
+
except Exception as e:
|
|
726
|
+
logger.warning("Invalid resource access token: %s", str(e))
|
|
727
|
+
return HttpResponseForbidden("Invalid or expired token.")
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def require_resource_token(
|
|
731
|
+
resource_type: str,
|
|
732
|
+
access: str,
|
|
733
|
+
get_resource_ids: Callable[..., List[str]],
|
|
734
|
+
):
|
|
735
|
+
"""Decorator for views requiring resource access token validation.
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
resource_type: Expected resource type (shipment, manifest, template, etc.)
|
|
739
|
+
access: Required access permission (label, invoice, manifest, render, etc.)
|
|
740
|
+
get_resource_ids: Callable that extracts resource IDs from request and view kwargs.
|
|
741
|
+
Receives (request, **kwargs), returns list of resource IDs.
|
|
742
|
+
|
|
743
|
+
Example:
|
|
744
|
+
@require_resource_token(
|
|
745
|
+
resource_type="document",
|
|
746
|
+
access="batch_labels",
|
|
747
|
+
get_resource_ids=lambda req, **kw: req.GET.get("shipments", "").split(","),
|
|
748
|
+
)
|
|
749
|
+
def get(self, request, **kwargs):
|
|
750
|
+
...
|
|
751
|
+
"""
|
|
752
|
+
|
|
753
|
+
def decorator(method):
|
|
754
|
+
@functools.wraps(method)
|
|
755
|
+
def wrapper(self, request, *args, **kwargs):
|
|
756
|
+
resource_ids = get_resource_ids(request, **kwargs)
|
|
757
|
+
error = validate_resource_token(
|
|
758
|
+
request, resource_type, resource_ids, access
|
|
759
|
+
)
|
|
760
|
+
if error:
|
|
761
|
+
return error
|
|
762
|
+
return method(self, request, *args, **kwargs)
|
|
763
|
+
|
|
764
|
+
return wrapper
|
|
765
|
+
|
|
766
|
+
return decorator
|
|
767
|
+
|
|
768
|
+
|
|
309
769
|
def app_tracking_query_params(url: str, carrier) -> str:
|
|
310
770
|
hub_flag = f"&hub={carrier.carrier_name}" if carrier.gateway.is_hub else ""
|
|
311
771
|
|
|
@@ -336,6 +796,24 @@ def get_carrier_tracking_link(carrier, tracking_number: str):
|
|
|
336
796
|
return tracking_url.format(tracking_number) if tracking_url is not None else None
|
|
337
797
|
|
|
338
798
|
|
|
799
|
+
def _ensure_picked_up_status(events: typing.List[dict]) -> typing.List[dict]:
|
|
800
|
+
"""Transform the chronologically first in_transit event to picked_up if none exists."""
|
|
801
|
+
if not events or any(e.get("status") == "picked_up" for e in events):
|
|
802
|
+
return events
|
|
803
|
+
|
|
804
|
+
# Events are sorted desc, so first in_transit chronologically is last in list
|
|
805
|
+
first_idx = next(
|
|
806
|
+
(i for i in range(len(events) - 1, -1, -1) if events[i].get("status") == "in_transit"),
|
|
807
|
+
None,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
return (
|
|
811
|
+
[{**e, "status": "picked_up"} if i == first_idx else e for i, e in enumerate(events)]
|
|
812
|
+
if first_idx is not None
|
|
813
|
+
else events
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
|
|
339
817
|
def process_events(
|
|
340
818
|
response_events: typing.List[datatypes.TrackingEvent],
|
|
341
819
|
current_events: typing.List[dict],
|
|
@@ -346,33 +824,173 @@ def process_events(
|
|
|
346
824
|
return current_events
|
|
347
825
|
|
|
348
826
|
new_events = lib.to_dict(response_events)
|
|
827
|
+
|
|
828
|
+
# If no current events, return new events as-is (already sorted by SDK)
|
|
349
829
|
if not any(current_events):
|
|
350
|
-
return
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
830
|
+
return new_events
|
|
831
|
+
|
|
832
|
+
# Merge events: add only new non-duplicate events to existing ones
|
|
833
|
+
current_hashes = {lib.to_json(event) for event in current_events}
|
|
834
|
+
unique_new_events = [
|
|
835
|
+
event for event in new_events if lib.to_json(event) not in current_hashes
|
|
836
|
+
]
|
|
837
|
+
|
|
838
|
+
# If no new unique events, return current events unchanged
|
|
839
|
+
if not any(unique_new_events):
|
|
840
|
+
return current_events
|
|
841
|
+
|
|
842
|
+
# When merging, we need to re-sort because new events may have timestamps
|
|
843
|
+
# that fall between existing events. We must parse datetimes properly
|
|
844
|
+
# (not use string comparison) to handle 12-hour AM/PM format correctly.
|
|
845
|
+
def try_parse_datetime(value: str, fmt: str) -> typing.Optional[datetime]:
|
|
846
|
+
"""Safely attempt to parse a datetime string with a given format."""
|
|
847
|
+
return failsafe(lambda: datetime.strptime(value, fmt))
|
|
848
|
+
|
|
849
|
+
def parse_date(event: dict) -> typing.Optional[datetime]:
|
|
850
|
+
"""Parse date from event using multiple format attempts."""
|
|
851
|
+
date_str = event.get("date", "")
|
|
852
|
+
date_formats = ["%Y-%m-%d", "%m/%d/%Y", "%-m/%d/%Y"]
|
|
853
|
+
return (
|
|
854
|
+
functools.reduce(
|
|
855
|
+
lambda acc, fmt: acc or try_parse_datetime(date_str, fmt),
|
|
856
|
+
date_formats,
|
|
857
|
+
None,
|
|
858
|
+
)
|
|
859
|
+
if date_str
|
|
860
|
+
else None
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
def parse_time(event: dict) -> typing.Optional[datetime.time]:
|
|
864
|
+
"""Parse time from event using multiple format attempts."""
|
|
865
|
+
time_str = event.get("time", "")
|
|
866
|
+
time_formats = ["%I:%M %p", "%H:%M:%S", "%H:%M", "%I:%M"]
|
|
867
|
+
parsed = (
|
|
868
|
+
functools.reduce(
|
|
869
|
+
lambda acc, fmt: acc or try_parse_datetime(time_str, fmt),
|
|
870
|
+
time_formats,
|
|
871
|
+
None,
|
|
872
|
+
)
|
|
873
|
+
if time_str
|
|
874
|
+
else None
|
|
875
|
+
)
|
|
876
|
+
return parsed.time() if parsed else None
|
|
877
|
+
|
|
878
|
+
def parse_event_datetime(event: dict) -> typing.Optional[datetime]:
|
|
879
|
+
"""Parse complete datetime from event date and time."""
|
|
880
|
+
parsed_date = parse_date(event)
|
|
881
|
+
parsed_time = parse_time(event) if parsed_date else None
|
|
882
|
+
return (
|
|
883
|
+
datetime.combine(parsed_date.date(), parsed_time)
|
|
884
|
+
if parsed_date and parsed_time
|
|
885
|
+
else parsed_date
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
def create_sort_key(event: dict) -> tuple:
|
|
889
|
+
"""Create sort key: dated events first (by datetime desc), undated last (by original order)."""
|
|
890
|
+
dt = parse_event_datetime(event)
|
|
891
|
+
return (0 if dt else 1, dt if dt else datetime.min)
|
|
892
|
+
|
|
893
|
+
# Merge and sort all events
|
|
894
|
+
merged_events = current_events + unique_new_events
|
|
895
|
+
sorted_events = sorted(merged_events, key=create_sort_key, reverse=True)
|
|
896
|
+
|
|
897
|
+
# Transform first in_transit to picked_up if no picked_up exists
|
|
898
|
+
# This provides a consistent pickup milestone across all carriers
|
|
899
|
+
return _ensure_picked_up_status(sorted_events)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _get_carrier_for_service(service: str, context=None) -> typing.Optional[str]:
|
|
903
|
+
"""Resolve carrier name from service code using karrio references."""
|
|
904
|
+
import karrio.server.core.dataunits as dataunits
|
|
905
|
+
|
|
906
|
+
services_map = dataunits.contextual_reference(context).get("services", {})
|
|
907
|
+
|
|
908
|
+
return next(
|
|
909
|
+
(
|
|
910
|
+
carrier_name
|
|
911
|
+
for carrier_name, services in services_map.items()
|
|
912
|
+
if service in services
|
|
913
|
+
),
|
|
914
|
+
None,
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def load_and_apply_shipping_method(
|
|
919
|
+
validated_data: dict,
|
|
920
|
+
shipping_method_id: str,
|
|
921
|
+
context: typing.Any,
|
|
922
|
+
) -> dict:
|
|
923
|
+
"""
|
|
924
|
+
Load a shipping method and apply its configuration to shipment data.
|
|
925
|
+
|
|
926
|
+
This function loads a ShippingMethod by ID and applies its configuration
|
|
927
|
+
to the validated_data dictionary using the shared apply_shipping_method_to_data
|
|
928
|
+
helper from the shipping module.
|
|
929
|
+
|
|
930
|
+
Args:
|
|
931
|
+
validated_data: The shipment data dictionary
|
|
932
|
+
shipping_method_id: The ID of the shipping method to load
|
|
933
|
+
context: The request context for access control
|
|
934
|
+
|
|
935
|
+
Returns:
|
|
936
|
+
dict: Modified validated_data with shipping method configuration applied
|
|
937
|
+
|
|
938
|
+
Raises:
|
|
939
|
+
APIException: If shipping method not found, inactive, or module not installed
|
|
940
|
+
"""
|
|
941
|
+
try:
|
|
942
|
+
from karrio.server.shipping.models import ShippingMethod
|
|
943
|
+
from karrio.server.shipping.serializers import apply_shipping_method_to_data
|
|
944
|
+
except ImportError:
|
|
945
|
+
raise exceptions.APIException(
|
|
946
|
+
"Shipping methods module is not installed.",
|
|
947
|
+
code="module_not_installed",
|
|
948
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
354
949
|
)
|
|
355
950
|
|
|
356
|
-
#
|
|
357
|
-
|
|
951
|
+
# Load the shipping method with access control
|
|
952
|
+
try:
|
|
953
|
+
method = ShippingMethod.access_by(context).get(
|
|
954
|
+
id=shipping_method_id,
|
|
955
|
+
is_active=True,
|
|
956
|
+
)
|
|
957
|
+
except ShippingMethod.DoesNotExist:
|
|
958
|
+
raise exceptions.APIException(
|
|
959
|
+
f"Shipping method '{shipping_method_id}' not found or inactive.",
|
|
960
|
+
code="shipping_method_not_found",
|
|
961
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
962
|
+
)
|
|
358
963
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if event_hash not in event_hashes:
|
|
362
|
-
event_hashes[event_hash] = event
|
|
964
|
+
# Apply shipping method configuration using shared helper
|
|
965
|
+
result_data = apply_shipping_method_to_data(validated_data, method, context)
|
|
363
966
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
967
|
+
logger.info(
|
|
968
|
+
"Applied shipping method to shipment",
|
|
969
|
+
shipping_method_id=method.id,
|
|
970
|
+
shipping_method_name=method.name,
|
|
971
|
+
carrier_service=method.carrier_service,
|
|
369
972
|
)
|
|
370
973
|
|
|
974
|
+
return result_data
|
|
975
|
+
|
|
371
976
|
|
|
372
977
|
def apply_rate_selection(payload: typing.Union[dict, typing.Any], **kwargs):
|
|
978
|
+
"""
|
|
979
|
+
Select the appropriate rate based on the following priority hierarchy:
|
|
980
|
+
|
|
981
|
+
1. selected_rate (already provided) - highest priority
|
|
982
|
+
2. rate_id or service - try to find matching rate
|
|
983
|
+
3. has_alternative_services fallback - try carrier-based fallback
|
|
984
|
+
4. apply_shipping_rules - FALLBACK when no rate found from above
|
|
985
|
+
|
|
986
|
+
Shipping rules act as a fallback when:
|
|
987
|
+
- No service/rate_id provided, OR
|
|
988
|
+
- service/rate_id provided but no matching rate found
|
|
989
|
+
"""
|
|
373
990
|
data = kwargs.get("data") or kwargs
|
|
374
991
|
get = lambda key, default=None: lib.identity(
|
|
375
|
-
payload.get(key, data.get(key, default))
|
|
992
|
+
payload.get(key, data.get(key, default))
|
|
993
|
+
if isinstance(payload, dict)
|
|
376
994
|
else getattr(payload, key, data.get(key, default))
|
|
377
995
|
)
|
|
378
996
|
|
|
@@ -393,14 +1011,47 @@ def apply_rate_selection(payload: typing.Union[dict, typing.Any], **kwargs):
|
|
|
393
1011
|
|
|
394
1012
|
# Select by id or service if provided
|
|
395
1013
|
if rate_id or service:
|
|
396
|
-
kwargs.update(
|
|
397
|
-
(
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
1014
|
+
kwargs.update(
|
|
1015
|
+
selected_rate=next(
|
|
1016
|
+
(
|
|
1017
|
+
rate
|
|
1018
|
+
for rate in rates
|
|
1019
|
+
if (rate_id and rate.get("id") == rate_id)
|
|
1020
|
+
or (service and rate.get("service") == service)
|
|
1021
|
+
),
|
|
1022
|
+
None,
|
|
1023
|
+
)
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
# has_alternative_services fallback when no exact match found
|
|
1027
|
+
has_alternative_services = options.get("has_alternative_services", False)
|
|
1028
|
+
|
|
1029
|
+
if kwargs.get("selected_rate") is None and has_alternative_services and service:
|
|
1030
|
+
carrier_name = _get_carrier_for_service(service, ctx)
|
|
1031
|
+
fallback_rate = lib.identity(
|
|
1032
|
+
next(
|
|
1033
|
+
(r for r in rates if r.get("carrier_name") == carrier_name),
|
|
1034
|
+
None,
|
|
1035
|
+
)
|
|
1036
|
+
if carrier_name
|
|
1037
|
+
else None
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
kwargs.update(
|
|
1041
|
+
selected_rate=lib.identity(
|
|
1042
|
+
{
|
|
1043
|
+
**fallback_rate,
|
|
1044
|
+
"service": service,
|
|
1045
|
+
"meta": {
|
|
1046
|
+
**(fallback_rate.get("meta") or {}),
|
|
1047
|
+
"has_alternative_services": True,
|
|
1048
|
+
},
|
|
1049
|
+
}
|
|
1050
|
+
if fallback_rate
|
|
1051
|
+
else None
|
|
1052
|
+
)
|
|
1053
|
+
)
|
|
1054
|
+
|
|
404
1055
|
return kwargs
|
|
405
1056
|
|
|
406
1057
|
# Apply shipping rules if enabled and no selected rate is provided
|
|
@@ -411,9 +1062,7 @@ def apply_rate_selection(payload: typing.Union[dict, typing.Any], **kwargs):
|
|
|
411
1062
|
|
|
412
1063
|
# Get active shipping rules
|
|
413
1064
|
active_rules = list(
|
|
414
|
-
automation_models.ShippingRule
|
|
415
|
-
.access_by(ctx)
|
|
416
|
-
.filter(is_active=True)
|
|
1065
|
+
automation_models.ShippingRule.access_by(ctx).filter(is_active=True)
|
|
417
1066
|
)
|
|
418
1067
|
|
|
419
1068
|
# Always run rule evaluation for activity tracking
|
|
@@ -421,6 +1070,7 @@ def apply_rate_selection(payload: typing.Union[dict, typing.Any], **kwargs):
|
|
|
421
1070
|
_, rule_selected_rate, rule_activity = engine.process_shipping_rules(
|
|
422
1071
|
shipment=payload,
|
|
423
1072
|
rules=active_rules,
|
|
1073
|
+
context=ctx,
|
|
424
1074
|
)
|
|
425
1075
|
|
|
426
1076
|
kwargs.update(
|
|
@@ -462,8 +1112,8 @@ def require_selected_rate(func):
|
|
|
462
1112
|
"meta": {
|
|
463
1113
|
**(result.meta or {}),
|
|
464
1114
|
**({"rule_activity": kwargs.get("rule_activity")}),
|
|
465
|
-
}
|
|
466
|
-
}
|
|
1115
|
+
},
|
|
1116
|
+
},
|
|
467
1117
|
)
|
|
468
1118
|
|
|
469
1119
|
if hasattr(result, "save") and kwargs.get("rule_activity"):
|