karrio-server-core 2025.5rc12__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 (112) hide show
  1. karrio/server/core/authentication.py +59 -25
  2. karrio/server/core/config.py +31 -0
  3. karrio/server/core/datatypes.py +30 -4
  4. karrio/server/core/dataunits.py +53 -22
  5. karrio/server/core/exceptions.py +287 -17
  6. karrio/server/core/filters.py +14 -0
  7. karrio/server/core/gateway.py +285 -10
  8. karrio/server/core/logging.py +403 -0
  9. karrio/server/core/management/commands/runserver.py +5 -0
  10. karrio/server/core/middleware.py +104 -2
  11. karrio/server/core/migrations/0006_add_api_log_requested_at_index.py +22 -0
  12. karrio/server/core/models/base.py +34 -1
  13. karrio/server/core/oauth_validators.py +2 -3
  14. karrio/server/core/permissions.py +1 -2
  15. karrio/server/core/serializers.py +183 -10
  16. karrio/server/core/signals.py +22 -28
  17. karrio/server/core/telemetry.py +573 -0
  18. karrio/server/core/tests/__init__.py +27 -0
  19. karrio/server/core/{tests.py → tests/base.py} +6 -7
  20. karrio/server/core/tests/test_exception_level.py +159 -0
  21. karrio/server/core/tests/test_resource_token.py +593 -0
  22. karrio/server/core/utils.py +688 -38
  23. karrio/server/core/validators.py +144 -222
  24. karrio/server/core/views/oauth.py +13 -12
  25. karrio/server/core/views/references.py +2 -2
  26. karrio/server/iam/apps.py +1 -4
  27. karrio/server/iam/migrations/0002_setup_carrier_permission_groups.py +103 -0
  28. karrio/server/iam/migrations/0003_remove_permission_groups.py +91 -0
  29. karrio/server/iam/permissions.py +7 -134
  30. karrio/server/iam/serializers.py +17 -2
  31. karrio/server/iam/signals.py +2 -4
  32. karrio/server/providers/admin.py +1 -1
  33. karrio/server/providers/management/commands/migrate_rate_sheets.py +101 -0
  34. karrio/server/providers/migrations/0082_add_zone_identifiers.py +50 -0
  35. karrio/server/providers/migrations/0083_add_optimized_rate_sheet_structure.py +33 -0
  36. karrio/server/providers/migrations/0084_alter_servicelevel_currency.py +168 -0
  37. karrio/server/providers/migrations/0085_populate_dhl_parcel_de_oauth_credentials.py +82 -0
  38. karrio/server/providers/migrations/0086_rename_dhl_parcel_de_customer_number_to_billing_number.py +71 -0
  39. karrio/server/providers/migrations/0087_alter_carrier_capabilities.py +38 -0
  40. karrio/server/providers/migrations/0088_servicelevel_surcharges.py +24 -0
  41. karrio/server/providers/migrations/0089_servicelevel_cost_max_volume.py +31 -0
  42. karrio/server/providers/migrations/0090_ratesheet_surcharges_servicelevel_zone_surcharge_ids.py +47 -0
  43. karrio/server/providers/migrations/0091_migrate_legacy_zones_surcharges.py +154 -0
  44. karrio/server/providers/models/__init__.py +1 -2
  45. karrio/server/providers/models/carrier.py +103 -18
  46. karrio/server/providers/models/service.py +188 -1
  47. karrio/server/providers/models/sheet.py +371 -0
  48. karrio/server/providers/serializers/base.py +263 -2
  49. karrio/server/providers/signals.py +2 -4
  50. karrio/server/providers/templates/providers/oauth_callback.html +105 -0
  51. karrio/server/providers/tests/__init__.py +5 -0
  52. karrio/server/providers/tests/test_connections.py +895 -0
  53. karrio/server/providers/views/carriers.py +1 -3
  54. karrio/server/providers/views/connections.py +322 -2
  55. karrio/server/samples.py +1 -1
  56. karrio/server/serializers/abstract.py +116 -21
  57. karrio/server/tracing/migrations/0007_tracingrecord_tracing_created_at_idx.py +19 -0
  58. karrio/server/tracing/models.py +2 -0
  59. karrio/server/tracing/utils.py +5 -8
  60. karrio/server/user/migrations/0007_user_metadata.py +25 -0
  61. karrio/server/user/models.py +38 -23
  62. karrio/server/user/serializers.py +1 -0
  63. karrio/server/user/templates/registration/registration_confirm_email.html +1 -1
  64. {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/METADATA +2 -2
  65. {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/RECORD +67 -86
  66. karrio/server/providers/extension/__init__.py +0 -1
  67. karrio/server/providers/extension/models/__init__.py +0 -1
  68. karrio/server/providers/extension/models/allied_express.py +0 -22
  69. karrio/server/providers/extension/models/allied_express_local.py +0 -22
  70. karrio/server/providers/extension/models/amazon_shipping.py +0 -27
  71. karrio/server/providers/extension/models/aramex.py +0 -25
  72. karrio/server/providers/extension/models/asendia_us.py +0 -21
  73. karrio/server/providers/extension/models/australiapost.py +0 -20
  74. karrio/server/providers/extension/models/boxknight.py +0 -19
  75. karrio/server/providers/extension/models/bpost.py +0 -21
  76. karrio/server/providers/extension/models/canadapost.py +0 -21
  77. karrio/server/providers/extension/models/canpar.py +0 -19
  78. karrio/server/providers/extension/models/chronopost.py +0 -22
  79. karrio/server/providers/extension/models/colissimo.py +0 -22
  80. karrio/server/providers/extension/models/dhl_express.py +0 -23
  81. karrio/server/providers/extension/models/dhl_parcel_de.py +0 -25
  82. karrio/server/providers/extension/models/dhl_poland.py +0 -22
  83. karrio/server/providers/extension/models/dhl_universal.py +0 -19
  84. karrio/server/providers/extension/models/dicom.py +0 -20
  85. karrio/server/providers/extension/models/dpd.py +0 -37
  86. karrio/server/providers/extension/models/dpdhl.py +0 -26
  87. karrio/server/providers/extension/models/easypost.py +0 -20
  88. karrio/server/providers/extension/models/eshipper.py +0 -21
  89. karrio/server/providers/extension/models/fedex.py +0 -25
  90. karrio/server/providers/extension/models/fedex_ws.py +0 -24
  91. karrio/server/providers/extension/models/freightcom.py +0 -21
  92. karrio/server/providers/extension/models/generic.py +0 -35
  93. karrio/server/providers/extension/models/geodis.py +0 -22
  94. karrio/server/providers/extension/models/hay_post.py +0 -22
  95. karrio/server/providers/extension/models/laposte.py +0 -19
  96. karrio/server/providers/extension/models/locate2u.py +0 -22
  97. karrio/server/providers/extension/models/nationex.py +0 -22
  98. karrio/server/providers/extension/models/purolator.py +0 -21
  99. karrio/server/providers/extension/models/roadie.py +0 -18
  100. karrio/server/providers/extension/models/royalmail.py +0 -19
  101. karrio/server/providers/extension/models/sendle.py +0 -22
  102. karrio/server/providers/extension/models/tge.py +0 -63
  103. karrio/server/providers/extension/models/tnt.py +0 -23
  104. karrio/server/providers/extension/models/ups.py +0 -23
  105. karrio/server/providers/extension/models/usps.py +0 -23
  106. karrio/server/providers/extension/models/usps_international.py +0 -23
  107. karrio/server/providers/extension/models/usps_wt.py +0 -24
  108. karrio/server/providers/extension/models/usps_wt_international.py +0 -24
  109. karrio/server/providers/extension/models/zoom2u.py +0 -23
  110. karrio/server/providers/tests.py +0 -3
  111. {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/WHEEL +0 -0
  112. {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,82 @@
1
+ # Generated by Django migration to populate missing required credentials for DHL Parcel DE
2
+ # This migration ensures existing dhl_parcel_de connections have all required fields
3
+ # (username, password, client_id, client_secret) to prevent initialization errors
4
+ # after the settings schema was updated.
5
+
6
+ from django.db import migrations
7
+ from django.utils import timezone
8
+
9
+
10
+ # Required fields for dhl_parcel_de that have no default values
11
+ REQUIRED_FIELDS = ["username", "password", "client_id", "client_secret"]
12
+
13
+
14
+ def populate_dhl_parcel_de_required_credentials(apps, schema_editor):
15
+ """
16
+ Find all dhl_parcel_de carrier connections that are missing required fields
17
+ and populate them with placeholder timestamp values.
18
+ """
19
+ Carrier = apps.get_model("providers", "Carrier")
20
+ db_alias = schema_editor.connection.alias
21
+
22
+ # Find all dhl_parcel_de carriers
23
+ dhl_parcel_de_carriers = Carrier.objects.using(db_alias).filter(
24
+ carrier_code="dhl_parcel_de"
25
+ )
26
+
27
+ timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
28
+
29
+ for carrier in dhl_parcel_de_carriers:
30
+ credentials = carrier.credentials or {}
31
+ updated = False
32
+
33
+ # Check and populate all missing required fields
34
+ for field in REQUIRED_FIELDS:
35
+ if not credentials.get(field):
36
+ credentials[field] = f"PLACEHOLDER_{timestamp}"
37
+ updated = True
38
+
39
+ if updated:
40
+ carrier.credentials = credentials
41
+ carrier.save(using=db_alias, update_fields=["credentials"])
42
+
43
+
44
+ def reverse_dhl_parcel_de_required_credentials(apps, schema_editor):
45
+ """
46
+ Remove placeholder credentials (for rollback).
47
+ Only removes credentials that were set by this migration (contain PLACEHOLDER_).
48
+ """
49
+ Carrier = apps.get_model("providers", "Carrier")
50
+ db_alias = schema_editor.connection.alias
51
+
52
+ dhl_parcel_de_carriers = Carrier.objects.using(db_alias).filter(
53
+ carrier_code="dhl_parcel_de"
54
+ )
55
+
56
+ for carrier in dhl_parcel_de_carriers:
57
+ credentials = carrier.credentials or {}
58
+ updated = False
59
+
60
+ # Only remove if it's a placeholder value
61
+ for field in REQUIRED_FIELDS:
62
+ if credentials.get(field, "").startswith("PLACEHOLDER_"):
63
+ del credentials[field]
64
+ updated = True
65
+
66
+ if updated:
67
+ carrier.credentials = credentials
68
+ carrier.save(using=db_alias, update_fields=["credentials"])
69
+
70
+
71
+ class Migration(migrations.Migration):
72
+
73
+ dependencies = [
74
+ ("providers", "0084_alter_servicelevel_currency"),
75
+ ]
76
+
77
+ operations = [
78
+ migrations.RunPython(
79
+ populate_dhl_parcel_de_required_credentials,
80
+ reverse_dhl_parcel_de_required_credentials,
81
+ ),
82
+ ]
@@ -0,0 +1,71 @@
1
+ # Generated by Django migration to rename customer_number to billing_number
2
+ # for DHL Parcel DE connections. This keeps existing configuration values
3
+ # when the field was renamed in the settings schema.
4
+
5
+ from django.db import migrations
6
+
7
+
8
+ def rename_customer_number_to_billing_number(apps, schema_editor):
9
+ """
10
+ Find all dhl_parcel_de carrier connections that have customer_number
11
+ and rename it to billing_number to preserve the configuration value.
12
+ """
13
+ Carrier = apps.get_model("providers", "Carrier")
14
+ db_alias = schema_editor.connection.alias
15
+
16
+ # Find all dhl_parcel_de carriers
17
+ dhl_parcel_de_carriers = Carrier.objects.using(db_alias).filter(
18
+ carrier_code="dhl_parcel_de"
19
+ )
20
+
21
+ for carrier in dhl_parcel_de_carriers:
22
+ credentials = carrier.credentials or {}
23
+
24
+ # Check if customer_number exists and billing_number doesn't
25
+ if credentials.get("customer_number") and not credentials.get("billing_number"):
26
+ # Copy customer_number value to billing_number
27
+ credentials["billing_number"] = credentials["customer_number"]
28
+ # Remove old customer_number field
29
+ del credentials["customer_number"]
30
+
31
+ carrier.credentials = credentials
32
+ carrier.save(using=db_alias, update_fields=["credentials"])
33
+
34
+
35
+ def rename_billing_number_to_customer_number(apps, schema_editor):
36
+ """
37
+ Reverse migration: rename billing_number back to customer_number.
38
+ """
39
+ Carrier = apps.get_model("providers", "Carrier")
40
+ db_alias = schema_editor.connection.alias
41
+
42
+ dhl_parcel_de_carriers = Carrier.objects.using(db_alias).filter(
43
+ carrier_code="dhl_parcel_de"
44
+ )
45
+
46
+ for carrier in dhl_parcel_de_carriers:
47
+ credentials = carrier.credentials or {}
48
+
49
+ # Check if billing_number exists and customer_number doesn't
50
+ if credentials.get("billing_number") and not credentials.get("customer_number"):
51
+ # Copy billing_number value back to customer_number
52
+ credentials["customer_number"] = credentials["billing_number"]
53
+ # Remove the billing_number field
54
+ del credentials["billing_number"]
55
+
56
+ carrier.credentials = credentials
57
+ carrier.save(using=db_alias, update_fields=["credentials"])
58
+
59
+
60
+ class Migration(migrations.Migration):
61
+
62
+ dependencies = [
63
+ ("providers", "0085_populate_dhl_parcel_de_oauth_credentials"),
64
+ ]
65
+
66
+ operations = [
67
+ migrations.RunPython(
68
+ rename_customer_number_to_billing_number,
69
+ rename_billing_number_to_customer_number,
70
+ ),
71
+ ]
@@ -0,0 +1,38 @@
1
+ # Generated by Django 5.2.9 on 2025-12-03 05:47
2
+
3
+ import functools
4
+ import karrio.server.core.fields
5
+ import karrio.server.core.models
6
+ from django.db import migrations
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ dependencies = [
12
+ ("providers", "0086_rename_dhl_parcel_de_customer_number_to_billing_number"),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.AlterField(
17
+ model_name="carrier",
18
+ name="capabilities",
19
+ field=karrio.server.core.fields.MultiChoiceField(
20
+ choices=[
21
+ ("pickup", "pickup"),
22
+ ("rating", "rating"),
23
+ ("shipping", "shipping"),
24
+ ("tracking", "tracking"),
25
+ ("paperless", "paperless"),
26
+ ("manifest", "manifest"),
27
+ ("duties", "duties"),
28
+ ("insurance", "insurance"),
29
+ ("webhook", "webhook"),
30
+ ("oauth", "oauth"),
31
+ ],
32
+ default=functools.partial(
33
+ karrio.server.core.models._identity, *(), **{"value": []}
34
+ ),
35
+ help_text="Select the capabilities of the carrier that you want to enable",
36
+ ),
37
+ ),
38
+ ]
@@ -0,0 +1,24 @@
1
+ # Generated by Django - Add surcharges field to ServiceLevel
2
+
3
+ from django.db import migrations, models
4
+ import karrio.server.core.models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('providers', '0087_alter_carrier_capabilities'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name='servicelevel',
16
+ name='surcharges',
17
+ field=models.JSONField(
18
+ blank=True,
19
+ default=karrio.server.core.models.field_default([]),
20
+ help_text='Service-level surcharges (fuel, handling, residential, etc.)',
21
+ null=True,
22
+ ),
23
+ ),
24
+ ]
@@ -0,0 +1,31 @@
1
+ # Generated by Django - Add cost and max_volume fields to ServiceLevel
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('providers', '0088_servicelevel_surcharges'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='servicelevel',
15
+ name='cost',
16
+ field=models.FloatField(
17
+ blank=True,
18
+ null=True,
19
+ help_text='Base COGS (Cost of Goods Sold) - internal cost tracking',
20
+ ),
21
+ ),
22
+ migrations.AddField(
23
+ model_name='servicelevel',
24
+ name='max_volume',
25
+ field=models.FloatField(
26
+ blank=True,
27
+ null=True,
28
+ help_text='Maximum volume in liters for volumetric weight calculation',
29
+ ),
30
+ ),
31
+ ]
@@ -0,0 +1,47 @@
1
+ # Generated by Django - Add surcharges to RateSheet and zone_ids/surcharge_ids to ServiceLevel
2
+
3
+ from django.db import migrations, models
4
+ import karrio.server.core.models as core
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('providers', '0089_servicelevel_cost_max_volume'),
11
+ ]
12
+
13
+ operations = [
14
+ # Add surcharges field to RateSheet (shared surcharge definitions)
15
+ migrations.AddField(
16
+ model_name='ratesheet',
17
+ name='surcharges',
18
+ field=models.JSONField(
19
+ blank=True,
20
+ null=True,
21
+ default=core.field_default([]),
22
+ help_text="Shared surcharge definitions: [{'id': 'surch_1', 'name': 'Fuel', 'amount': 8.5, 'surcharge_type': 'percentage'}]",
23
+ ),
24
+ ),
25
+ # Add zone_ids to ServiceLevel (references shared zones)
26
+ migrations.AddField(
27
+ model_name='servicelevel',
28
+ name='zone_ids',
29
+ field=models.JSONField(
30
+ blank=True,
31
+ null=True,
32
+ default=core.field_default([]),
33
+ help_text="List of zone IDs this service applies to: ['zone_1', 'zone_2']",
34
+ ),
35
+ ),
36
+ # Add surcharge_ids to ServiceLevel (references shared surcharges)
37
+ migrations.AddField(
38
+ model_name='servicelevel',
39
+ name='surcharge_ids',
40
+ field=models.JSONField(
41
+ blank=True,
42
+ null=True,
43
+ default=core.field_default([]),
44
+ help_text="List of surcharge IDs to apply: ['surch_fuel', 'surch_residential']",
45
+ ),
46
+ ),
47
+ ]
@@ -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,9 +3,8 @@ from karrio.server.providers.models.sheet import RateSheet
3
3
  from karrio.server.providers.models.config import CarrierConfig
4
4
  from karrio.server.providers.models.service import ServiceLevel
5
5
  from karrio.server.providers.models.template import LabelTemplate
6
- import karrio.server.providers.extension.models as extensions
7
- from karrio.server.core.models.base import register_model
8
6
  from karrio.server.providers.models.utils import has_rate_sheet
7
+ from karrio.server.core.models.base import register_model
9
8
  from karrio.server.providers.models.carrier import (
10
9
  Carrier,
11
10
  COUNTRIES,
@@ -3,9 +3,10 @@ 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
- import karrio.sdk as karrio
8
8
  import karrio.lib as lib
9
+ import karrio.sdk as karrio
9
10
  import karrio.core.units as units
10
11
  import django.core.cache as caching
11
12
  import karrio.api.gateway as gateway
@@ -21,17 +22,77 @@ 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):
@@ -41,10 +102,9 @@ class CarrierManager(Manager):
41
102
  class SystemCarrierManager(models.Manager):
42
103
  def get_queryset(self):
43
104
  return (
44
- super()
45
- .get_queryset()
105
+ CarrierQuerySet(self.model, using=self._db)
46
106
  .prefetch_related(
47
- "configs",
107
+ *(("active_orgs",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
48
108
  )
49
109
  .select_related(
50
110
  "created_by",
@@ -53,12 +113,17 @@ class SystemCarrierManager(models.Manager):
53
113
  .filter(is_system=True)
54
114
  )
55
115
 
116
+ def resolve_config_for(self, context):
117
+ return self.get_queryset().resolve_config_for(context)
118
+
56
119
 
57
120
  @core.register_model
58
121
  class Carrier(core.OwnedEntity):
59
122
  class Meta:
60
123
  ordering = ["test_mode", "-created_at"]
61
124
 
125
+ CONTEXT_RELATIONS = ["rate_sheet"]
126
+
62
127
  objects = Manager()
63
128
  user_carriers = CarrierManager()
64
129
  system_carriers = SystemCarrierManager()
@@ -130,6 +195,11 @@ class Carrier(core.OwnedEntity):
130
195
  on_delete=models.SET_NULL,
131
196
  )
132
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
+
133
203
  def __str__(self):
134
204
  return self.carrier_id
135
205
 
@@ -148,7 +218,7 @@ class Carrier(core.OwnedEntity):
148
218
  @property
149
219
  def carrier_name(self):
150
220
  return (
151
- self.credentials.get("custom_carrier_name")
221
+ "generic"
152
222
  if "custom_carrier_name" in self.credentials
153
223
  else lib.failsafe(lambda: self.carrier_code) or "generic"
154
224
  )
@@ -157,9 +227,11 @@ class Carrier(core.OwnedEntity):
157
227
  def display_name(self):
158
228
  import karrio.references as references
159
229
 
160
- return self.credentials.get("display_name") or references.REFERENCES[
161
- "carriers"
162
- ].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
+ )
163
235
 
164
236
  @property
165
237
  def carrier_config(self):
@@ -167,7 +239,16 @@ class Carrier(core.OwnedEntity):
167
239
 
168
240
  @property
169
241
  def config(self) -> dict:
170
- 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 {}
171
252
 
172
253
  @property
173
254
  def services(self) -> typing.Optional[typing.List[dict]]:
@@ -191,7 +272,14 @@ class Carrier(core.OwnedEntity):
191
272
 
192
273
  if any(self.services or []):
193
274
  _computed_data.update(
194
- 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
+ ]
195
283
  )
196
284
 
197
285
  # override the config with the system config
@@ -209,27 +297,24 @@ class Carrier(core.OwnedEntity):
209
297
  @property
210
298
  def gateway(self) -> gateway.Gateway:
211
299
  import karrio.server.core.middleware as middleware
300
+ import karrio.server.core.config as system_config
212
301
 
213
302
  _context = middleware.SessionContext.get_current_request()
214
303
  _tracer = getattr(_context, "tracer", lib.Tracer())
215
304
  _cache = lib.Cache(caching.cache)
305
+ _config = lib.SystemConfig(system_config.config)
216
306
 
217
307
  return karrio.gateway[self.ext].create(
218
308
  self.data.to_dict(),
219
309
  _tracer,
220
310
  _cache,
311
+ _config,
221
312
  )
222
313
 
223
314
  @staticmethod
224
315
  def resolve_config(
225
316
  carrier, is_user_config: bool = False, is_system_config: bool = False
226
317
  ):
227
- """Resolve the config for a carrier.
228
- Here are the rules:
229
- - If the carrier is a system carrier, return the first config with no org
230
- - If the carrier is an org carrier, return the first config from the org
231
- - If the carrier is a user carrier, return the first config from the user
232
- """
233
318
  import karrio.server.serializers as serializers
234
319
  import karrio.server.core.middleware as middleware
235
320
  from django.contrib.auth.models import AnonymousUser