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.
- karrio/server/core/authentication.py +59 -25
- karrio/server/core/config.py +31 -0
- karrio/server/core/datatypes.py +30 -4
- karrio/server/core/dataunits.py +53 -22
- karrio/server/core/exceptions.py +287 -17
- karrio/server/core/filters.py +14 -0
- karrio/server/core/gateway.py +285 -10
- karrio/server/core/logging.py +403 -0
- karrio/server/core/management/commands/runserver.py +5 -0
- karrio/server/core/middleware.py +104 -2
- karrio/server/core/migrations/0006_add_api_log_requested_at_index.py +22 -0
- 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 +183 -10
- 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 +17 -2
- karrio/server/iam/signals.py +2 -4
- karrio/server/providers/admin.py +1 -1
- karrio/server/providers/management/commands/migrate_rate_sheets.py +101 -0
- karrio/server/providers/migrations/0082_add_zone_identifiers.py +50 -0
- karrio/server/providers/migrations/0083_add_optimized_rate_sheet_structure.py +33 -0
- karrio/server/providers/migrations/0084_alter_servicelevel_currency.py +168 -0
- 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/__init__.py +1 -2
- karrio/server/providers/models/carrier.py +103 -18
- karrio/server/providers/models/service.py +188 -1
- karrio/server/providers/models/sheet.py +371 -0
- 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/samples.py +1 -1
- karrio/server/serializers/abstract.py +116 -21
- karrio/server/tracing/migrations/0007_tracingrecord_tracing_created_at_idx.py +19 -0
- karrio/server/tracing/models.py +2 -0
- karrio/server/tracing/utils.py +5 -8
- karrio/server/user/migrations/0007_user_metadata.py +25 -0
- karrio/server/user/models.py +38 -23
- karrio/server/user/serializers.py +1 -0
- karrio/server/user/templates/registration/registration_confirm_email.html +1 -1
- {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/METADATA +2 -2
- {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/RECORD +67 -86
- karrio/server/providers/extension/__init__.py +0 -1
- karrio/server/providers/extension/models/__init__.py +0 -1
- karrio/server/providers/extension/models/allied_express.py +0 -22
- karrio/server/providers/extension/models/allied_express_local.py +0 -22
- karrio/server/providers/extension/models/amazon_shipping.py +0 -27
- karrio/server/providers/extension/models/aramex.py +0 -25
- karrio/server/providers/extension/models/asendia_us.py +0 -21
- karrio/server/providers/extension/models/australiapost.py +0 -20
- karrio/server/providers/extension/models/boxknight.py +0 -19
- karrio/server/providers/extension/models/bpost.py +0 -21
- karrio/server/providers/extension/models/canadapost.py +0 -21
- karrio/server/providers/extension/models/canpar.py +0 -19
- karrio/server/providers/extension/models/chronopost.py +0 -22
- karrio/server/providers/extension/models/colissimo.py +0 -22
- karrio/server/providers/extension/models/dhl_express.py +0 -23
- karrio/server/providers/extension/models/dhl_parcel_de.py +0 -25
- karrio/server/providers/extension/models/dhl_poland.py +0 -22
- karrio/server/providers/extension/models/dhl_universal.py +0 -19
- karrio/server/providers/extension/models/dicom.py +0 -20
- karrio/server/providers/extension/models/dpd.py +0 -37
- karrio/server/providers/extension/models/dpdhl.py +0 -26
- karrio/server/providers/extension/models/easypost.py +0 -20
- karrio/server/providers/extension/models/eshipper.py +0 -21
- karrio/server/providers/extension/models/fedex.py +0 -25
- karrio/server/providers/extension/models/fedex_ws.py +0 -24
- karrio/server/providers/extension/models/freightcom.py +0 -21
- karrio/server/providers/extension/models/generic.py +0 -35
- karrio/server/providers/extension/models/geodis.py +0 -22
- karrio/server/providers/extension/models/hay_post.py +0 -22
- karrio/server/providers/extension/models/laposte.py +0 -19
- karrio/server/providers/extension/models/locate2u.py +0 -22
- karrio/server/providers/extension/models/nationex.py +0 -22
- karrio/server/providers/extension/models/purolator.py +0 -21
- karrio/server/providers/extension/models/roadie.py +0 -18
- karrio/server/providers/extension/models/royalmail.py +0 -19
- karrio/server/providers/extension/models/sendle.py +0 -22
- karrio/server/providers/extension/models/tge.py +0 -63
- karrio/server/providers/extension/models/tnt.py +0 -23
- karrio/server/providers/extension/models/ups.py +0 -23
- karrio/server/providers/extension/models/usps.py +0 -23
- karrio/server/providers/extension/models/usps_international.py +0 -23
- karrio/server/providers/extension/models/usps_wt.py +0 -24
- karrio/server/providers/extension/models/usps_wt_international.py +0 -24
- karrio/server/providers/extension/models/zoom2u.py +0 -23
- karrio/server/providers/tests.py +0 -3
- {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/WHEEL +0 -0
- {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
|
+
]
|
karrio/server/providers/migrations/0086_rename_dhl_parcel_de_customer_number_to_billing_number.py
ADDED
|
@@ -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
|
+
]
|
karrio/server/providers/migrations/0090_ratesheet_surcharges_servicelevel_zone_surcharge_ids.py
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
45
|
-
.get_queryset()
|
|
105
|
+
CarrierQuerySet(self.model, using=self._db)
|
|
46
106
|
.prefetch_related(
|
|
47
|
-
"
|
|
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
|
-
|
|
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
|
|
161
|
-
"
|
|
162
|
-
|
|
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
|
-
|
|
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=[
|
|
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
|