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
|
@@ -8,6 +8,13 @@ import karrio.server.core.datatypes as datatypes
|
|
|
8
8
|
|
|
9
9
|
@core.register_model
|
|
10
10
|
class ServiceLevel(core.OwnedEntity):
|
|
11
|
+
"""
|
|
12
|
+
Service level definition for rate sheet-based shipping.
|
|
13
|
+
|
|
14
|
+
Services reference shared zones and surcharges defined at the RateSheet level
|
|
15
|
+
via zone_ids and surcharge_ids. Rate values are stored in RateSheet.service_rates.
|
|
16
|
+
"""
|
|
17
|
+
|
|
11
18
|
class Meta:
|
|
12
19
|
db_table = "service-level"
|
|
13
20
|
verbose_name = "Service Level"
|
|
@@ -51,7 +58,41 @@ class ServiceLevel(core.OwnedEntity):
|
|
|
51
58
|
domicile = models.BooleanField(null=True)
|
|
52
59
|
international = models.BooleanField(null=True)
|
|
53
60
|
|
|
54
|
-
|
|
61
|
+
# ─────────────────────────────────────────────────────────────────
|
|
62
|
+
# VOLUMETRIC WEIGHT
|
|
63
|
+
# ─────────────────────────────────────────────────────────────────
|
|
64
|
+
max_volume = models.FloatField(
|
|
65
|
+
blank=True,
|
|
66
|
+
null=True,
|
|
67
|
+
help_text="Maximum volume in liters for volumetric weight calculation",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# ─────────────────────────────────────────────────────────────────
|
|
71
|
+
# COST TRACKING (internal - not shown to customer)
|
|
72
|
+
# ─────────────────────────────────────────────────────────────────
|
|
73
|
+
cost = models.FloatField(
|
|
74
|
+
blank=True,
|
|
75
|
+
null=True,
|
|
76
|
+
help_text="Base COGS (Cost of Goods Sold) - internal cost tracking",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# ─────────────────────────────────────────────────────────────────
|
|
80
|
+
# ZONE & SURCHARGE REFERENCES
|
|
81
|
+
# These reference shared definitions at the RateSheet level
|
|
82
|
+
# ─────────────────────────────────────────────────────────────────
|
|
83
|
+
zone_ids = models.JSONField(
|
|
84
|
+
blank=True,
|
|
85
|
+
null=True,
|
|
86
|
+
default=core.field_default([]),
|
|
87
|
+
help_text="List of zone IDs this service applies to: ['zone_1', 'zone_2']",
|
|
88
|
+
)
|
|
89
|
+
surcharge_ids = models.JSONField(
|
|
90
|
+
blank=True,
|
|
91
|
+
null=True,
|
|
92
|
+
default=core.field_default([]),
|
|
93
|
+
help_text="List of surcharge IDs to apply: ['surch_fuel', 'surch_residential']",
|
|
94
|
+
)
|
|
95
|
+
|
|
55
96
|
metadata = models.JSONField(blank=True, null=True, default=core.field_default({}))
|
|
56
97
|
|
|
57
98
|
def __str__(self):
|
|
@@ -60,133 +101,149 @@ class ServiceLevel(core.OwnedEntity):
|
|
|
60
101
|
@property
|
|
61
102
|
def object_type(self):
|
|
62
103
|
return "service_level"
|
|
63
|
-
|
|
64
|
-
@property
|
|
65
|
-
def
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def rate_sheet(self):
|
|
107
|
+
"""Get the rate sheet this service belongs to."""
|
|
108
|
+
return self.service_sheet.first()
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def zones(self):
|
|
112
|
+
"""
|
|
113
|
+
Get zones as ServiceZone objects for SDK compatibility.
|
|
114
|
+
|
|
115
|
+
Transforms zone_ids + rate_sheet data into the ServiceZone format
|
|
116
|
+
expected by the RatingMixinProxy. Each service_rate entry becomes
|
|
117
|
+
a separate ServiceZone (supporting multiple weight brackets per zone).
|
|
66
118
|
"""
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
119
|
+
_rate_sheet = self.rate_sheet
|
|
120
|
+
if not _rate_sheet:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
zones_by_id = {z.get("id"): z for z in (_rate_sheet.zones or [])}
|
|
124
|
+
|
|
125
|
+
# Get all service_rates for this service (may have multiple per zone_id for weight brackets)
|
|
126
|
+
service_rates = [
|
|
127
|
+
sr for sr in (_rate_sheet.service_rates or [])
|
|
128
|
+
if sr.get("service_id") == self.id
|
|
129
|
+
and sr.get("zone_id") in (self.zone_ids or [])
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
result = []
|
|
133
|
+
for rate_data in service_rates:
|
|
134
|
+
zone_id = rate_data.get("zone_id")
|
|
135
|
+
zone_def = zones_by_id.get(zone_id)
|
|
136
|
+
|
|
137
|
+
if not zone_def:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Build ServiceZone-compatible dict (one per service_rate entry)
|
|
141
|
+
result.append({
|
|
142
|
+
"id": zone_id,
|
|
143
|
+
"label": zone_def.get("label"),
|
|
144
|
+
"rate": rate_data.get("rate"),
|
|
145
|
+
"cost": rate_data.get("cost"),
|
|
146
|
+
"min_weight": rate_data.get("min_weight"),
|
|
147
|
+
"max_weight": rate_data.get("max_weight"),
|
|
148
|
+
"transit_days": rate_data.get("transit_days") or zone_def.get("transit_days"),
|
|
149
|
+
"transit_time": rate_data.get("transit_time") or zone_def.get("transit_time"),
|
|
150
|
+
"country_codes": zone_def.get("country_codes") or [],
|
|
151
|
+
"postal_codes": zone_def.get("postal_codes") or [],
|
|
152
|
+
"cities": zone_def.get("cities") or [],
|
|
153
|
+
"radius": zone_def.get("radius"),
|
|
154
|
+
"latitude": zone_def.get("latitude"),
|
|
155
|
+
"longitude": zone_def.get("longitude"),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def surcharges(self):
|
|
70
162
|
"""
|
|
71
|
-
|
|
72
|
-
|
|
163
|
+
Get surcharges as Surcharge objects for SDK compatibility.
|
|
164
|
+
|
|
165
|
+
Transforms surcharge_ids + rate_sheet data into the Surcharge format
|
|
166
|
+
expected by the RatingMixinProxy.
|
|
167
|
+
"""
|
|
168
|
+
_rate_sheet = self.rate_sheet
|
|
169
|
+
if not _rate_sheet:
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
surcharges_by_id = {s.get("id"): s for s in (_rate_sheet.surcharges or [])}
|
|
173
|
+
|
|
174
|
+
result = []
|
|
175
|
+
for surcharge_id in (self.surcharge_ids or []):
|
|
176
|
+
surcharge_def = surcharges_by_id.get(surcharge_id)
|
|
177
|
+
if not surcharge_def or not surcharge_def.get("active", True):
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
result.append({
|
|
181
|
+
"id": surcharge_id,
|
|
182
|
+
"name": surcharge_def.get("name"),
|
|
183
|
+
"amount": surcharge_def.get("amount"),
|
|
184
|
+
"surcharge_type": surcharge_def.get("surcharge_type", "fixed"),
|
|
185
|
+
"cost": surcharge_def.get("cost"),
|
|
186
|
+
"active": surcharge_def.get("active", True),
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
def get_rate_for_zone(self, zone_id: str) -> dict:
|
|
192
|
+
"""
|
|
193
|
+
Get the rate for a specific zone from the parent rate sheet.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
dict: Rate data including rate, cost, min_weight, max_weight, transit_days
|
|
197
|
+
"""
|
|
198
|
+
rate_sheet = self.rate_sheet
|
|
73
199
|
if not rate_sheet:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
except:
|
|
79
|
-
rate_sheet = None
|
|
80
|
-
|
|
81
|
-
if rate_sheet and rate_sheet.zones and rate_sheet.service_rates:
|
|
82
|
-
# Use optimized structure
|
|
83
|
-
return rate_sheet.get_service_zones_legacy(self.id)
|
|
84
|
-
else:
|
|
85
|
-
# Fall back to legacy zones field
|
|
86
|
-
return self.zones or []
|
|
87
|
-
|
|
88
|
-
def update_zone_cell(self, zone_id: str, field: str, value):
|
|
89
|
-
"""Update a single field in a zone by ID or index with validation"""
|
|
90
|
-
# Define allowed fields with their validators
|
|
91
|
-
allowed_fields = {
|
|
92
|
-
'rate': float,
|
|
93
|
-
'min_weight': float,
|
|
94
|
-
'max_weight': float,
|
|
95
|
-
'transit_days': int,
|
|
96
|
-
'transit_time': float,
|
|
97
|
-
'label': str,
|
|
98
|
-
'radius': float,
|
|
99
|
-
'latitude': float,
|
|
100
|
-
'longitude': float,
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if field not in allowed_fields:
|
|
104
|
-
raise ValueError(f"Field '{field}' is not allowed for zone updates")
|
|
105
|
-
|
|
106
|
-
# Validate and convert the value
|
|
107
|
-
try:
|
|
108
|
-
if value is not None and value != '':
|
|
109
|
-
value = allowed_fields[field](value)
|
|
110
|
-
except (ValueError, TypeError):
|
|
111
|
-
raise ValueError(f"Invalid value '{value}' for field '{field}' (expected {allowed_fields[field].__name__})")
|
|
112
|
-
|
|
113
|
-
zones = self.zones or []
|
|
114
|
-
|
|
115
|
-
# First try to find by zone ID
|
|
116
|
-
for zone in zones:
|
|
117
|
-
if zone.get('id') == zone_id:
|
|
118
|
-
zone[field] = value
|
|
119
|
-
self.save(update_fields=['zones'])
|
|
120
|
-
return zone
|
|
121
|
-
|
|
122
|
-
# Fallback: try to find by index for zones without IDs
|
|
123
|
-
try:
|
|
124
|
-
zone_index = int(zone_id)
|
|
125
|
-
if 0 <= zone_index < len(zones):
|
|
126
|
-
zones[zone_index][field] = value
|
|
127
|
-
self.save(update_fields=['zones'])
|
|
128
|
-
return zones[zone_index]
|
|
129
|
-
except (ValueError, IndexError):
|
|
130
|
-
pass
|
|
131
|
-
|
|
132
|
-
raise ValueError(f"Zone {zone_id} not found")
|
|
133
|
-
|
|
134
|
-
def batch_update_cells(self, updates: list):
|
|
200
|
+
return None
|
|
201
|
+
return rate_sheet.get_service_rate(self.id, zone_id)
|
|
202
|
+
|
|
203
|
+
def get_applicable_surcharges(self) -> list:
|
|
135
204
|
"""
|
|
136
|
-
|
|
137
|
-
|
|
205
|
+
Get the applicable surcharges from the parent rate sheet.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
list: List of surcharge definitions
|
|
138
209
|
"""
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
'
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
210
|
+
rate_sheet = self.rate_sheet
|
|
211
|
+
if not rate_sheet:
|
|
212
|
+
return []
|
|
213
|
+
|
|
214
|
+
surcharges = []
|
|
215
|
+
for surcharge_id in (self.surcharge_ids or []):
|
|
216
|
+
surcharge = rate_sheet.get_surcharge(surcharge_id)
|
|
217
|
+
if surcharge and surcharge.get('active', True):
|
|
218
|
+
surcharges.append(surcharge)
|
|
219
|
+
return surcharges
|
|
220
|
+
|
|
221
|
+
def calculate_rate(self, zone_id: str) -> tuple:
|
|
222
|
+
"""
|
|
223
|
+
Calculate the total rate for a zone including surcharges.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
zone_id: The zone ID to calculate rate for
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
tuple: (total_rate, breakdown) where breakdown includes base rate and surcharges
|
|
230
|
+
"""
|
|
231
|
+
rate_sheet = self.rate_sheet
|
|
232
|
+
if not rate_sheet:
|
|
233
|
+
return 0, []
|
|
234
|
+
|
|
235
|
+
# Get base rate for zone
|
|
236
|
+
rate_data = rate_sheet.get_service_rate(self.id, zone_id)
|
|
237
|
+
base_rate = float(rate_data.get('rate', 0)) if rate_data else 0
|
|
238
|
+
|
|
239
|
+
# Apply surcharges
|
|
240
|
+
total_rate, surcharge_breakdown = rate_sheet.apply_surcharges_to_rate(
|
|
241
|
+
base_rate, self.surcharge_ids or []
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return total_rate, {
|
|
245
|
+
'base_rate': base_rate,
|
|
246
|
+
'base_cost': rate_data.get('cost') if rate_data else None,
|
|
247
|
+
'surcharges': surcharge_breakdown,
|
|
248
|
+
'total': total_rate,
|
|
150
249
|
}
|
|
151
|
-
|
|
152
|
-
zones = list(self.zones or [])
|
|
153
|
-
|
|
154
|
-
for update in updates:
|
|
155
|
-
zone_id = update.get('zone_id')
|
|
156
|
-
field = update.get('field')
|
|
157
|
-
value = update.get('value')
|
|
158
|
-
|
|
159
|
-
if field not in allowed_fields:
|
|
160
|
-
raise ValueError(f"Field '{field}' is not allowed for zone updates")
|
|
161
|
-
|
|
162
|
-
# Validate and convert the value
|
|
163
|
-
try:
|
|
164
|
-
if value is not None and value != '':
|
|
165
|
-
value = allowed_fields[field](value)
|
|
166
|
-
except (ValueError, TypeError):
|
|
167
|
-
raise ValueError(f"Invalid value '{value}' for field '{field}' (expected {allowed_fields[field].__name__})")
|
|
168
|
-
|
|
169
|
-
# Find zone by ID first, then by index
|
|
170
|
-
zone_found = False
|
|
171
|
-
for zone in zones:
|
|
172
|
-
if zone.get('id') == zone_id:
|
|
173
|
-
zone[field] = value
|
|
174
|
-
zone_found = True
|
|
175
|
-
break
|
|
176
|
-
|
|
177
|
-
# Fallback to index if zone_id is numeric and zone not found by ID
|
|
178
|
-
if not zone_found:
|
|
179
|
-
try:
|
|
180
|
-
zone_index = int(zone_id)
|
|
181
|
-
if 0 <= zone_index < len(zones):
|
|
182
|
-
zones[zone_index][field] = value
|
|
183
|
-
zone_found = True
|
|
184
|
-
except (ValueError, IndexError):
|
|
185
|
-
pass
|
|
186
|
-
|
|
187
|
-
if not zone_found:
|
|
188
|
-
raise ValueError(f"Zone {zone_id} not found")
|
|
189
|
-
|
|
190
|
-
self.zones = zones
|
|
191
|
-
self.save(update_fields=['zones'])
|
|
192
|
-
return self.zones
|