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,91 @@
1
+ # Generated migration to remove permission groups
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ def remove_permission_groups(apps, schema_editor):
7
+ """
8
+ Remove all permission groups that were created by setup_groups().
9
+
10
+ This migration removes the following groups:
11
+ - manage_apps
12
+ - manage_carriers (deprecated)
13
+ - read_carriers
14
+ - write_carriers
15
+ - manage_orders
16
+ - manage_team
17
+ - manage_org_owner
18
+ - manage_webhooks
19
+ - manage_data
20
+ - manage_shipments
21
+ - manage_system
22
+ """
23
+ Group = apps.get_model("user", "Group")
24
+ ContextPermission = apps.get_model("iam", "ContextPermission")
25
+
26
+ # List of groups to remove
27
+ groups_to_remove = [
28
+ "manage_apps",
29
+ "manage_carriers",
30
+ "read_carriers",
31
+ "write_carriers",
32
+ "manage_orders",
33
+ "manage_team",
34
+ "manage_org_owner",
35
+ "manage_webhooks",
36
+ "manage_data",
37
+ "manage_shipments",
38
+ "manage_system",
39
+ "manage_pickups",
40
+ "manage_trackers",
41
+ ]
42
+
43
+ # First, remove the groups from all ContextPermissions
44
+ for group_name in groups_to_remove:
45
+ group = Group.objects.filter(name=group_name).first()
46
+ if group:
47
+ # Remove this group from all context permissions
48
+ for ctx_perm in ContextPermission.objects.filter(groups=group):
49
+ ctx_perm.groups.remove(group)
50
+
51
+ # Then delete the groups themselves
52
+ Group.objects.filter(name__in=groups_to_remove).delete()
53
+
54
+
55
+ def reverse_migration(apps, schema_editor):
56
+ """
57
+ Reverse migration - recreate groups (without permissions, which were set dynamically).
58
+ Note: This won't restore the full permission setup, only creates empty groups.
59
+ """
60
+ Group = apps.get_model("user", "Group")
61
+
62
+ groups_to_create = [
63
+ "manage_apps",
64
+ "manage_carriers",
65
+ "read_carriers",
66
+ "write_carriers",
67
+ "manage_orders",
68
+ "manage_team",
69
+ "manage_org_owner",
70
+ "manage_webhooks",
71
+ "manage_data",
72
+ "manage_shipments",
73
+ "manage_system",
74
+ "manage_pickups",
75
+ "manage_trackers",
76
+ ]
77
+
78
+ for group_name in groups_to_create:
79
+ Group.objects.get_or_create(name=group_name)
80
+
81
+
82
+ class Migration(migrations.Migration):
83
+
84
+ dependencies = [
85
+ ("iam", "0002_setup_carrier_permission_groups"),
86
+ ("user", "0004_group"),
87
+ ]
88
+
89
+ operations = [
90
+ migrations.RunPython(remove_permission_groups, reverse_migration),
91
+ ]
@@ -1,134 +1,7 @@
1
- import typing
2
- import logging
3
- from django.db import models
4
- from django.contrib.auth import get_user_model
5
- from django.contrib.auth.models import Permission
6
-
7
- import karrio.server.core.utils as utils
8
- import karrio.server.user.models as users
9
- import karrio.server.iam.serializers as serializers
10
-
11
- logger = logging.getLogger(__name__)
12
- User = get_user_model()
13
-
14
-
15
- @utils.skip_on_loadata
16
- @utils.async_wrapper
17
- @utils.tenant_aware
18
- def setup_groups(**_):
19
- """This function create all standard group permissions if they don't exsist."""
20
- print("> setting up permissions")
21
-
22
- # manage_apps
23
- setup_group(
24
- serializers.PermissionGroup.manage_apps.name,
25
- permissions=Permission.objects.filter(content_type__app_label="apps"),
26
- )
27
-
28
- # manage_carriers
29
- setup_group(
30
- serializers.PermissionGroup.manage_carriers.name,
31
- permissions=[
32
- *Permission.objects.filter(content_type__app_label="providers"),
33
- *Permission.objects.filter(
34
- models.Q(content_type__app_label="orgs")
35
- & models.Q(name__icontains="carrier")
36
- ),
37
- ],
38
- override=True,
39
- )
40
-
41
- # manage_orders
42
- setup_group(
43
- serializers.PermissionGroup.manage_orders.name,
44
- permissions=Permission.objects.filter(content_type__app_label="orders"),
45
- )
46
-
47
- # manage_team
48
- setup_group(
49
- serializers.PermissionGroup.manage_team.name,
50
- permissions=(
51
- Permission.objects.filter(
52
- content_type__app_label="orgs", name__icontains="organization"
53
- ).exclude(name__icontains="owner")
54
- ),
55
- override=True,
56
- )
57
-
58
- # manage_org_owner
59
- setup_group(
60
- serializers.PermissionGroup.manage_org_owner.name,
61
- permissions=Permission.objects.filter(
62
- content_type__model="OrganizationOwner".lower()
63
- ),
64
- )
65
-
66
- # manage_webhooks
67
- setup_group(
68
- serializers.PermissionGroup.manage_webhooks.name,
69
- permissions=Permission.objects.filter(content_type__model="Webhook".lower()),
70
- )
71
-
72
- # manage_data
73
- setup_group(
74
- serializers.PermissionGroup.manage_data.name,
75
- permissions=[
76
- *Permission.objects.filter(
77
- content_type__app_label__in=["data", "graph", "documents"]
78
- ),
79
- *Permission.objects.filter(
80
- content_type__app_label="audit", name__icontains="view"
81
- ),
82
- *Permission.objects.filter(
83
- content_type__app_label="rest_framework_tracking",
84
- name__icontains="view",
85
- ),
86
- ],
87
- override=True,
88
- )
89
-
90
- # manage_shipments
91
- setup_group(
92
- serializers.PermissionGroup.manage_shipments.name,
93
- permissions=[
94
- *Permission.objects.filter(content_type__app_label="manager"),
95
- *Permission.objects.filter(
96
- models.Q(content_type__app_label="orgs")
97
- & (
98
- models.Q(name__icontains="address")
99
- | models.Q(name__icontains="parcel")
100
- | models.Q(name__icontains="commodity")
101
- | models.Q(name__icontains="customs")
102
- | models.Q(name__icontains="pickup")
103
- | models.Q(name__icontains="tracker")
104
- | models.Q(name__icontains="shipment")
105
- )
106
- ),
107
- ],
108
- )
109
-
110
- # manage_system
111
- setup_group(
112
- serializers.PermissionGroup.manage_system.name,
113
- permissions=Permission.objects.filter(
114
- content_type__app_label__in=[
115
- "admin",
116
- "user",
117
- "pricing",
118
- "providers",
119
- "audit",
120
- "database",
121
- "rest_framework_tracking",
122
- ]
123
- ),
124
- )
125
-
126
-
127
- def setup_group(
128
- name: str, permissions: typing.List[Permission], override: bool = False
129
- ):
130
- group, created = users.Group.objects.get_or_create(name=name)
131
-
132
- if created or override:
133
- group.permissions.set(permissions)
134
- group.save()
1
+ # This module previously contained permission group setup logic.
2
+ # The setup_groups() function has been removed to:
3
+ # 1. Fix Django warning about database access during app initialization
4
+ # 2. Prepare for a better RBAC implementation in the future
5
+ #
6
+ # The PermissionGroup enum and ROLES_GROUPS mapping are preserved in
7
+ # karrio.server.iam.serializers for organization role management.
@@ -9,7 +9,9 @@ class PermissionGroup(lib.StrEnum):
9
9
  manage_orders = "manage_orders"
10
10
  manage_data = "manage_data"
11
11
  manage_pickups = "manage_pickups"
12
- manage_carriers = "manage_carriers"
12
+ manage_carriers = "manage_carriers" # Deprecated: use read_carriers + write_carriers
13
+ read_carriers = "read_carriers"
14
+ write_carriers = "write_carriers"
13
15
  manage_trackers = "manage_trackers"
14
16
  manage_webhooks = "manage_webhooks"
15
17
  manage_shipments = "manage_shipments"
@@ -22,7 +24,8 @@ ROLES_GROUPS: typing.Dict[str, typing.List[str]] = {
22
24
  PermissionGroup.manage_org_owner.value,
23
25
  PermissionGroup.manage_team.value,
24
26
  PermissionGroup.manage_apps.value,
25
- PermissionGroup.manage_carriers.value,
27
+ PermissionGroup.read_carriers.value,
28
+ PermissionGroup.write_carriers.value,
26
29
  PermissionGroup.manage_webhooks.value,
27
30
  PermissionGroup.manage_data.value,
28
31
  PermissionGroup.manage_orders.value,
@@ -33,10 +36,12 @@ ROLES_GROUPS: typing.Dict[str, typing.List[str]] = {
33
36
  "admin": [
34
37
  PermissionGroup.manage_team.value,
35
38
  PermissionGroup.manage_apps.value,
36
- PermissionGroup.manage_carriers.value,
39
+ PermissionGroup.read_carriers.value,
40
+ PermissionGroup.write_carriers.value,
37
41
  ],
38
42
  "developer": [
39
43
  PermissionGroup.manage_webhooks.value,
44
+ PermissionGroup.read_carriers.value,
40
45
  ],
41
46
  "member": [
42
47
  PermissionGroup.manage_data.value,
@@ -44,5 +49,6 @@ ROLES_GROUPS: typing.Dict[str, typing.List[str]] = {
44
49
  PermissionGroup.manage_pickups.value,
45
50
  PermissionGroup.manage_trackers.value,
46
51
  PermissionGroup.manage_shipments.value,
52
+ PermissionGroup.read_carriers.value,
47
53
  ],
48
54
  }
@@ -1,17 +1,15 @@
1
- import logging
2
1
  from django.db.models import signals
3
2
 
3
+ from karrio.server.core.logging import logger
4
4
  import karrio.server.core.utils as utils
5
5
  import karrio.server.user.models as user
6
6
  import karrio.server.iam.models as models
7
7
 
8
- logger = logging.getLogger(__name__)
9
-
10
8
 
11
9
  def register_all():
12
10
  signals.post_delete.connect(context_object_deleted, sender=user.Token)
13
11
 
14
- logger.info("karrio.iam signals registered...")
12
+ logger.info("Signal registration complete", module="karrio.iam")
15
13
 
16
14
 
17
15
  @utils.disable_for_loaddata
@@ -354,7 +354,7 @@ class LabelTemplateAdmin(admin.ModelAdmin):
354
354
  return False
355
355
 
356
356
 
357
- @utils.skip_on_commands()
357
+ @utils.skip_on_commands(["loaddata", "migrate", "makemigrations", "shell"])
358
358
  def register_carrier_admins():
359
359
  for carrier_name, display_name in ref.REFERENCES["carriers"].items():
360
360
  proxy = providers.create_carrier_proxy(carrier_name, display_name)
@@ -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
+ ]