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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
174
|
-
"
|
|
175
|
-
|
|
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
|
-
|
|
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=[
|
|
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
|