karrio-server 2025.5rc36__py3-none-any.whl → 2025.5.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/VERSION +1 -1
- karrio/server/settings/apm.py +311 -14
- karrio/server/urls/__init__.py +1 -0
- karrio/server/urls/tokens.py +237 -0
- {karrio_server-2025.5rc36.dist-info → karrio_server-2025.5.1.dist-info}/METADATA +1 -1
- {karrio_server-2025.5rc36.dist-info → karrio_server-2025.5.1.dist-info}/RECORD +9 -8
- {karrio_server-2025.5rc36.dist-info → karrio_server-2025.5.1.dist-info}/WHEEL +0 -0
- {karrio_server-2025.5rc36.dist-info → karrio_server-2025.5.1.dist-info}/entry_points.txt +0 -0
- {karrio_server-2025.5rc36.dist-info → karrio_server-2025.5.1.dist-info}/top_level.txt +0 -0
karrio/server/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2025.
|
|
1
|
+
2025.5.1
|
karrio/server/settings/apm.py
CHANGED
|
@@ -35,33 +35,192 @@ if POSTHOG_KEY:
|
|
|
35
35
|
posthog.host = POSTHOG_HOST
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
#
|
|
38
|
+
# =============================================================================
|
|
39
|
+
# Sentry Configuration
|
|
40
|
+
# =============================================================================
|
|
41
|
+
#
|
|
42
|
+
# Sentry provides error tracking, performance monitoring, and distributed
|
|
43
|
+
# tracing for Karrio. When SENTRY_DSN is configured, the following features
|
|
44
|
+
# are enabled:
|
|
45
|
+
#
|
|
46
|
+
# 1. Error Tracking: All unhandled exceptions are captured with full context
|
|
47
|
+
# 2. Performance Monitoring: API endpoints and gateway operations are traced
|
|
48
|
+
# 3. Distributed Tracing: Carrier API calls are linked to parent transactions
|
|
49
|
+
# 4. Metrics: Custom metrics for carrier operations (rates, shipments, etc.)
|
|
50
|
+
# 5. Logging Integration: ERROR/CRITICAL logs are sent to Sentry
|
|
51
|
+
#
|
|
52
|
+
# Environment Variables:
|
|
53
|
+
# SENTRY_DSN - Sentry Data Source Name (required to enable)
|
|
54
|
+
# SENTRY_ENVIRONMENT - Environment name (default: from ENV or "production")
|
|
55
|
+
# SENTRY_RELEASE - Release version (default: VERSION)
|
|
56
|
+
# SENTRY_TRACES_SAMPLE_RATE - Transaction sampling rate 0.0-1.0 (default: 1.0)
|
|
57
|
+
# SENTRY_PROFILES_SAMPLE_RATE - Profile sampling rate 0.0-1.0 (default: 1.0)
|
|
58
|
+
# SENTRY_SEND_PII - Send personally identifiable information (default: true)
|
|
59
|
+
# SENTRY_DEBUG - Enable Sentry debug mode (default: false)
|
|
60
|
+
#
|
|
61
|
+
# =============================================================================
|
|
62
|
+
|
|
39
63
|
sentry_sdk.utils.MAX_STRING_LENGTH = 4096
|
|
40
64
|
SENTRY_DSN = config("SENTRY_DSN", default=None)
|
|
65
|
+
SENTRY_ENVIRONMENT = config("SENTRY_ENVIRONMENT", default=config("ENV", default="production"))
|
|
66
|
+
SENTRY_RELEASE = config("SENTRY_RELEASE", default=config("VERSION", default=None))
|
|
67
|
+
SENTRY_TRACES_SAMPLE_RATE = config("SENTRY_TRACES_SAMPLE_RATE", default=1.0, cast=float)
|
|
68
|
+
SENTRY_PROFILES_SAMPLE_RATE = config("SENTRY_PROFILES_SAMPLE_RATE", default=1.0, cast=float)
|
|
69
|
+
SENTRY_SEND_PII = config("SENTRY_SEND_PII", default=True, cast=bool)
|
|
70
|
+
SENTRY_DEBUG = config("SENTRY_DEBUG", default=False, cast=bool)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _sentry_before_send(event, hint):
|
|
74
|
+
"""Pre-process events before sending to Sentry.
|
|
75
|
+
|
|
76
|
+
This hook allows us to:
|
|
77
|
+
- Scrub sensitive data (API keys, tokens, passwords)
|
|
78
|
+
- Add custom tags
|
|
79
|
+
- Filter out certain events
|
|
80
|
+
"""
|
|
81
|
+
# Scrub sensitive data from request bodies
|
|
82
|
+
if "request" in event:
|
|
83
|
+
request_data = event["request"]
|
|
84
|
+
|
|
85
|
+
# Scrub headers
|
|
86
|
+
if "headers" in request_data:
|
|
87
|
+
sensitive_headers = ["authorization", "x-api-key", "cookie", "x-csrf-token"]
|
|
88
|
+
for header in sensitive_headers:
|
|
89
|
+
if header in request_data["headers"]:
|
|
90
|
+
request_data["headers"][header] = "[Filtered]"
|
|
91
|
+
|
|
92
|
+
# Scrub POST data
|
|
93
|
+
if "data" in request_data and isinstance(request_data["data"], dict):
|
|
94
|
+
sensitive_fields = [
|
|
95
|
+
"password", "secret", "token", "api_key", "apikey",
|
|
96
|
+
"access_token", "refresh_token", "client_secret",
|
|
97
|
+
"account_number", "meter_number", "license_key",
|
|
98
|
+
]
|
|
99
|
+
for field in sensitive_fields:
|
|
100
|
+
for key in list(request_data["data"].keys()):
|
|
101
|
+
if field.lower() in key.lower():
|
|
102
|
+
request_data["data"][key] = "[Filtered]"
|
|
103
|
+
|
|
104
|
+
return event
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _sentry_before_send_transaction(event, hint):
|
|
108
|
+
"""Pre-process transactions before sending to Sentry.
|
|
109
|
+
|
|
110
|
+
This hook allows us to:
|
|
111
|
+
- Filter out noisy transactions (health checks, static files)
|
|
112
|
+
- Add custom tags
|
|
113
|
+
"""
|
|
114
|
+
transaction_name = event.get("transaction", "")
|
|
115
|
+
|
|
116
|
+
# Filter out health check and monitoring endpoints
|
|
117
|
+
noisy_endpoints = [
|
|
118
|
+
"/health",
|
|
119
|
+
"/ready",
|
|
120
|
+
"/live",
|
|
121
|
+
"/_health",
|
|
122
|
+
"/favicon.ico",
|
|
123
|
+
"/static/",
|
|
124
|
+
"/robots.txt",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
for endpoint in noisy_endpoints:
|
|
128
|
+
if transaction_name.startswith(endpoint):
|
|
129
|
+
return None # Drop this transaction
|
|
130
|
+
|
|
131
|
+
return event
|
|
132
|
+
|
|
41
133
|
|
|
42
134
|
if SENTRY_DSN:
|
|
43
135
|
# Build integrations list
|
|
44
|
-
integrations = [
|
|
136
|
+
integrations = [
|
|
137
|
+
DjangoIntegration(
|
|
138
|
+
transaction_style="url", # Use URL patterns for transaction names
|
|
139
|
+
middleware_spans=True, # Create spans for middleware
|
|
140
|
+
signals_spans=True, # Create spans for Django signals
|
|
141
|
+
),
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
# Add PostHog integration if available
|
|
45
145
|
if POSTHOG_KEY and PostHogIntegration is not None:
|
|
46
146
|
integrations.append(PostHogIntegration())
|
|
47
147
|
|
|
148
|
+
# Try to add Redis integration if Redis is configured
|
|
149
|
+
try:
|
|
150
|
+
from sentry_sdk.integrations.redis import RedisIntegration
|
|
151
|
+
if config("REDIS_URL", default=None) or config("REDIS_HOST", default=None):
|
|
152
|
+
integrations.append(RedisIntegration())
|
|
153
|
+
except ImportError:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
# Try to add Huey integration for background tasks
|
|
157
|
+
try:
|
|
158
|
+
from sentry_sdk.integrations.huey import HueyIntegration
|
|
159
|
+
integrations.append(HueyIntegration())
|
|
160
|
+
except ImportError:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
# Try to add httpx integration for async HTTP clients
|
|
164
|
+
try:
|
|
165
|
+
from sentry_sdk.integrations.httpx import HttpxIntegration
|
|
166
|
+
integrations.append(HttpxIntegration())
|
|
167
|
+
except Exception:
|
|
168
|
+
pass # httpx may not be installed
|
|
169
|
+
|
|
170
|
+
# Try to add Strawberry GraphQL integration
|
|
171
|
+
try:
|
|
172
|
+
from sentry_sdk.integrations.strawberry import StrawberryIntegration
|
|
173
|
+
integrations.append(StrawberryIntegration(async_execution=False))
|
|
174
|
+
except Exception:
|
|
175
|
+
pass # strawberry integration may not be available
|
|
176
|
+
|
|
48
177
|
sentry_sdk.init(
|
|
49
178
|
dsn=SENTRY_DSN,
|
|
50
179
|
integrations=integrations,
|
|
51
|
-
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
180
|
+
|
|
181
|
+
# Environment and release tracking
|
|
182
|
+
environment=SENTRY_ENVIRONMENT,
|
|
183
|
+
release=SENTRY_RELEASE,
|
|
184
|
+
|
|
185
|
+
# Performance monitoring
|
|
186
|
+
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
|
|
187
|
+
profile_session_sample_rate=SENTRY_PROFILES_SAMPLE_RATE,
|
|
59
188
|
profile_lifecycle="trace",
|
|
60
|
-
|
|
61
|
-
#
|
|
62
|
-
send_default_pii=
|
|
63
|
-
|
|
189
|
+
|
|
190
|
+
# Privacy settings
|
|
191
|
+
send_default_pii=SENTRY_SEND_PII,
|
|
192
|
+
|
|
193
|
+
# Logging integration
|
|
64
194
|
enable_logs=True,
|
|
195
|
+
|
|
196
|
+
# Debug mode
|
|
197
|
+
debug=SENTRY_DEBUG,
|
|
198
|
+
|
|
199
|
+
# Event processing hooks
|
|
200
|
+
before_send=_sentry_before_send,
|
|
201
|
+
before_send_transaction=_sentry_before_send_transaction,
|
|
202
|
+
|
|
203
|
+
# Additional options
|
|
204
|
+
max_breadcrumbs=50, # Keep last 50 breadcrumbs for context
|
|
205
|
+
attach_stacktrace=True, # Attach stack traces to messages
|
|
206
|
+
include_source_context=True, # Include source code in stack traces
|
|
207
|
+
include_local_variables=True, # Include local variables in stack traces
|
|
208
|
+
|
|
209
|
+
# Set custom tags
|
|
210
|
+
_experiments={
|
|
211
|
+
"record_sql_params": True, # Record SQL query parameters
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Set default tags that will be applied to all events
|
|
216
|
+
sentry_sdk.set_tag("service", "karrio-api")
|
|
217
|
+
sentry_sdk.set_tag("framework", "django")
|
|
218
|
+
|
|
219
|
+
import logging
|
|
220
|
+
logger = logging.getLogger(__name__)
|
|
221
|
+
logger.info(
|
|
222
|
+
f"Sentry initialized: env={SENTRY_ENVIRONMENT}, "
|
|
223
|
+
f"traces_sample_rate={SENTRY_TRACES_SAMPLE_RATE}"
|
|
65
224
|
)
|
|
66
225
|
|
|
67
226
|
|
|
@@ -191,3 +350,141 @@ if OTEL_ENABLED and OTEL_EXPORTER_OTLP_ENDPOINT:
|
|
|
191
350
|
# Log that OpenTelemetry is enabled
|
|
192
351
|
logger = logging.getLogger(__name__)
|
|
193
352
|
logger.info(f"OpenTelemetry enabled: Service={OTEL_SERVICE_NAME}, Endpoint={OTEL_EXPORTER_OTLP_ENDPOINT}")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# =============================================================================
|
|
356
|
+
# Datadog Configuration
|
|
357
|
+
# =============================================================================
|
|
358
|
+
#
|
|
359
|
+
# Datadog provides full-stack observability including APM, infrastructure
|
|
360
|
+
# monitoring, logs, and more. When DD_TRACE_ENABLED is set, the following
|
|
361
|
+
# features are enabled:
|
|
362
|
+
#
|
|
363
|
+
# 1. APM Tracing: Distributed tracing for all requests and background tasks
|
|
364
|
+
# 2. Metrics: Custom metrics via DogStatsD
|
|
365
|
+
# 3. Logs: Automatic trace ID injection into logs
|
|
366
|
+
# 4. Profiling: Continuous profiling (optional)
|
|
367
|
+
#
|
|
368
|
+
# Environment Variables:
|
|
369
|
+
# DD_TRACE_ENABLED - Enable Datadog tracing (default: false)
|
|
370
|
+
# DD_SERVICE - Service name (default: karrio-api)
|
|
371
|
+
# DD_ENV - Environment name (default: from ENV or "production")
|
|
372
|
+
# DD_VERSION - Application version (default: VERSION)
|
|
373
|
+
# DD_AGENT_HOST - Datadog agent host (default: localhost)
|
|
374
|
+
# DD_TRACE_AGENT_PORT - Datadog agent port (default: 8126)
|
|
375
|
+
# DD_DOGSTATSD_PORT - DogStatsD port for metrics (default: 8125)
|
|
376
|
+
# DD_TRACE_SAMPLE_RATE - Sampling rate 0.0-1.0 (default: 1.0)
|
|
377
|
+
# DD_PROFILING_ENABLED - Enable continuous profiling (default: false)
|
|
378
|
+
# DD_LOGS_INJECTION - Inject trace IDs into logs (default: true)
|
|
379
|
+
#
|
|
380
|
+
# =============================================================================
|
|
381
|
+
|
|
382
|
+
DD_TRACE_ENABLED = config("DD_TRACE_ENABLED", default=False, cast=bool)
|
|
383
|
+
DATADOG_ENABLED = config("DATADOG_ENABLED", default=False, cast=bool) # Alias
|
|
384
|
+
|
|
385
|
+
# Use either DD_TRACE_ENABLED or DATADOG_ENABLED
|
|
386
|
+
_datadog_enabled = DD_TRACE_ENABLED or DATADOG_ENABLED
|
|
387
|
+
|
|
388
|
+
if _datadog_enabled:
|
|
389
|
+
# Datadog configuration
|
|
390
|
+
DD_SERVICE = config("DD_SERVICE", default="karrio-api")
|
|
391
|
+
DD_ENV = config("DD_ENV", default=config("ENV", default="production"))
|
|
392
|
+
DD_VERSION = config("DD_VERSION", default=config("VERSION", default="unknown"))
|
|
393
|
+
DD_AGENT_HOST = config("DD_AGENT_HOST", default="localhost")
|
|
394
|
+
DD_TRACE_AGENT_PORT = config("DD_TRACE_AGENT_PORT", default=8126, cast=int)
|
|
395
|
+
DD_DOGSTATSD_PORT = config("DD_DOGSTATSD_PORT", default=8125, cast=int)
|
|
396
|
+
DD_TRACE_SAMPLE_RATE = config("DD_TRACE_SAMPLE_RATE", default=1.0, cast=float)
|
|
397
|
+
DD_PROFILING_ENABLED = config("DD_PROFILING_ENABLED", default=False, cast=bool)
|
|
398
|
+
DD_LOGS_INJECTION = config("DD_LOGS_INJECTION", default=True, cast=bool)
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
import ddtrace
|
|
402
|
+
from ddtrace import config as dd_config, tracer, patch_all
|
|
403
|
+
|
|
404
|
+
# Configure tracer
|
|
405
|
+
ddtrace.config.service = DD_SERVICE
|
|
406
|
+
ddtrace.config.env = DD_ENV
|
|
407
|
+
ddtrace.config.version = DD_VERSION
|
|
408
|
+
|
|
409
|
+
# Configure Django integration
|
|
410
|
+
dd_config.django["service_name"] = DD_SERVICE
|
|
411
|
+
dd_config.django["cache_service_name"] = f"{DD_SERVICE}-cache"
|
|
412
|
+
dd_config.django["database_service_name"] = f"{DD_SERVICE}-db"
|
|
413
|
+
dd_config.django["trace_query_string"] = True
|
|
414
|
+
dd_config.django["analytics_enabled"] = True
|
|
415
|
+
|
|
416
|
+
# Configure trace sampling
|
|
417
|
+
tracer.configure(
|
|
418
|
+
hostname=DD_AGENT_HOST,
|
|
419
|
+
port=DD_TRACE_AGENT_PORT,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Set global sample rate
|
|
423
|
+
from ddtrace.sampler import DatadogSampler
|
|
424
|
+
tracer.configure(sampler=DatadogSampler(default_sample_rate=DD_TRACE_SAMPLE_RATE))
|
|
425
|
+
|
|
426
|
+
# Enable log injection
|
|
427
|
+
if DD_LOGS_INJECTION:
|
|
428
|
+
ddtrace.patch(logging=True)
|
|
429
|
+
|
|
430
|
+
# Patch all supported libraries
|
|
431
|
+
patch_all(
|
|
432
|
+
django=True,
|
|
433
|
+
redis=True,
|
|
434
|
+
psycopg=True,
|
|
435
|
+
requests=True,
|
|
436
|
+
httpx=True,
|
|
437
|
+
logging=DD_LOGS_INJECTION,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# Patch Huey for background task tracing
|
|
441
|
+
try:
|
|
442
|
+
from ddtrace import patch
|
|
443
|
+
patch(huey=True)
|
|
444
|
+
except Exception:
|
|
445
|
+
pass # Huey integration may not be available in all ddtrace versions
|
|
446
|
+
|
|
447
|
+
# Enable profiling if configured
|
|
448
|
+
if DD_PROFILING_ENABLED:
|
|
449
|
+
try:
|
|
450
|
+
import ddtrace.profiling.auto # noqa: F401
|
|
451
|
+
except ImportError:
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
# Configure DogStatsD for metrics
|
|
455
|
+
try:
|
|
456
|
+
from datadog import initialize, statsd
|
|
457
|
+
|
|
458
|
+
initialize(
|
|
459
|
+
statsd_host=DD_AGENT_HOST,
|
|
460
|
+
statsd_port=DD_DOGSTATSD_PORT,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Set default tags for all metrics
|
|
464
|
+
statsd.constant_tags = [
|
|
465
|
+
f"service:{DD_SERVICE}",
|
|
466
|
+
f"env:{DD_ENV}",
|
|
467
|
+
f"version:{DD_VERSION}",
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
except ImportError:
|
|
471
|
+
pass # datadog package not installed, metrics won't work
|
|
472
|
+
|
|
473
|
+
import logging
|
|
474
|
+
logger = logging.getLogger(__name__)
|
|
475
|
+
logger.info(
|
|
476
|
+
f"Datadog APM initialized: service={DD_SERVICE}, env={DD_ENV}, "
|
|
477
|
+
f"agent={DD_AGENT_HOST}:{DD_TRACE_AGENT_PORT}"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
except ImportError:
|
|
481
|
+
import logging
|
|
482
|
+
logger = logging.getLogger(__name__)
|
|
483
|
+
logger.warning(
|
|
484
|
+
"Datadog tracing enabled but ddtrace package not installed. "
|
|
485
|
+
"Install with: pip install ddtrace"
|
|
486
|
+
)
|
|
487
|
+
except Exception as e:
|
|
488
|
+
import logging
|
|
489
|
+
logger = logging.getLogger(__name__)
|
|
490
|
+
logger.warning(f"Failed to initialize Datadog APM: {e}")
|
karrio/server/urls/__init__.py
CHANGED
|
@@ -46,6 +46,7 @@ urlpatterns = [
|
|
|
46
46
|
for (subpath, urls, namespace) in settings.NAMESPACED_URLS
|
|
47
47
|
],
|
|
48
48
|
path("", include("karrio.server.urls.jwt")),
|
|
49
|
+
path("", include("karrio.server.urls.tokens")),
|
|
49
50
|
path("", include("karrio.server.user.urls")),
|
|
50
51
|
*[path("", include(urls)) for urls in settings.KARRIO_URLS],
|
|
51
52
|
path("admin/", admin.site.urls, name="app_admin"),
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Generic Resource Access Token API.
|
|
2
|
+
|
|
3
|
+
This module provides a generic endpoint for generating limited-access tokens
|
|
4
|
+
for various resources (documents, exports, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from django.urls import path
|
|
9
|
+
from rest_framework import serializers, status
|
|
10
|
+
from rest_framework.views import APIView
|
|
11
|
+
from rest_framework.request import Request
|
|
12
|
+
from rest_framework.response import Response
|
|
13
|
+
from rest_framework.permissions import IsAuthenticated
|
|
14
|
+
|
|
15
|
+
import karrio.server.openapi as openapi
|
|
16
|
+
from karrio.server.core.utils import ResourceAccessToken
|
|
17
|
+
|
|
18
|
+
ENDPOINT_ID = "&&" # Unique endpoint ID for OpenAPI operation IDs
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Serializers
|
|
22
|
+
class ResourceTokenRequest(serializers.Serializer):
|
|
23
|
+
resource_type = serializers.ChoiceField(
|
|
24
|
+
choices=[
|
|
25
|
+
("shipment", "Shipment"),
|
|
26
|
+
("manifest", "Manifest"),
|
|
27
|
+
("order", "Order"),
|
|
28
|
+
("template", "Template"),
|
|
29
|
+
("document", "Document"),
|
|
30
|
+
],
|
|
31
|
+
help_text="The type of resource to grant access to.",
|
|
32
|
+
)
|
|
33
|
+
resource_ids = serializers.ListField(
|
|
34
|
+
child=serializers.CharField(),
|
|
35
|
+
min_length=1,
|
|
36
|
+
help_text="List of resource IDs to grant access to.",
|
|
37
|
+
)
|
|
38
|
+
access = serializers.ListField(
|
|
39
|
+
child=serializers.ChoiceField(
|
|
40
|
+
choices=[
|
|
41
|
+
("label", "Label"),
|
|
42
|
+
("invoice", "Invoice"),
|
|
43
|
+
("manifest", "Manifest"),
|
|
44
|
+
("render", "Render template"),
|
|
45
|
+
("batch_labels", "Batch labels"),
|
|
46
|
+
("batch_invoices", "Batch invoices"),
|
|
47
|
+
("batch_manifests", "Batch manifests"),
|
|
48
|
+
]
|
|
49
|
+
),
|
|
50
|
+
min_length=1,
|
|
51
|
+
help_text="List of access permissions to grant.",
|
|
52
|
+
)
|
|
53
|
+
format = serializers.ChoiceField(
|
|
54
|
+
choices=[
|
|
55
|
+
("pdf", "PDF"),
|
|
56
|
+
("png", "PNG"),
|
|
57
|
+
("zpl", "ZPL"),
|
|
58
|
+
("gif", "GIF"),
|
|
59
|
+
],
|
|
60
|
+
required=False,
|
|
61
|
+
allow_null=True,
|
|
62
|
+
help_text="Document format (optional).",
|
|
63
|
+
)
|
|
64
|
+
expires_in = serializers.IntegerField(
|
|
65
|
+
required=False,
|
|
66
|
+
min_value=60,
|
|
67
|
+
max_value=3600,
|
|
68
|
+
default=300,
|
|
69
|
+
help_text="Token expiration time in seconds (60-3600, default: 300).",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ResourceTokenResponse(serializers.Serializer):
|
|
74
|
+
token = serializers.CharField(help_text="The JWT access token.")
|
|
75
|
+
expires_at = serializers.DateTimeField(help_text="Token expiration timestamp.")
|
|
76
|
+
resource_urls = serializers.DictField(
|
|
77
|
+
child=serializers.CharField(),
|
|
78
|
+
help_text="Map of resource IDs to their access URLs with token.",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# URL builders for different resource types
|
|
83
|
+
def _build_shipment_urls(resource_ids: list, access: list, format_ext: str, token: str) -> dict:
|
|
84
|
+
access_type = access[0] if access else "label"
|
|
85
|
+
return {rid: f"/v1/shipments/{rid}/{access_type}.{format_ext}?token={token}" for rid in resource_ids}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _build_manifest_urls(resource_ids: list, format_ext: str, token: str) -> dict:
|
|
89
|
+
return {rid: f"/v1/manifests/{rid}/manifest.{format_ext}?token={token}" for rid in resource_ids}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _build_order_urls(resource_ids: list, access: list, format_ext: str, token: str) -> dict:
|
|
93
|
+
order_ids = ",".join(resource_ids)
|
|
94
|
+
access_type = access[0] if access else "batch_labels"
|
|
95
|
+
doc_type = "invoice" if access_type == "batch_invoices" else "label"
|
|
96
|
+
return {"batch": f"/documents/orders/{doc_type}.{format_ext}?orders={order_ids}&token={token}"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _build_template_urls(resource_ids: list, token: str) -> dict:
|
|
100
|
+
from karrio.server.documents.models import DocumentTemplate
|
|
101
|
+
|
|
102
|
+
# Query templates to get their slugs
|
|
103
|
+
templates = DocumentTemplate.objects.filter(pk__in=resource_ids).values("pk", "slug")
|
|
104
|
+
template_map = {t["pk"]: t["slug"] for t in templates}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
rid: f"/documents/templates/{rid}.{template_map.get(rid, 'doc')}?token={token}"
|
|
108
|
+
for rid in resource_ids
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _build_document_urls(resource_ids: list, access: list, format_ext: str, token: str) -> dict:
|
|
113
|
+
access_type = access[0] if access else "batch_labels"
|
|
114
|
+
ids = ",".join(resource_ids)
|
|
115
|
+
url_map = {
|
|
116
|
+
"batch_labels": f"/documents/shipments/label.{format_ext}?shipments={ids}&token={token}",
|
|
117
|
+
"batch_invoices": f"/documents/shipments/invoice.{format_ext}?shipments={ids}&token={token}",
|
|
118
|
+
"batch_manifests": f"/documents/manifests/manifest.{format_ext}?manifests={ids}&token={token}",
|
|
119
|
+
}
|
|
120
|
+
return {"batch": url_map.get(access_type, url_map["batch_labels"])}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_resource_urls(
|
|
124
|
+
resource_type: str,
|
|
125
|
+
resource_ids: list,
|
|
126
|
+
access: list,
|
|
127
|
+
format: str,
|
|
128
|
+
token: str,
|
|
129
|
+
) -> dict:
|
|
130
|
+
"""Build resource URLs with token for each resource ID."""
|
|
131
|
+
format_ext = (format or "pdf").lower()
|
|
132
|
+
|
|
133
|
+
builders = {
|
|
134
|
+
"shipment": lambda: _build_shipment_urls(resource_ids, access, format_ext, token),
|
|
135
|
+
"manifest": lambda: _build_manifest_urls(resource_ids, format_ext, token),
|
|
136
|
+
"order": lambda: _build_order_urls(resource_ids, access, format_ext, token),
|
|
137
|
+
"template": lambda: _build_template_urls(resource_ids, token),
|
|
138
|
+
"document": lambda: _build_document_urls(resource_ids, access, format_ext, token),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return builders.get(resource_type, lambda: {})()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ResourceTokenView(APIView):
|
|
146
|
+
"""Generate limited-access tokens for resources."""
|
|
147
|
+
|
|
148
|
+
permission_classes = [IsAuthenticated]
|
|
149
|
+
|
|
150
|
+
@openapi.extend_schema(
|
|
151
|
+
tags=["Auth"],
|
|
152
|
+
operation_id=f"{ENDPOINT_ID}generate_resource_token",
|
|
153
|
+
summary="Generate resource access token",
|
|
154
|
+
description="""
|
|
155
|
+
Generate a short-lived JWT token for accessing specific resources.
|
|
156
|
+
|
|
157
|
+
This endpoint is used to create secure, time-limited access tokens for
|
|
158
|
+
resources like shipment labels, manifests, and document templates.
|
|
159
|
+
|
|
160
|
+
**Use cases:**
|
|
161
|
+
- Generate a token to allow document preview in a new browser window
|
|
162
|
+
- Create shareable links for documents with automatic expiration
|
|
163
|
+
- Enable secure document downloads without exposing API keys
|
|
164
|
+
|
|
165
|
+
**Token lifetime:** Default 5 minutes, configurable up to 1 hour.
|
|
166
|
+
""",
|
|
167
|
+
request=ResourceTokenRequest,
|
|
168
|
+
responses={
|
|
169
|
+
201: ResourceTokenResponse,
|
|
170
|
+
400: openapi.OpenApiTypes.OBJECT,
|
|
171
|
+
401: openapi.OpenApiTypes.OBJECT,
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
def post(self, request: Request) -> Response:
|
|
175
|
+
serializer = ResourceTokenRequest(data=request.data)
|
|
176
|
+
serializer.is_valid(raise_exception=True)
|
|
177
|
+
|
|
178
|
+
data = serializer.validated_data
|
|
179
|
+
resource_type = data["resource_type"]
|
|
180
|
+
resource_ids = data["resource_ids"]
|
|
181
|
+
access = data["access"]
|
|
182
|
+
format = data.get("format")
|
|
183
|
+
expires_in = data.get("expires_in", 300)
|
|
184
|
+
|
|
185
|
+
# Get org_id for multi-tenant environments
|
|
186
|
+
org_id = None
|
|
187
|
+
if hasattr(request, "org") and request.org:
|
|
188
|
+
org_id = request.org.id
|
|
189
|
+
|
|
190
|
+
# Get test_mode from request context
|
|
191
|
+
test_mode = getattr(request, "test_mode", None)
|
|
192
|
+
|
|
193
|
+
# Generate the token
|
|
194
|
+
token = ResourceAccessToken.for_resource(
|
|
195
|
+
user=request.user,
|
|
196
|
+
resource_type=resource_type,
|
|
197
|
+
resource_ids=resource_ids,
|
|
198
|
+
access=access,
|
|
199
|
+
format=format,
|
|
200
|
+
org_id=org_id,
|
|
201
|
+
test_mode=test_mode,
|
|
202
|
+
expires_in=expires_in,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
token_string = str(token)
|
|
206
|
+
|
|
207
|
+
# Calculate expiration time
|
|
208
|
+
expires_at = datetime.fromtimestamp(token["exp"], tz=timezone.utc)
|
|
209
|
+
|
|
210
|
+
# Build resource URLs
|
|
211
|
+
resource_urls = build_resource_urls(
|
|
212
|
+
resource_type=resource_type,
|
|
213
|
+
resource_ids=resource_ids,
|
|
214
|
+
access=access,
|
|
215
|
+
format=format,
|
|
216
|
+
token=token_string,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
response = Response(
|
|
220
|
+
{
|
|
221
|
+
"token": token_string,
|
|
222
|
+
"expires_at": expires_at.isoformat(),
|
|
223
|
+
"resource_urls": resource_urls,
|
|
224
|
+
},
|
|
225
|
+
status=status.HTTP_201_CREATED,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Prevent caching of tokens
|
|
229
|
+
response["Cache-Control"] = "no-store"
|
|
230
|
+
response["CDN-Cache-Control"] = "no-store"
|
|
231
|
+
|
|
232
|
+
return response
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
urlpatterns = [
|
|
236
|
+
path("api/tokens", ResourceTokenView.as_view(), name="resource-tokens"),
|
|
237
|
+
]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
karrio/server/VERSION,sha256=
|
|
1
|
+
karrio/server/VERSION,sha256=tGn2vY99q8a82QkInZS50BCOgMKISav_zxLuzLB71_A,8
|
|
2
2
|
karrio/server/__init__.py,sha256=iOEMwnlORWezdO8-2vxBIPSR37D7JGjluZ8f55vzxls,81
|
|
3
3
|
karrio/server/__main__.py,sha256=hy2-Zb2wSVe_Pu6zWZ-BhiM4rBaGZ3D16HSuhudygqg,632
|
|
4
4
|
karrio/server/asgi.py,sha256=LsZYMWo8U9zURVPdHnvUsziOhMjdCdQoD2-gMJbS2U0,462
|
|
@@ -6,7 +6,7 @@ karrio/server/workers.py,sha256=wOlWmXC7zRJV86IfbQnfUZsnCIvvWtXzFHH_EIkg1J0,179
|
|
|
6
6
|
karrio/server/wsgi.py,sha256=SpWqkEYlMsj89_znZ8p8IjH3EgTVRWRq_9eS8t64dMw,403
|
|
7
7
|
karrio/server/lib/otel_huey.py,sha256=6MP6vX6b6x6RPF2K1m8B8L8S9GK1Q3vANmLidsxh65k,5428
|
|
8
8
|
karrio/server/settings/__init__.py,sha256=iw-NBcReOnDYpnvSEBdYDfV7jC0040jYdupnmSdElec,866
|
|
9
|
-
karrio/server/settings/apm.py,sha256=
|
|
9
|
+
karrio/server/settings/apm.py,sha256=mcQqchavxkbDTWYqUwaiJLSd8JD3cWx5c-Nr8iOeFN8,18916
|
|
10
10
|
karrio/server/settings/base.py,sha256=ZD0bd949IpHdFkI5WSuQ2W8QIoRZx11DHwdVwUqVlMM,22931
|
|
11
11
|
karrio/server/settings/cache.py,sha256=0o6XLXjtRw3MU2iXQh7Ge9RDXyekIQcXy3Fp54in4Zo,3923
|
|
12
12
|
karrio/server/settings/constance.py,sha256=IaSk5XmJzFBkvN2XzkZBaLcD97QDF1oO7231y58xoHY,7783
|
|
@@ -72,10 +72,11 @@ karrio/server/static/karrio/js/karrio.js,sha256=wuM0bdyAsFGMPPZjrbP6EDChdxLEK-nE
|
|
|
72
72
|
karrio/server/static/karrio/js/karrio.js.map,sha256=dlVHo5i-7vPIMdNXuiwg51WsamHsksoHJa8iWKkfWrs,328313
|
|
73
73
|
karrio/server/templates/admin/base_site.html,sha256=kbcdvehXZ1EHaw07JL7fSZmjrnVMKsnydjbTKa7Ondg,466
|
|
74
74
|
karrio/server/templates/openapi/openapi.html,sha256=3ApCZ5pE6Wjv7CJllVbqD2WiDQuLy-BFS-IIHurXhBY,1133
|
|
75
|
-
karrio/server/urls/__init__.py,sha256=
|
|
75
|
+
karrio/server/urls/__init__.py,sha256=SHmmXmVn9Hbf7eZ2wcL10hG84yQPBrcVaCsvi5cdrrA,2062
|
|
76
76
|
karrio/server/urls/jwt.py,sha256=QN2L-EpUEQCF2UGYPu_VVlA49Fc0BtcY7Ov3-xpp7_U,6772
|
|
77
|
-
|
|
78
|
-
karrio_server-2025.
|
|
79
|
-
karrio_server-2025.
|
|
80
|
-
karrio_server-2025.
|
|
81
|
-
karrio_server-2025.
|
|
77
|
+
karrio/server/urls/tokens.py,sha256=5slk9F0HPgV3dRnR9UipAl-R8837eiGqKJ31QnCdLnU,8215
|
|
78
|
+
karrio_server-2025.5.1.dist-info/METADATA,sha256=vzpN8lPECnPqc16li5kgjxg1CYnZDcJdCXwBkANc1jg,4303
|
|
79
|
+
karrio_server-2025.5.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
80
|
+
karrio_server-2025.5.1.dist-info/entry_points.txt,sha256=c2eftt6MpJjyp0OFv1OmO9nUYSDemt9fGq_RDdvpGLw,55
|
|
81
|
+
karrio_server-2025.5.1.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
|
|
82
|
+
karrio_server-2025.5.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|