Skip to content

Commit 53158b2

Browse files
authored
fix: add special format transformations (#80)
special transformations to format inputs the way serializers expect adds handling for date only fields, decimal (vs float) and integer range
1 parent 4ee1b5e commit 53158b2

File tree

6 files changed

+352
-43
lines changed

6 files changed

+352
-43
lines changed

netbox_diode_plugin/api/applier.py

+2-16
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from django.db import models
1212
from rest_framework.exceptions import ValidationError as ValidationError
1313

14-
from .common import NON_FIELD_ERRORS, Change, ChangeSet, ChangeSetException, ChangeSetResult, ChangeType
14+
from .common import NON_FIELD_ERRORS, Change, ChangeSet, ChangeSetException, ChangeSetResult, ChangeType, error_from_validation_error
1515
from .plugin_utils import get_object_type_model, legal_fields
1616
from .supported_models import get_serializer_for_model
1717

@@ -35,7 +35,7 @@ def apply_changeset(change_set: ChangeSet, request) -> ChangeSetResult:
3535
data = _pre_apply(model_class, change, created)
3636
_apply_change(data, model_class, change, created, request)
3737
except ValidationError as e:
38-
raise _err_from_validation_error(e, object_type)
38+
raise error_from_validation_error(e, object_type)
3939
except ObjectDoesNotExist:
4040
raise _err(f"{object_type} with id {change.object_id} does not exist", object_type, "object_id")
4141
except TypeError as e:
@@ -129,17 +129,3 @@ def _err(message, object_name, field):
129129
object_name = "__all__"
130130
return ChangeSetException(message, errors={object_name: {field: [message]}})
131131

132-
def _err_from_validation_error(e, object_name):
133-
errors = {}
134-
if e.detail:
135-
if isinstance(e.detail, dict):
136-
errors[object_name] = e.detail
137-
elif isinstance(e.detail, (list, tuple)):
138-
errors[object_name] = {
139-
NON_FIELD_ERRORS: e.detail
140-
}
141-
else:
142-
errors[object_name] = {
143-
NON_FIELD_ERRORS: [e.detail]
144-
}
145-
return ChangeSetException("validation error", errors=errors)

netbox_diode_plugin/api/common.py

+17
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,20 @@ class AutoSlug:
235235

236236
field_name: str
237237
value: str
238+
239+
240+
def error_from_validation_error(e, object_name):
241+
"""Convert a drf ValidationError to a ChangeSetException."""
242+
errors = {}
243+
if e.detail:
244+
if isinstance(e.detail, dict):
245+
errors[object_name] = e.detail
246+
elif isinstance(e.detail, (list, tuple)):
247+
errors[object_name] = {
248+
NON_FIELD_ERRORS: e.detail
249+
}
250+
else:
251+
errors[object_name] = {
252+
NON_FIELD_ERRORS: [e.detail]
253+
}
254+
return ChangeSetException("validation error", errors=errors)

netbox_diode_plugin/api/differ.py

+27-22
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
from django.contrib.contenttypes.models import ContentType
1010
from django.core.exceptions import ValidationError
1111
from utilities.data import shallow_compare_dict
12+
from django.db.backends.postgresql.psycopg_any import NumericRange
1213

13-
from .common import Change, ChangeSet, ChangeSetException, ChangeSetResult, ChangeType, UnresolvedReference
14+
from .common import Change, ChangeSet, ChangeSetException, ChangeSetResult, ChangeType, error_from_validation_error
1415
from .plugin_utils import get_primary_value, legal_fields
1516
from .supported_models import extract_supported_models
1617
from .transformer import cleanup_unresolved_references, set_custom_field_defaults, transform_proto_json
@@ -78,29 +79,23 @@ def prechange_data_from_instance(instance) -> dict: # noqa: C901
7879
else:
7980
cfmap[cf.name] = cf.serialize(value)
8081
prechange_data["custom_fields"] = cfmap
81-
82+
prechange_data = _harmonize_formats(prechange_data)
8283
return prechange_data
8384

8485

85-
def _harmonize_formats(prechange_data: dict, postchange_data: dict):
86-
for k, v in prechange_data.items():
87-
if k.startswith('_'):
88-
continue
89-
if isinstance(v, datetime.datetime):
90-
prechange_data[k] = v.strftime("%Y-%m-%dT%H:%M:%SZ")
91-
elif isinstance(v, datetime.date):
92-
prechange_data[k] = v.strftime("%Y-%m-%d")
93-
elif isinstance(v, int) and k in postchange_data:
94-
val = postchange_data[k]
95-
if isinstance(val, UnresolvedReference):
96-
continue
97-
try:
98-
postchange_data[k] = int(val)
99-
except Exception:
100-
continue
101-
elif isinstance(v, dict):
102-
_harmonize_formats(v, postchange_data.get(k, {}))
86+
def _harmonize_formats(prechange_data):
87+
if isinstance(prechange_data, dict):
88+
return {k: _harmonize_formats(v) for k, v in prechange_data.items()}
89+
if isinstance(prechange_data, (list, tuple)):
90+
return [_harmonize_formats(v) for v in prechange_data]
91+
if isinstance(prechange_data, datetime.datetime):
92+
return prechange_data.strftime("%Y-%m-%dT%H:%M:%SZ")
93+
if isinstance(prechange_data, datetime.date):
94+
return prechange_data.strftime("%Y-%m-%d")
95+
if isinstance(prechange_data, NumericRange):
96+
return (prechange_data.lower, prechange_data.upper-1)
10397

98+
return prechange_data
10499

105100
def clean_diff_data(data: dict, exclude_empty_values: bool = True) -> dict:
106101
"""Clean diff data by removing null values."""
@@ -170,8 +165,19 @@ def sort_dict_recursively(d):
170165
return sorted([sort_dict_recursively(item) for item in d], key=str)
171166
return d
172167

173-
174168
def generate_changeset(entity: dict, object_type: str) -> ChangeSetResult:
169+
"""Generate a changeset for an entity."""
170+
try:
171+
return _generate_changeset(entity, object_type)
172+
except ChangeSetException:
173+
raise
174+
except ValidationError as e:
175+
raise error_from_validation_error(e, object_type)
176+
except Exception as e:
177+
logger.error(f"Unexpected error generating changeset: {e}")
178+
raise
179+
180+
def _generate_changeset(entity: dict, object_type: str) -> ChangeSetResult:
175181
"""Generate a changeset for an entity."""
176182
change_set = ChangeSet()
177183

@@ -196,7 +202,6 @@ def generate_changeset(entity: dict, object_type: str) -> ChangeSetResult:
196202
# this is also important for custom fields because they do not appear to
197203
# respsect paritial update serialization.
198204
entity = _partially_merge(prechange_data, entity, instance)
199-
_harmonize_formats(prechange_data, entity)
200205
changed_data = shallow_compare_dict(
201206
prechange_data, entity,
202207
)

netbox_diode_plugin/api/plugin_utils.py

+199-2
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
"""Diode plugin helpers."""
22

33
# Generated code. DO NOT EDIT.
4-
# Timestamp: 2025-04-12 15:25:46Z
4+
# Timestamp: 2025-04-13 13:20:10Z
55

66
from dataclasses import dataclass
7+
import datetime
8+
import decimal
79
from functools import lru_cache
10+
import logging
811
from typing import Type
912

1013
from core.models import ObjectType as NetBoxType
1114
from django.contrib.contenttypes.models import ContentType
1215
from django.db import models
1316

17+
logger = logging.getLogger(__name__)
1418

1519
@lru_cache(maxsize=256)
1620
def get_object_type_model(object_type: str) -> Type[models.Model]:
@@ -995,4 +999,197 @@ def legal_fields(object_type: str|Type[models.Model]) -> frozenset[str]:
995999

9961000
def get_primary_value(data: dict, object_type: str) -> str|None:
9971001
field = _OBJECT_TYPE_PRIMARY_VALUE_FIELD_MAP.get(object_type, 'name')
998-
return data.get(field)
1002+
return data.get(field)
1003+
1004+
1005+
def transform_timestamp_to_date_only(value: str) -> str:
1006+
return datetime.datetime.fromisoformat(value).strftime('%Y-%m-%d')
1007+
1008+
def transform_float_to_decimal(value: float) -> decimal.Decimal:
1009+
try:
1010+
return decimal.Decimal(str(value))
1011+
except decimal.InvalidOperation:
1012+
raise ValueError(f'Invalid decimal value: {value}')
1013+
1014+
def int_from_int64string(value: str) -> int:
1015+
return int(value)
1016+
1017+
def collect_integer_pairs(value: list[int]) -> list[tuple[int, int]]:
1018+
if len(value) % 2 != 0:
1019+
raise ValueError('Array must have an even number of elements')
1020+
return [(value[i], value[i+1]) for i in range(0, len(value), 2)]
1021+
1022+
def for_all(transform):
1023+
def wrapper(value):
1024+
if isinstance(value, list):
1025+
return [transform(v) for v in value]
1026+
return transform(value)
1027+
return wrapper
1028+
1029+
_FORMAT_TRANSFORMATIONS = {
1030+
'circuits.circuit': {
1031+
'commit_rate': int_from_int64string,
1032+
'distance': transform_float_to_decimal,
1033+
'install_date': transform_timestamp_to_date_only,
1034+
'termination_date': transform_timestamp_to_date_only,
1035+
},
1036+
'circuits.circuittermination': {
1037+
'port_speed': int_from_int64string,
1038+
'upstream_speed': int_from_int64string,
1039+
},
1040+
'dcim.cable': {
1041+
'length': transform_float_to_decimal,
1042+
},
1043+
'dcim.consoleport': {
1044+
'speed': int_from_int64string,
1045+
},
1046+
'dcim.consoleserverport': {
1047+
'speed': int_from_int64string,
1048+
},
1049+
'dcim.device': {
1050+
'latitude': transform_float_to_decimal,
1051+
'longitude': transform_float_to_decimal,
1052+
'position': transform_float_to_decimal,
1053+
'vc_position': int_from_int64string,
1054+
'vc_priority': int_from_int64string,
1055+
},
1056+
'dcim.devicetype': {
1057+
'u_height': transform_float_to_decimal,
1058+
'weight': transform_float_to_decimal,
1059+
},
1060+
'dcim.frontport': {
1061+
'rear_port_position': int_from_int64string,
1062+
},
1063+
'dcim.interface': {
1064+
'mtu': int_from_int64string,
1065+
'rf_channel_frequency': transform_float_to_decimal,
1066+
'rf_channel_width': transform_float_to_decimal,
1067+
'speed': int_from_int64string,
1068+
'tx_power': int_from_int64string,
1069+
},
1070+
'dcim.moduletype': {
1071+
'weight': transform_float_to_decimal,
1072+
},
1073+
'dcim.powerfeed': {
1074+
'amperage': int_from_int64string,
1075+
'max_utilization': int_from_int64string,
1076+
'voltage': int_from_int64string,
1077+
},
1078+
'dcim.powerport': {
1079+
'allocated_draw': int_from_int64string,
1080+
'maximum_draw': int_from_int64string,
1081+
},
1082+
'dcim.rack': {
1083+
'max_weight': int_from_int64string,
1084+
'mounting_depth': int_from_int64string,
1085+
'outer_depth': int_from_int64string,
1086+
'outer_width': int_from_int64string,
1087+
'starting_unit': int_from_int64string,
1088+
'u_height': int_from_int64string,
1089+
'weight': transform_float_to_decimal,
1090+
'width': int_from_int64string,
1091+
},
1092+
'dcim.rackreservation': {
1093+
'units': for_all(int_from_int64string),
1094+
},
1095+
'dcim.racktype': {
1096+
'max_weight': int_from_int64string,
1097+
'mounting_depth': int_from_int64string,
1098+
'outer_depth': int_from_int64string,
1099+
'outer_width': int_from_int64string,
1100+
'starting_unit': int_from_int64string,
1101+
'u_height': int_from_int64string,
1102+
'weight': transform_float_to_decimal,
1103+
'width': int_from_int64string,
1104+
},
1105+
'dcim.rearport': {
1106+
'positions': int_from_int64string,
1107+
},
1108+
'dcim.site': {
1109+
'latitude': transform_float_to_decimal,
1110+
'longitude': transform_float_to_decimal,
1111+
},
1112+
'dcim.virtualdevicecontext': {
1113+
'identifier': int_from_int64string,
1114+
},
1115+
'ipam.aggregate': {
1116+
'date_added': transform_timestamp_to_date_only,
1117+
},
1118+
'ipam.asn': {
1119+
'asn': int_from_int64string,
1120+
},
1121+
'ipam.asnrange': {
1122+
'end': int_from_int64string,
1123+
'start': int_from_int64string,
1124+
},
1125+
'ipam.fhrpgroup': {
1126+
'group_id': int_from_int64string,
1127+
},
1128+
'ipam.fhrpgroupassignment': {
1129+
'priority': int_from_int64string,
1130+
},
1131+
'ipam.role': {
1132+
'weight': int_from_int64string,
1133+
},
1134+
'ipam.service': {
1135+
'ports': for_all(int_from_int64string),
1136+
},
1137+
'ipam.vlan': {
1138+
'vid': int_from_int64string,
1139+
},
1140+
'ipam.vlangroup': {
1141+
'vid_ranges': collect_integer_pairs,
1142+
},
1143+
'ipam.vlantranslationrule': {
1144+
'local_vid': int_from_int64string,
1145+
'remote_vid': int_from_int64string,
1146+
},
1147+
'virtualization.virtualdisk': {
1148+
'size': int_from_int64string,
1149+
},
1150+
'virtualization.virtualmachine': {
1151+
'disk': int_from_int64string,
1152+
'memory': int_from_int64string,
1153+
'vcpus': transform_float_to_decimal,
1154+
},
1155+
'virtualization.vminterface': {
1156+
'mtu': int_from_int64string,
1157+
},
1158+
'vpn.ikepolicy': {
1159+
'version': int_from_int64string,
1160+
},
1161+
'vpn.ikeproposal': {
1162+
'group': int_from_int64string,
1163+
'sa_lifetime': int_from_int64string,
1164+
},
1165+
'vpn.ipsecpolicy': {
1166+
'pfs_group': int_from_int64string,
1167+
},
1168+
'vpn.ipsecproposal': {
1169+
'sa_lifetime_data': int_from_int64string,
1170+
'sa_lifetime_seconds': int_from_int64string,
1171+
},
1172+
'vpn.l2vpn': {
1173+
'identifier': int_from_int64string,
1174+
},
1175+
'vpn.tunnel': {
1176+
'tunnel_id': int_from_int64string,
1177+
},
1178+
'wireless.wirelesslink': {
1179+
'distance': transform_float_to_decimal,
1180+
},
1181+
}
1182+
1183+
def apply_format_transformations(data: dict, object_type: str):
1184+
for key, transform in _FORMAT_TRANSFORMATIONS.get(object_type, {}).items():
1185+
val = data.get(key, None)
1186+
if val is None:
1187+
continue
1188+
try:
1189+
data[key] = transform(val)
1190+
except ValidationError:
1191+
raise
1192+
except ValueError as e:
1193+
raise ValidationError(f'Invalid value {val} for field {key} in {object_type}: {e}')
1194+
except Exception as e:
1195+
raise ValidationError(f'Invalid value {val} for field {key} in {object_type}')

netbox_diode_plugin/api/transformer.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@
1818

1919
from .common import AutoSlug, ChangeSetException, UnresolvedReference
2020
from .matcher import find_existing_object, fingerprint
21-
from .plugin_utils import CUSTOM_FIELD_OBJECT_REFERENCE_TYPE, get_json_ref_info, get_primary_value, legal_fields
21+
from .plugin_utils import (
22+
CUSTOM_FIELD_OBJECT_REFERENCE_TYPE,
23+
apply_format_transformations,
24+
get_json_ref_info,
25+
get_primary_value,
26+
legal_fields,
27+
)
2228

2329
logger = logging.getLogger("netbox.diode_data")
2430

@@ -72,6 +78,7 @@ def transform_proto_json(proto_json: dict, object_type: str, supported_models: d
7278
"""
7379
entities = _transform_proto_json_1(proto_json, object_type)
7480
logger.debug(f"_transform_proto_json_1 entities: {json.dumps(entities, default=lambda o: str(o), indent=4)}")
81+
7582
entities = _topo_sort(entities)
7683
logger.debug(f"_topo_sort: {json.dumps(entities, default=lambda o: str(o), indent=4)}")
7784
deduplicated = _fingerprint_dedupe(entities)
@@ -105,6 +112,7 @@ def _transform_proto_json_1(proto_json: dict, object_type: str, context=None) ->
105112

106113
# handle camelCase protoJSON if provided...
107114
proto_json = _ensure_snake_case(proto_json, object_type)
115+
apply_format_transformations(proto_json, object_type)
108116

109117
# context pushed down from parent nodes
110118
if context is not None:

0 commit comments

Comments
 (0)