karrio-server-core 2025.5rc12__py3-none-any.whl → 2026.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. karrio/server/core/authentication.py +59 -25
  2. karrio/server/core/config.py +31 -0
  3. karrio/server/core/datatypes.py +30 -4
  4. karrio/server/core/dataunits.py +53 -22
  5. karrio/server/core/exceptions.py +287 -17
  6. karrio/server/core/filters.py +14 -0
  7. karrio/server/core/gateway.py +285 -10
  8. karrio/server/core/logging.py +403 -0
  9. karrio/server/core/management/commands/runserver.py +5 -0
  10. karrio/server/core/middleware.py +104 -2
  11. karrio/server/core/migrations/0006_add_api_log_requested_at_index.py +22 -0
  12. karrio/server/core/models/base.py +34 -1
  13. karrio/server/core/oauth_validators.py +2 -3
  14. karrio/server/core/permissions.py +1 -2
  15. karrio/server/core/serializers.py +183 -10
  16. karrio/server/core/signals.py +22 -28
  17. karrio/server/core/telemetry.py +573 -0
  18. karrio/server/core/tests/__init__.py +27 -0
  19. karrio/server/core/{tests.py → tests/base.py} +6 -7
  20. karrio/server/core/tests/test_exception_level.py +159 -0
  21. karrio/server/core/tests/test_resource_token.py +593 -0
  22. karrio/server/core/utils.py +688 -38
  23. karrio/server/core/validators.py +144 -222
  24. karrio/server/core/views/oauth.py +13 -12
  25. karrio/server/core/views/references.py +2 -2
  26. karrio/server/iam/apps.py +1 -4
  27. karrio/server/iam/migrations/0002_setup_carrier_permission_groups.py +103 -0
  28. karrio/server/iam/migrations/0003_remove_permission_groups.py +91 -0
  29. karrio/server/iam/permissions.py +7 -134
  30. karrio/server/iam/serializers.py +17 -2
  31. karrio/server/iam/signals.py +2 -4
  32. karrio/server/providers/admin.py +1 -1
  33. karrio/server/providers/management/commands/migrate_rate_sheets.py +101 -0
  34. karrio/server/providers/migrations/0082_add_zone_identifiers.py +50 -0
  35. karrio/server/providers/migrations/0083_add_optimized_rate_sheet_structure.py +33 -0
  36. karrio/server/providers/migrations/0084_alter_servicelevel_currency.py +168 -0
  37. karrio/server/providers/migrations/0085_populate_dhl_parcel_de_oauth_credentials.py +82 -0
  38. karrio/server/providers/migrations/0086_rename_dhl_parcel_de_customer_number_to_billing_number.py +71 -0
  39. karrio/server/providers/migrations/0087_alter_carrier_capabilities.py +38 -0
  40. karrio/server/providers/migrations/0088_servicelevel_surcharges.py +24 -0
  41. karrio/server/providers/migrations/0089_servicelevel_cost_max_volume.py +31 -0
  42. karrio/server/providers/migrations/0090_ratesheet_surcharges_servicelevel_zone_surcharge_ids.py +47 -0
  43. karrio/server/providers/migrations/0091_migrate_legacy_zones_surcharges.py +154 -0
  44. karrio/server/providers/models/__init__.py +1 -2
  45. karrio/server/providers/models/carrier.py +103 -18
  46. karrio/server/providers/models/service.py +188 -1
  47. karrio/server/providers/models/sheet.py +371 -0
  48. karrio/server/providers/serializers/base.py +263 -2
  49. karrio/server/providers/signals.py +2 -4
  50. karrio/server/providers/templates/providers/oauth_callback.html +105 -0
  51. karrio/server/providers/tests/__init__.py +5 -0
  52. karrio/server/providers/tests/test_connections.py +895 -0
  53. karrio/server/providers/views/carriers.py +1 -3
  54. karrio/server/providers/views/connections.py +322 -2
  55. karrio/server/samples.py +1 -1
  56. karrio/server/serializers/abstract.py +116 -21
  57. karrio/server/tracing/migrations/0007_tracingrecord_tracing_created_at_idx.py +19 -0
  58. karrio/server/tracing/models.py +2 -0
  59. karrio/server/tracing/utils.py +5 -8
  60. karrio/server/user/migrations/0007_user_metadata.py +25 -0
  61. karrio/server/user/models.py +38 -23
  62. karrio/server/user/serializers.py +1 -0
  63. karrio/server/user/templates/registration/registration_confirm_email.html +1 -1
  64. {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/METADATA +2 -2
  65. {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/RECORD +67 -86
  66. karrio/server/providers/extension/__init__.py +0 -1
  67. karrio/server/providers/extension/models/__init__.py +0 -1
  68. karrio/server/providers/extension/models/allied_express.py +0 -22
  69. karrio/server/providers/extension/models/allied_express_local.py +0 -22
  70. karrio/server/providers/extension/models/amazon_shipping.py +0 -27
  71. karrio/server/providers/extension/models/aramex.py +0 -25
  72. karrio/server/providers/extension/models/asendia_us.py +0 -21
  73. karrio/server/providers/extension/models/australiapost.py +0 -20
  74. karrio/server/providers/extension/models/boxknight.py +0 -19
  75. karrio/server/providers/extension/models/bpost.py +0 -21
  76. karrio/server/providers/extension/models/canadapost.py +0 -21
  77. karrio/server/providers/extension/models/canpar.py +0 -19
  78. karrio/server/providers/extension/models/chronopost.py +0 -22
  79. karrio/server/providers/extension/models/colissimo.py +0 -22
  80. karrio/server/providers/extension/models/dhl_express.py +0 -23
  81. karrio/server/providers/extension/models/dhl_parcel_de.py +0 -25
  82. karrio/server/providers/extension/models/dhl_poland.py +0 -22
  83. karrio/server/providers/extension/models/dhl_universal.py +0 -19
  84. karrio/server/providers/extension/models/dicom.py +0 -20
  85. karrio/server/providers/extension/models/dpd.py +0 -37
  86. karrio/server/providers/extension/models/dpdhl.py +0 -26
  87. karrio/server/providers/extension/models/easypost.py +0 -20
  88. karrio/server/providers/extension/models/eshipper.py +0 -21
  89. karrio/server/providers/extension/models/fedex.py +0 -25
  90. karrio/server/providers/extension/models/fedex_ws.py +0 -24
  91. karrio/server/providers/extension/models/freightcom.py +0 -21
  92. karrio/server/providers/extension/models/generic.py +0 -35
  93. karrio/server/providers/extension/models/geodis.py +0 -22
  94. karrio/server/providers/extension/models/hay_post.py +0 -22
  95. karrio/server/providers/extension/models/laposte.py +0 -19
  96. karrio/server/providers/extension/models/locate2u.py +0 -22
  97. karrio/server/providers/extension/models/nationex.py +0 -22
  98. karrio/server/providers/extension/models/purolator.py +0 -21
  99. karrio/server/providers/extension/models/roadie.py +0 -18
  100. karrio/server/providers/extension/models/royalmail.py +0 -19
  101. karrio/server/providers/extension/models/sendle.py +0 -22
  102. karrio/server/providers/extension/models/tge.py +0 -63
  103. karrio/server/providers/extension/models/tnt.py +0 -23
  104. karrio/server/providers/extension/models/ups.py +0 -23
  105. karrio/server/providers/extension/models/usps.py +0 -23
  106. karrio/server/providers/extension/models/usps_international.py +0 -23
  107. karrio/server/providers/extension/models/usps_wt.py +0 -24
  108. karrio/server/providers/extension/models/usps_wt_international.py +0 -24
  109. karrio/server/providers/extension/models/zoom2u.py +0 -23
  110. karrio/server/providers/tests.py +0 -3
  111. {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/WHEEL +0 -0
  112. {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/top_level.txt +0 -0
@@ -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(Template(warning).substitute(error=e))
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 no carrier list is provided, and gateway has "rating" capability.
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 a carrier list is provided, and gateway is in the list.
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
- carrier.gateway.settings.account_country_code
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 sorted(
351
- new_events,
352
- key=lambda e: f"{e.get('date', '')} {e.get('time', '')}",
353
- reverse=True,
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
- # Create hash for comparison using lib.to_json
357
- event_hashes = {lib.to_json(event): event for event in current_events}
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
- for event in new_events:
360
- event_hash = lib.to_json(event)
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
- # Sort events by date and time in descending order (latest first)
365
- return sorted(
366
- event_hashes.values(),
367
- key=lambda e: f"{e.get('date', '')} {e.get('time', '')}",
368
- reverse=True,
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)) if isinstance(payload, dict)
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(selected_rate=next(
397
- (
398
- rate for rate in rates
399
- if (rate_id and rate.get("id") == rate_id)
400
- or (service and rate.get("service") == service)
401
- ),
402
- None,
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"):