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
@@ -0,0 +1,154 @@
1
+ # Generated by Django - Migrate legacy zones/surcharges to shared format and remove legacy fields
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ def migrate_legacy_to_shared_format(apps, schema_editor):
7
+ """
8
+ Migrate legacy per-service zones and surcharges to the new shared format.
9
+
10
+ Legacy format:
11
+ - ServiceLevel.zones: [{'label': 'Zone 1', 'rate': 10.0, 'country_codes': [...], ...}]
12
+ - ServiceLevel.surcharges: [{'name': 'Fuel', 'amount': 5.0, 'surcharge_type': 'percentage'}]
13
+
14
+ New format:
15
+ - RateSheet.zones: [{'id': 'zone_1', 'label': 'Zone 1', 'country_codes': [...]}]
16
+ - RateSheet.surcharges: [{'id': 'surch_1', 'name': 'Fuel', 'amount': 5.0, ...}]
17
+ - RateSheet.service_rates: [{'service_id': 'svc_1', 'zone_id': 'zone_1', 'rate': 10.0}]
18
+ - ServiceLevel.zone_ids: ['zone_1', 'zone_2']
19
+ - ServiceLevel.surcharge_ids: ['surch_1']
20
+ """
21
+ RateSheet = apps.get_model('providers', 'RateSheet')
22
+
23
+ for rate_sheet in RateSheet.objects.all():
24
+ # Skip if already migrated (has zones or service_rates)
25
+ if rate_sheet.zones or rate_sheet.service_rates:
26
+ continue
27
+
28
+ all_zones = {}
29
+ all_surcharges = {}
30
+ service_rates = []
31
+ zone_counter = 1
32
+ surcharge_counter = 1
33
+
34
+ # Process each service in the rate sheet
35
+ for service in rate_sheet.services.all():
36
+ legacy_zones = service.zones or []
37
+ legacy_surcharges = service.surcharges or []
38
+
39
+ service_zone_ids = []
40
+ service_surcharge_ids = []
41
+
42
+ # Process zones
43
+ for zone_data in legacy_zones:
44
+ # Create zone signature for deduplication
45
+ # Use `or []` to handle None values
46
+ cities = zone_data.get('cities') or []
47
+ postal_codes = zone_data.get('postal_codes') or []
48
+ country_codes = zone_data.get('country_codes') or []
49
+
50
+ zone_signature = {
51
+ 'label': zone_data.get('label') or f'Zone {zone_counter}',
52
+ 'cities': sorted(cities),
53
+ 'postal_codes': sorted(postal_codes),
54
+ 'country_codes': sorted(country_codes),
55
+ }
56
+ sig_key = str(zone_signature)
57
+
58
+ if sig_key not in all_zones:
59
+ zone_id = f"zone_{zone_counter}"
60
+ all_zones[sig_key] = {
61
+ 'id': zone_id,
62
+ 'label': zone_signature['label'],
63
+ 'cities': cities,
64
+ 'postal_codes': postal_codes,
65
+ 'country_codes': country_codes,
66
+ 'transit_days': zone_data.get('transit_days'),
67
+ 'transit_time': zone_data.get('transit_time'),
68
+ 'radius': zone_data.get('radius'),
69
+ 'latitude': zone_data.get('latitude'),
70
+ 'longitude': zone_data.get('longitude'),
71
+ }
72
+ zone_counter += 1
73
+
74
+ zone_id = all_zones[sig_key]['id']
75
+ service_zone_ids.append(zone_id)
76
+
77
+ # Create service rate record
78
+ service_rates.append({
79
+ 'service_id': service.id,
80
+ 'zone_id': zone_id,
81
+ 'rate': zone_data.get('rate', 0),
82
+ 'cost': zone_data.get('cost'),
83
+ 'min_weight': zone_data.get('min_weight'),
84
+ 'max_weight': zone_data.get('max_weight'),
85
+ 'transit_days': zone_data.get('transit_days'),
86
+ 'transit_time': zone_data.get('transit_time'),
87
+ })
88
+
89
+ # Process surcharges
90
+ for surcharge_data in legacy_surcharges:
91
+ # Create surcharge signature for deduplication
92
+ surcharge_signature = {
93
+ 'name': surcharge_data.get('name', f'Surcharge {surcharge_counter}'),
94
+ 'amount': surcharge_data.get('amount', 0),
95
+ 'surcharge_type': surcharge_data.get('surcharge_type', 'fixed'),
96
+ }
97
+ sig_key = str(surcharge_signature)
98
+
99
+ if sig_key not in all_surcharges:
100
+ surcharge_id = f"surch_{surcharge_counter}"
101
+ all_surcharges[sig_key] = {
102
+ 'id': surcharge_id,
103
+ 'name': surcharge_signature['name'],
104
+ 'amount': surcharge_signature['amount'],
105
+ 'surcharge_type': surcharge_signature['surcharge_type'],
106
+ 'cost': surcharge_data.get('cost'),
107
+ 'active': surcharge_data.get('active', True),
108
+ }
109
+ surcharge_counter += 1
110
+
111
+ surcharge_id = all_surcharges[sig_key]['id']
112
+ service_surcharge_ids.append(surcharge_id)
113
+
114
+ # Update service with zone_ids and surcharge_ids
115
+ service.zone_ids = list(set(service_zone_ids)) # Deduplicate
116
+ service.surcharge_ids = list(set(service_surcharge_ids)) # Deduplicate
117
+ service.save(update_fields=['zone_ids', 'surcharge_ids'])
118
+
119
+ # Save shared zones and surcharges to rate sheet
120
+ if all_zones or service_rates:
121
+ rate_sheet.zones = list(all_zones.values())
122
+ rate_sheet.service_rates = service_rates
123
+ rate_sheet.surcharges = list(all_surcharges.values())
124
+ rate_sheet.save(update_fields=['zones', 'service_rates', 'surcharges'])
125
+
126
+
127
+ def reverse_migration(apps, schema_editor):
128
+ """
129
+ Reverse migration is a no-op since we're not deleting the legacy data,
130
+ just adding new fields. The legacy fields will be removed in a separate migration.
131
+ """
132
+ pass
133
+
134
+
135
+ class Migration(migrations.Migration):
136
+
137
+ dependencies = [
138
+ ('providers', '0090_ratesheet_surcharges_servicelevel_zone_surcharge_ids'),
139
+ ]
140
+
141
+ operations = [
142
+ # First, migrate the data
143
+ migrations.RunPython(migrate_legacy_to_shared_format, reverse_migration),
144
+
145
+ # Then remove the legacy fields from ServiceLevel
146
+ migrations.RemoveField(
147
+ model_name='servicelevel',
148
+ name='zones',
149
+ ),
150
+ migrations.RemoveField(
151
+ model_name='servicelevel',
152
+ name='surcharges',
153
+ ),
154
+ ]
@@ -3,6 +3,7 @@ import functools
3
3
  import django.conf as conf
4
4
  import django.forms as forms
5
5
  import django.db.models as models
6
+ from django.db.models import Q, OuterRef, Subquery, Case, When, IntegerField
6
7
 
7
8
  import karrio.lib as lib
8
9
  import karrio.sdk as karrio
@@ -21,37 +22,88 @@ DIMENSION_UNITS = [(c.name, c.name) for c in units.DimensionUnit]
21
22
  CAPABILITIES_CHOICES = [(c, c) for c in units.CarrierCapabilities.get_capabilities()]
22
23
 
23
24
 
25
+ class CarrierQuerySet(models.QuerySet):
26
+ def resolve_config_for(self, context):
27
+ from karrio.server.providers.models.config import CarrierConfig
28
+
29
+ if context is None:
30
+ return self
31
+
32
+ user = getattr(context, "user", None)
33
+ org = getattr(context, "org", None)
34
+
35
+ if isinstance(context, dict):
36
+ user = user or context.get("user")
37
+ org = org or context.get("org")
38
+
39
+ # 1. Define what "My Config" looks like (User or Org specific)
40
+ my_config_filter = Q()
41
+ if org:
42
+ my_config_filter = Q(org=org)
43
+ elif user and getattr(user, "is_authenticated", False):
44
+ my_config_filter = Q(created_by=user)
45
+
46
+ # 2. Define what "System Default" looks like
47
+ system_default_filter = Q(created_by__is_staff=True)
48
+ if hasattr(CarrierConfig, "org"):
49
+ system_default_filter &= Q(org__isnull=True)
50
+
51
+ # 3. Build the Subquery - only use priority if we have a user/org filter
52
+ config_filter = (
53
+ my_config_filter | system_default_filter
54
+ if my_config_filter
55
+ else system_default_filter
56
+ )
57
+
58
+ config_query = CarrierConfig.objects.filter(carrier=OuterRef("pk")).filter(
59
+ config_filter
60
+ )
61
+
62
+ if my_config_filter:
63
+ # Prioritize user/org config over system default
64
+ config_query = config_query.annotate(
65
+ priority=Case(
66
+ When(my_config_filter, then=0),
67
+ default=1,
68
+ output_field=IntegerField(),
69
+ )
70
+ ).order_by("priority")
71
+
72
+ # 4. Annotate the queryset
73
+ return self.annotate(
74
+ _computed_config=Subquery(config_query.values("config")[:1])
75
+ )
76
+
77
+
24
78
  class Manager(models.Manager):
25
79
  def get_queryset(self):
26
80
  return (
27
- super()
28
- .get_queryset()
81
+ CarrierQuerySet(self.model, using=self._db)
29
82
  .select_related(
30
83
  "created_by",
84
+ "rate_sheet",
31
85
  *(("link",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
32
86
  )
87
+ .prefetch_related(
88
+ "active_users",
89
+ *(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
90
+ )
33
91
  )
34
92
 
93
+ def resolve_config_for(self, context):
94
+ return self.get_queryset().resolve_config_for(context)
95
+
35
96
 
36
97
  class CarrierManager(Manager):
37
98
  def get_queryset(self):
38
- return (
39
- super()
40
- .get_queryset()
41
- .prefetch_related(
42
- *(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
43
- )
44
- .filter(is_system=False)
45
- )
99
+ return super().get_queryset().filter(is_system=False)
46
100
 
47
101
 
48
102
  class SystemCarrierManager(models.Manager):
49
103
  def get_queryset(self):
50
104
  return (
51
- super()
52
- .get_queryset()
105
+ CarrierQuerySet(self.model, using=self._db)
53
106
  .prefetch_related(
54
- "configs",
55
107
  *(("active_orgs",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
56
108
  )
57
109
  .select_related(
@@ -61,12 +113,17 @@ class SystemCarrierManager(models.Manager):
61
113
  .filter(is_system=True)
62
114
  )
63
115
 
116
+ def resolve_config_for(self, context):
117
+ return self.get_queryset().resolve_config_for(context)
118
+
64
119
 
65
120
  @core.register_model
66
121
  class Carrier(core.OwnedEntity):
67
122
  class Meta:
68
123
  ordering = ["test_mode", "-created_at"]
69
124
 
125
+ CONTEXT_RELATIONS = ["rate_sheet"]
126
+
70
127
  objects = Manager()
71
128
  user_carriers = CarrierManager()
72
129
  system_carriers = SystemCarrierManager()
@@ -138,6 +195,11 @@ class Carrier(core.OwnedEntity):
138
195
  on_delete=models.SET_NULL,
139
196
  )
140
197
 
198
+ @classmethod
199
+ def resolve_context_data(cls, queryset, context):
200
+ """Apply context-aware carrier config resolution."""
201
+ return queryset.resolve_config_for(context)
202
+
141
203
  def __str__(self):
142
204
  return self.carrier_id
143
205
 
@@ -160,19 +222,16 @@ class Carrier(core.OwnedEntity):
160
222
  if "custom_carrier_name" in self.credentials
161
223
  else lib.failsafe(lambda: self.carrier_code) or "generic"
162
224
  )
163
- # return (
164
- # self.credentials.get("custom_carrier_name")
165
- # if "custom_carrier_name" in self.credentials
166
- # else lib.failsafe(lambda: self.carrier_code) or "generic"
167
- # )
168
225
 
169
226
  @property
170
227
  def display_name(self):
171
228
  import karrio.references as references
172
229
 
173
- return self.credentials.get("display_name") or references.REFERENCES[
174
- "carriers"
175
- ].get(self.ext) or "generic"
230
+ return (
231
+ self.credentials.get("display_name")
232
+ or references.REFERENCES["carriers"].get(self.ext)
233
+ or "generic"
234
+ )
176
235
 
177
236
  @property
178
237
  def carrier_config(self):
@@ -180,7 +239,16 @@ class Carrier(core.OwnedEntity):
180
239
 
181
240
  @property
182
241
  def config(self) -> dict:
183
- return getattr(self.carrier_config, "config", {})
242
+ if hasattr(self, "_computed_config"):
243
+ annotated_config = self._computed_config
244
+ if annotated_config is not None:
245
+ return annotated_config
246
+ # If the annotation didn't resolve (eg. context missing), fall back to DB lookup.
247
+ resolved_config = getattr(self.carrier_config, "config", None)
248
+ if resolved_config is not None:
249
+ return resolved_config
250
+ # Return empty dict if no config is found - do NOT fallback to credentials
251
+ return {}
184
252
 
185
253
  @property
186
254
  def services(self) -> typing.Optional[typing.List[dict]]:
@@ -204,7 +272,14 @@ class Carrier(core.OwnedEntity):
204
272
 
205
273
  if any(self.services or []):
206
274
  _computed_data.update(
207
- services=[forms.model_to_dict(s) for s in self.services]
275
+ services=[
276
+ {
277
+ **forms.model_to_dict(s),
278
+ "zones": s.zones, # Include computed zones property
279
+ "surcharges": s.surcharges, # Include computed surcharges property
280
+ }
281
+ for s in self.services
282
+ ]
208
283
  )
209
284
 
210
285
  # override the config with the system config
@@ -222,27 +297,24 @@ class Carrier(core.OwnedEntity):
222
297
  @property
223
298
  def gateway(self) -> gateway.Gateway:
224
299
  import karrio.server.core.middleware as middleware
300
+ import karrio.server.core.config as system_config
225
301
 
226
302
  _context = middleware.SessionContext.get_current_request()
227
303
  _tracer = getattr(_context, "tracer", lib.Tracer())
228
304
  _cache = lib.Cache(caching.cache)
305
+ _config = lib.SystemConfig(system_config.config)
229
306
 
230
307
  return karrio.gateway[self.ext].create(
231
308
  self.data.to_dict(),
232
309
  _tracer,
233
310
  _cache,
311
+ _config,
234
312
  )
235
313
 
236
314
  @staticmethod
237
315
  def resolve_config(
238
316
  carrier, is_user_config: bool = False, is_system_config: bool = False
239
317
  ):
240
- """Resolve the config for a carrier.
241
- Here are the rules:
242
- - If the carrier is a system carrier, return the first config with no org
243
- - If the carrier is an org carrier, return the first config from the org
244
- - If the carrier is a user carrier, return the first config from the user
245
- """
246
318
  import karrio.server.serializers as serializers
247
319
  import karrio.server.core.middleware as middleware
248
320
  from django.contrib.auth.models import AnonymousUser