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.
Files changed (56) hide show
  1. karrio/server/core/authentication.py +38 -20
  2. karrio/server/core/config.py +31 -0
  3. karrio/server/core/datatypes.py +30 -4
  4. karrio/server/core/dataunits.py +26 -7
  5. karrio/server/core/exceptions.py +287 -17
  6. karrio/server/core/filters.py +14 -0
  7. karrio/server/core/gateway.py +284 -11
  8. karrio/server/core/logging.py +403 -0
  9. karrio/server/core/middleware.py +104 -2
  10. karrio/server/core/models/base.py +34 -1
  11. karrio/server/core/oauth_validators.py +2 -3
  12. karrio/server/core/permissions.py +1 -2
  13. karrio/server/core/serializers.py +154 -7
  14. karrio/server/core/signals.py +22 -28
  15. karrio/server/core/telemetry.py +573 -0
  16. karrio/server/core/tests/__init__.py +27 -0
  17. karrio/server/core/{tests.py → tests/base.py} +6 -7
  18. karrio/server/core/tests/test_exception_level.py +159 -0
  19. karrio/server/core/tests/test_resource_token.py +593 -0
  20. karrio/server/core/utils.py +688 -38
  21. karrio/server/core/validators.py +144 -222
  22. karrio/server/core/views/oauth.py +13 -12
  23. karrio/server/core/views/references.py +2 -2
  24. karrio/server/iam/apps.py +1 -4
  25. karrio/server/iam/migrations/0002_setup_carrier_permission_groups.py +103 -0
  26. karrio/server/iam/migrations/0003_remove_permission_groups.py +91 -0
  27. karrio/server/iam/permissions.py +7 -134
  28. karrio/server/iam/serializers.py +9 -3
  29. karrio/server/iam/signals.py +2 -4
  30. karrio/server/providers/admin.py +1 -1
  31. karrio/server/providers/migrations/0085_populate_dhl_parcel_de_oauth_credentials.py +82 -0
  32. karrio/server/providers/migrations/0086_rename_dhl_parcel_de_customer_number_to_billing_number.py +71 -0
  33. karrio/server/providers/migrations/0087_alter_carrier_capabilities.py +38 -0
  34. karrio/server/providers/migrations/0088_servicelevel_surcharges.py +24 -0
  35. karrio/server/providers/migrations/0089_servicelevel_cost_max_volume.py +31 -0
  36. karrio/server/providers/migrations/0090_ratesheet_surcharges_servicelevel_zone_surcharge_ids.py +47 -0
  37. karrio/server/providers/migrations/0091_migrate_legacy_zones_surcharges.py +154 -0
  38. karrio/server/providers/models/carrier.py +101 -29
  39. karrio/server/providers/models/service.py +182 -125
  40. karrio/server/providers/models/sheet.py +342 -198
  41. karrio/server/providers/serializers/base.py +263 -2
  42. karrio/server/providers/signals.py +2 -4
  43. karrio/server/providers/templates/providers/oauth_callback.html +105 -0
  44. karrio/server/providers/tests/__init__.py +5 -0
  45. karrio/server/providers/tests/test_connections.py +895 -0
  46. karrio/server/providers/views/carriers.py +1 -3
  47. karrio/server/providers/views/connections.py +322 -2
  48. karrio/server/serializers/abstract.py +112 -21
  49. karrio/server/tracing/utils.py +5 -8
  50. karrio/server/user/models.py +36 -34
  51. karrio/server/user/serializers.py +1 -0
  52. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/METADATA +2 -2
  53. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/RECORD +55 -38
  54. karrio/server/providers/tests.py +0 -3
  55. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/WHEEL +0 -0
  56. {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
- zones = models.JSONField(blank=True, null=True, default=core.field_default([]))
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 computed_zones(self):
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
- Computed property that returns zones in legacy format for backward compatibility.
68
- If the service belongs to a rate sheet with optimized structure, reconstruct from there.
69
- Otherwise, fall back to the service's own zones field.
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
- # Check if this service belongs to a rate sheet with optimized structure
72
- rate_sheet = getattr(self, '_rate_sheet_cache', None)
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
- # Try to find rate sheet this service belongs to
75
- try:
76
- rate_sheet = self.service_sheet.first()
77
- self._rate_sheet_cache = rate_sheet
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
- Batch update multiple zone cells with validation
137
- updates format: [{'zone_id': str, 'field': str, 'value': any}, ...]
205
+ Get the applicable surcharges from the parent rate sheet.
206
+
207
+ Returns:
208
+ list: List of surcharge definitions
138
209
  """
139
- # Define allowed fields with their validators
140
- allowed_fields = {
141
- 'rate': float,
142
- 'min_weight': float,
143
- 'max_weight': float,
144
- 'transit_days': int,
145
- 'transit_time': float,
146
- 'label': str,
147
- 'radius': float,
148
- 'latitude': float,
149
- 'longitude': float,
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