karrio-server-graph 2026.1__py3-none-any.whl → 2026.1.3__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.
@@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType
8
8
  from django_email_verification import confirm as email_verification
9
9
  from django_otp.plugins.otp_email import models as otp
10
10
  from django.utils.translation import gettext_lazy as _
11
- from django.db import transaction
11
+ from django.db import transaction, models
12
12
 
13
13
  from karrio.server.core.utils import ConfirmationToken, send_email
14
14
  from karrio.server.user.serializers import TokenSerializer
@@ -490,7 +490,12 @@ class DeleteMutation(utils.BaseMutation):
490
490
  if validator:
491
491
  validator(instance, context=info.context)
492
492
 
493
+ # Get the shipment FK if it exists and is a direct relationship
494
+ # (not a GenericRelation/RelatedManager)
493
495
  shipment = getattr(instance, "shipment", None)
496
+ if shipment is not None and not isinstance(shipment, manager.Shipment):
497
+ shipment = None
498
+
494
499
  instance.delete(keep_parents=True)
495
500
 
496
501
  if shipment is not None:
@@ -514,6 +519,7 @@ class CreateRateSheetMutation(utils.BaseMutation):
514
519
  zones_data = data.pop("zones", [])
515
520
  surcharges_data = data.pop("surcharges", [])
516
521
  service_rates_data = data.pop("service_rates", [])
522
+ # Note: origin_countries stays in data - saved via serializer
517
523
  services_data = [
518
524
  (svc.copy() if isinstance(svc, dict) else dict(svc))
519
525
  for svc in data.get("services", [])
@@ -542,7 +548,7 @@ class CreateRateSheetMutation(utils.BaseMutation):
542
548
 
543
549
  # Link carriers
544
550
  if any(carriers):
545
- providers.Carrier.access_by(info.context.request).filter(
551
+ providers.CarrierConnection.access_by(info.context.request).filter(
546
552
  carrier_code=rate_sheet.carrier_name,
547
553
  id__in=carriers,
548
554
  ).update(rate_sheet=rate_sheet)
@@ -573,6 +579,7 @@ class UpdateRateSheetMutation(utils.BaseMutation):
573
579
  )
574
580
  data = input.copy()
575
581
  carriers = data.pop("carriers", [])
582
+ # Note: origin_countries stays in data - saved via serializer
576
583
 
577
584
  serializer = serializers.RateSheetModelSerializer(
578
585
  instance,
@@ -595,7 +602,7 @@ class UpdateRateSheetMutation(utils.BaseMutation):
595
602
 
596
603
  # Link/unlink carriers
597
604
  if any(carriers):
598
- carrier_qs = providers.Carrier.access_by(info.context.request).filter(
605
+ carrier_qs = providers.CarrierConnection.access_by(info.context.request).filter(
599
606
  carrier_code=rate_sheet.carrier_name
600
607
  )
601
608
  carrier_qs.filter(id__in=carriers).update(rate_sheet=rate_sheet)
@@ -987,72 +994,348 @@ class ChangeShipmentStatusMutation(utils.BaseMutation):
987
994
  return ChangeShipmentStatusMutation(shipment=shipment) # type:ignore
988
995
 
989
996
 
990
- def create_template_mutation(name: str, template_type: str) -> typing.Type:
991
- _type: typing.Any = dict(
992
- address=types.AddressTemplateType,
993
- customs=types.CustomsTemplateType,
994
- parcel=types.ParcelTemplateType,
995
- ).get(template_type)
996
-
997
- @strawberry.type
998
- class _Mutation(utils.BaseMutation):
999
- template: typing.Optional[_type] = None
1000
-
1001
- @staticmethod
1002
- @utils.authentication_required
1003
- @transaction.atomic
1004
- def mutate(info: Info, **input) -> name: # type:ignore
1005
- data = input.copy()
1006
- instance = (
1007
- graph.Template.access_by(info.context.request).get(id=input["id"])
1008
- if "id" in input
1009
- else None
997
+ def _clear_default_address_templates(info, exclude_id=None):
998
+ """Clear is_default flag on all address templates except the specified one."""
999
+ queryset = manager.Address.access_by(info.context.request).filter(
1000
+ meta__is_default=True,
1001
+ meta__label__isnull=False,
1002
+ )
1003
+ if exclude_id:
1004
+ queryset = queryset.exclude(id=exclude_id)
1005
+
1006
+ for address in queryset:
1007
+ address.meta = {**(address.meta or {}), "is_default": False}
1008
+ address.save(update_fields=["meta"])
1009
+
1010
+
1011
+ def _clear_default_parcel_templates(info, exclude_id=None):
1012
+ """Clear is_default flag on all parcel templates except the specified one."""
1013
+ queryset = manager.Parcel.access_by(info.context.request).filter(
1014
+ meta__is_default=True,
1015
+ meta__label__isnull=False,
1016
+ )
1017
+ if exclude_id:
1018
+ queryset = queryset.exclude(id=exclude_id)
1019
+
1020
+ for parcel in queryset:
1021
+ parcel.meta = {**(parcel.meta or {}), "is_default": False}
1022
+ parcel.save(update_fields=["meta"])
1023
+
1024
+
1025
+ @strawberry.type
1026
+ class CreateAddressMutation(utils.BaseMutation):
1027
+ """Create a saved address using Address model with meta.label."""
1028
+
1029
+ address: typing.Optional[types.AddressTemplateType] = None
1030
+
1031
+ @staticmethod
1032
+ @utils.authentication_required
1033
+ @transaction.atomic
1034
+ def mutate(info: Info, **input) -> "CreateAddressMutation":
1035
+ data = input.copy()
1036
+
1037
+ # Extract meta from input (flat structure)
1038
+ meta_input = data.pop("meta", {})
1039
+ meta = {
1040
+ k: v
1041
+ for k, v in meta_input.items()
1042
+ if not utils.is_unset(v)
1043
+ }
1044
+
1045
+ # If setting as default, clear existing default
1046
+ if meta.get("is_default"):
1047
+ _clear_default_address_templates(info)
1048
+
1049
+ # Ensure is_default has a value
1050
+ if "is_default" not in meta:
1051
+ meta["is_default"] = False
1052
+
1053
+ serializer = serializers.AddressModelSerializer(
1054
+ data={**data, "meta": meta},
1055
+ context=info.context.request,
1056
+ )
1057
+ serializer.is_valid(raise_exception=True)
1058
+ address = serializer.save()
1059
+
1060
+ return CreateAddressMutation(address=address) # type:ignore
1061
+
1062
+
1063
+ @strawberry.type
1064
+ class UpdateAddressMutation(utils.BaseMutation):
1065
+ """Update a saved address."""
1066
+
1067
+ address: typing.Optional[types.AddressTemplateType] = None
1068
+
1069
+ @staticmethod
1070
+ @utils.authentication_required
1071
+ @transaction.atomic
1072
+ def mutate(info: Info, **input) -> "UpdateAddressMutation":
1073
+ data = input.copy()
1074
+ id = data.pop("id")
1075
+
1076
+ # Extract meta from input (flat structure)
1077
+ meta_input = data.pop("meta", {})
1078
+ meta_updates = {
1079
+ k: v
1080
+ for k, v in meta_input.items()
1081
+ if not utils.is_unset(v)
1082
+ }
1083
+
1084
+ instance = manager.Address.access_by(info.context.request).get(id=id)
1085
+
1086
+ # Verify this is a template (has meta.label)
1087
+ if not (instance.meta or {}).get("label"):
1088
+ raise utils.ValidationError(
1089
+ "This address is not a template. Only templates can be updated here."
1010
1090
  )
1011
- customs_data = data.get("customs", {})
1012
-
1013
- if "commodities" in customs_data and instance is not None:
1014
- save_many_to_many_data(
1015
- "commodities",
1016
- serializers.CommodityModelSerializer,
1017
- getattr(instance, "customs", None),
1018
- payload=customs_data,
1019
- context=info.context.request,
1020
- )
1021
1091
 
1022
- serializer = serializers.TemplateModelSerializer(
1023
- instance,
1024
- data=data,
1025
- context=info.context.request,
1026
- partial=(instance is not None),
1092
+ # Update meta field
1093
+ meta = {**(instance.meta or {}), **meta_updates}
1094
+
1095
+ # If setting as default, clear existing default
1096
+ if meta_updates.get("is_default"):
1097
+ _clear_default_address_templates(info, exclude_id=id)
1098
+
1099
+ serializer = serializers.AddressModelSerializer(
1100
+ instance,
1101
+ data={**data, "meta": meta},
1102
+ context=info.context.request,
1103
+ partial=True,
1104
+ )
1105
+ serializer.is_valid(raise_exception=True)
1106
+ address = serializer.save()
1107
+
1108
+ return UpdateAddressMutation(address=address) # type:ignore
1109
+
1110
+
1111
+ @strawberry.type
1112
+ class CreateParcelMutation(utils.BaseMutation):
1113
+ """Create a saved parcel using Parcel model with meta.label."""
1114
+
1115
+ parcel: typing.Optional[types.ParcelTemplateType] = None
1116
+
1117
+ @staticmethod
1118
+ @utils.authentication_required
1119
+ @transaction.atomic
1120
+ def mutate(info: Info, **input) -> "CreateParcelMutation":
1121
+ data = input.copy()
1122
+
1123
+ # Extract meta from input (flat structure)
1124
+ meta_input = data.pop("meta", {})
1125
+ meta = {
1126
+ k: v
1127
+ for k, v in meta_input.items()
1128
+ if not utils.is_unset(v)
1129
+ }
1130
+
1131
+ # If setting as default, clear existing default
1132
+ if meta.get("is_default"):
1133
+ _clear_default_parcel_templates(info)
1134
+
1135
+ # Ensure is_default has a value
1136
+ if "is_default" not in meta:
1137
+ meta["is_default"] = False
1138
+
1139
+ serializer = serializers.ParcelModelSerializer(
1140
+ data={**data, "meta": meta},
1141
+ context=info.context.request,
1142
+ )
1143
+ serializer.is_valid(raise_exception=True)
1144
+ parcel = serializer.save()
1145
+
1146
+ return CreateParcelMutation(parcel=parcel) # type:ignore
1147
+
1148
+
1149
+ @strawberry.type
1150
+ class UpdateParcelMutation(utils.BaseMutation):
1151
+ """Update a saved parcel."""
1152
+
1153
+ parcel: typing.Optional[types.ParcelTemplateType] = None
1154
+
1155
+ @staticmethod
1156
+ @utils.authentication_required
1157
+ @transaction.atomic
1158
+ def mutate(info: Info, **input) -> "UpdateParcelMutation":
1159
+ data = input.copy()
1160
+ id = data.pop("id")
1161
+
1162
+ # Extract meta from input (flat structure)
1163
+ meta_input = data.pop("meta", {})
1164
+ meta_updates = {
1165
+ k: v
1166
+ for k, v in meta_input.items()
1167
+ if not utils.is_unset(v)
1168
+ }
1169
+
1170
+ instance = manager.Parcel.access_by(info.context.request).get(id=id)
1171
+
1172
+ # Verify this is a template (has meta.label)
1173
+ if not (instance.meta or {}).get("label"):
1174
+ raise utils.ValidationError(
1175
+ "This parcel is not a template. Only templates can be updated here."
1027
1176
  )
1028
1177
 
1029
- serializer.is_valid(raise_exception=True)
1030
- template = serializer.save()
1178
+ # Update meta field
1179
+ meta = {**(instance.meta or {}), **meta_updates}
1031
1180
 
1032
- return _Mutation(template=template) # type:ignore
1181
+ # If setting as default, clear existing default
1182
+ if meta_updates.get("is_default"):
1183
+ _clear_default_parcel_templates(info, exclude_id=id)
1033
1184
 
1034
- return strawberry.type(type(name, (_Mutation,), {}))
1185
+ serializer = serializers.ParcelModelSerializer(
1186
+ instance,
1187
+ data={**data, "meta": meta},
1188
+ context=info.context.request,
1189
+ partial=True,
1190
+ )
1191
+ serializer.is_valid(raise_exception=True)
1192
+ parcel = serializer.save()
1035
1193
 
1194
+ return UpdateParcelMutation(parcel=parcel) # type:ignore
1036
1195
 
1037
- CreateAddressTemplateMutation = create_template_mutation(
1038
- "CreateAddressTemplateMutation", "address"
1039
- )
1040
- UpdateAddressTemplateMutation = create_template_mutation(
1041
- "UpdateAddressTemplateMutation",
1042
- "address",
1043
- )
1044
- CreateCustomsTemplateMutation = create_template_mutation(
1045
- "CreateCustomsTemplateMutation", "customs"
1046
- )
1047
- UpdateCustomsTemplateMutation = create_template_mutation(
1048
- "UpdateCustomsTemplateMutation", "customs"
1049
- )
1050
- CreateParcelTemplateMutation = create_template_mutation(
1051
- "CreateParcelTemplateMutation", "parcel"
1052
- )
1053
- UpdateParcelTemplateMutation = create_template_mutation(
1054
- "UpdateParcelTemplateMutation", "parcel"
1055
- )
1196
+
1197
+ @strawberry.type
1198
+ class DeleteAddressMutation(utils.BaseMutation):
1199
+ """Delete a saved address."""
1200
+
1201
+ id: str = strawberry.UNSET
1202
+
1203
+ @staticmethod
1204
+ @utils.authentication_required
1205
+ def mutate(info: Info, **input) -> "DeleteAddressMutation":
1206
+ id = input.get("id")
1207
+ instance = manager.Address.access_by(info.context.request).get(id=id)
1208
+
1209
+ # Verify this is a saved address (has meta.label)
1210
+ if not (instance.meta or {}).get("label"):
1211
+ raise utils.ValidationError(
1212
+ "This address is not a saved address. Only saved addresses can be deleted here."
1213
+ )
1214
+
1215
+ instance.delete(keep_parents=True)
1216
+ return DeleteAddressMutation(id=id) # type:ignore
1217
+
1218
+
1219
+ @strawberry.type
1220
+ class DeleteParcelMutation(utils.BaseMutation):
1221
+ """Delete a saved parcel."""
1222
+
1223
+ id: str = strawberry.UNSET
1224
+
1225
+ @staticmethod
1226
+ @utils.authentication_required
1227
+ def mutate(info: Info, **input) -> "DeleteParcelMutation":
1228
+ id = input.get("id")
1229
+ instance = manager.Parcel.access_by(info.context.request).get(id=id)
1230
+
1231
+ # Verify this is a saved parcel (has meta.label)
1232
+ if not (instance.meta or {}).get("label"):
1233
+ raise utils.ValidationError(
1234
+ "This parcel is not a saved parcel. Only saved parcels can be deleted here."
1235
+ )
1236
+
1237
+ instance.delete(keep_parents=True)
1238
+ return DeleteParcelMutation(id=id) # type:ignore
1239
+
1240
+
1241
+ def _clear_default_product_templates(info, exclude_id=None):
1242
+ """Clear is_default flag on all product templates except the specified one."""
1243
+ queryset = manager.Commodity.access_by(info.context.request).filter(
1244
+ meta__is_default=True,
1245
+ meta__label__isnull=False,
1246
+ )
1247
+ if exclude_id:
1248
+ queryset = queryset.exclude(id=exclude_id)
1249
+
1250
+ for commodity in queryset:
1251
+ commodity.meta = {**(commodity.meta or {}), "is_default": False}
1252
+ commodity.save(update_fields=["meta"])
1253
+
1254
+
1255
+ @strawberry.type
1256
+ class CreateProductMutation(utils.BaseMutation):
1257
+ """Create a saved product using Commodity model with meta.label."""
1258
+
1259
+ product: typing.Optional[types.ProductTemplateType] = None
1260
+
1261
+ @staticmethod
1262
+ @utils.authentication_required
1263
+ @transaction.atomic
1264
+ def mutate(info: Info, **input) -> "CreateProductMutation":
1265
+ data = input.copy()
1266
+
1267
+ # Extract meta from input (flat structure)
1268
+ meta_input = data.pop("meta", {})
1269
+ meta = {
1270
+ k: v
1271
+ for k, v in meta_input.items()
1272
+ if not utils.is_unset(v)
1273
+ }
1274
+
1275
+ # If setting as default, clear existing default
1276
+ if meta.get("is_default"):
1277
+ _clear_default_product_templates(info)
1278
+
1279
+ # Ensure is_default has a value
1280
+ if "is_default" not in meta:
1281
+ meta["is_default"] = False
1282
+
1283
+ serializer = serializers.CommodityModelSerializer(
1284
+ data={**data, "meta": meta},
1285
+ context=info.context.request,
1286
+ )
1287
+ serializer.is_valid(raise_exception=True)
1288
+ product = serializer.save()
1289
+
1290
+ return CreateProductMutation(product=product) # type:ignore
1291
+
1292
+
1293
+ @strawberry.type
1294
+ class UpdateProductMutation(utils.BaseMutation):
1295
+ """Update a saved product."""
1296
+
1297
+ product: typing.Optional[types.ProductTemplateType] = None
1298
+
1299
+ @staticmethod
1300
+ @utils.authentication_required
1301
+ @transaction.atomic
1302
+ def mutate(info: Info, **input) -> "UpdateProductMutation":
1303
+ data = input.copy()
1304
+ id = data.pop("id")
1305
+
1306
+ # Extract meta from input (flat structure)
1307
+ meta_input = data.pop("meta", {})
1308
+ meta_updates = {
1309
+ k: v
1310
+ for k, v in meta_input.items()
1311
+ if not utils.is_unset(v)
1312
+ }
1313
+
1314
+ instance = manager.Commodity.access_by(info.context.request).get(id=id)
1315
+
1316
+ # Verify this is a template (has meta.label)
1317
+ if not instance.is_template:
1318
+ raise utils.ValidationError(
1319
+ "This commodity is not a template. Only templates can be updated here."
1320
+ )
1321
+
1322
+ # Update meta field
1323
+ meta = {**(instance.meta or {}), **meta_updates}
1324
+
1325
+ # If setting as default, clear existing default
1326
+ if meta_updates.get("is_default"):
1327
+ _clear_default_product_templates(info, exclude_id=id)
1328
+
1329
+ serializer = serializers.CommodityModelSerializer(
1330
+ instance,
1331
+ data={**data, "meta": meta},
1332
+ context=info.context.request,
1333
+ partial=True,
1334
+ )
1335
+ serializer.is_valid(raise_exception=True)
1336
+ product = serializer.save()
1337
+
1338
+ return UpdateProductMutation(product=product) # type:ignore
1056
1339
 
1057
1340
 
1058
1341
  @strawberry.type
@@ -1089,7 +1372,7 @@ class UpdateCarrierConnectionMutation(utils.BaseMutation):
1089
1372
  def mutate(info: Info, **input) -> "UpdateCarrierConnectionMutation":
1090
1373
  data = input.copy()
1091
1374
  id = data.get("id")
1092
- instance = providers.Carrier.access_by(info.context.request).get(id=id)
1375
+ instance = providers.CarrierConnection.access_by(info.context.request).get(id=id)
1093
1376
  connection = lib.identity(
1094
1377
  providers_serializers.CarrierConnectionModelSerializer.map(
1095
1378
  instance,
@@ -1115,37 +1398,35 @@ class SystemCarrierMutation(utils.BaseMutation):
1115
1398
  def mutate(
1116
1399
  info: Info, **input: inputs.SystemCarrierMutationInput
1117
1400
  ) -> "SystemCarrierMutation":
1401
+ from karrio.server.providers.serializers import BrokeredConnectionModelSerializer
1402
+
1118
1403
  pk = input.get("id")
1119
1404
  context = info.context.request
1120
- carrier = providers.Carrier.system_carriers.get(pk=pk)
1405
+ system_connection = providers.SystemConnection.objects.get(pk=pk)
1406
+
1407
+ # Build serializer data from input
1408
+ # Map 'config' to 'config_overrides' and 'enable' to 'is_enabled'
1409
+ data = {"system_connection_id": pk}
1121
1410
 
1122
1411
  if "enable" in input:
1123
- if input.get("enable"):
1124
- if hasattr(carrier, "active_orgs"):
1125
- carrier.active_orgs.add(info.context.request.org)
1126
- else:
1127
- carrier.active_users.add(info.context.request.user)
1128
- else:
1129
- if hasattr(carrier, "active_orgs"):
1130
- carrier.active_orgs.remove(info.context.request.org)
1131
- else:
1132
- carrier.active_users.remove(info.context.request.user)
1412
+ data["is_enabled"] = input.get("enable")
1133
1413
 
1134
1414
  if "config" in input:
1135
- config = providers.Carrier.resolve_config(carrier, is_user_config=True)
1136
- serializers.CarrierConfigModelSerializer.map(
1137
- instance=config,
1415
+ data["config_overrides"] = input.get("config") or {}
1416
+
1417
+ # Use the serializer to create or update the BrokeredConnection
1418
+ # The @owned_model_serializer decorator handles org linking automatically
1419
+ brokered = (
1420
+ BrokeredConnectionModelSerializer.map(
1421
+ data=data,
1138
1422
  context=context,
1139
- data={
1140
- "carrier": carrier.pk,
1141
- "config": process_dictionaries_mutations(
1142
- ["config"], (input["config"] or {}), config
1143
- ),
1144
- },
1145
- ).save()
1423
+ )
1424
+ .save()
1425
+ .instance
1426
+ )
1146
1427
 
1147
1428
  return SystemCarrierMutation(
1148
- carrier=providers.Carrier.system_carriers.get(pk=pk)
1429
+ carrier=system_connection
1149
1430
  ) # type: ignore
1150
1431
 
1151
1432