diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 29ecfd028..3cb5b506c 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -498,7 +498,9 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge # Validate decimal elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: - if type(value) is not decimal.Decimal: + try: + decimal.Decimal(value) + except decimal.InvalidOperation: raise ValidationError("Value must be a decimal.") if self.validation_minimum is not None and value < self.validation_minimum: raise ValidationError(f"Value must be at least {self.validation_minimum}") diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index c8aa1343e..a65b61bff 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,4 +1,5 @@ -import decimal +from decimal import Decimal + from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.urls import reverse @@ -401,7 +402,7 @@ class CustomFieldAPITest(APITestCase): CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'), CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'), CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='integer_field', default=123), - CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default='123.45'), + CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default=123.45), CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False), CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'), CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'), @@ -452,7 +453,7 @@ class CustomFieldAPITest(APITestCase): custom_fields[0].name: 'bar', custom_fields[1].name: 'DEF', custom_fields[2].name: 456, - custom_fields[3].name: '456.78', + custom_fields[3].name: Decimal('456.78'), custom_fields[4].name: True, custom_fields[5].name: '2020-01-02', custom_fields[6].name: 'http://example.com/2', @@ -603,7 +604,7 @@ class CustomFieldAPITest(APITestCase): 'text_field': 'bar', 'longtext_field': 'blah blah blah', 'integer_field': 456, - 'decimal_field': '456.78', + 'decimal_field': 456.78, 'boolean_field': True, 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', @@ -726,7 +727,7 @@ class CustomFieldAPITest(APITestCase): 'text_field': 'bar', 'longtext_field': 'abcdefghij', 'integer_field': 456, - 'decimal_field': '456.78', + 'decimal_field': 456.78, 'boolean_field': True, 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', @@ -942,7 +943,7 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site1.custom_field_data['text'], 'ABC') self.assertEqual(site1.custom_field_data['longtext'], 'Foo') self.assertEqual(site1.custom_field_data['integer'], 123) - self.assertEqual(site1.custom_field_data['decimal'], '123.45') + self.assertEqual(site1.custom_field_data['decimal'], 123.45) self.assertEqual(site1.custom_field_data['boolean'], True) self.assertEqual(site1.custom_field_data['date'], '2020-01-01') self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') @@ -956,7 +957,7 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site2.custom_field_data['text'], 'DEF') self.assertEqual(site2.custom_field_data['longtext'], 'Bar') self.assertEqual(site2.custom_field_data['integer'], 456) - self.assertEqual(site2.custom_field_data['decimal'], '456.78') + self.assertEqual(site2.custom_field_data['decimal'], 456.78) self.assertEqual(site2.custom_field_data['boolean'], False) self.assertEqual(site2.custom_field_data['date'], '2020-01-02') self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') @@ -1092,14 +1093,20 @@ class CustomFieldModelFilterTest(TestCase): cf.content_types.set([obj_type]) # Exact text filtering - cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT, - filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) + cf = CustomField( + name='cf4', + type=CustomFieldTypeChoices.TYPE_TEXT, + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT + ) cf.save() cf.content_types.set([obj_type]) # Loose text filtering - cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_TEXT, - filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) + cf = CustomField( + name='cf5', + type=CustomFieldTypeChoices.TYPE_TEXT, + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE + ) cf.save() cf.content_types.set([obj_type]) @@ -1109,24 +1116,38 @@ class CustomFieldModelFilterTest(TestCase): cf.content_types.set([obj_type]) # Exact URL filtering - cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL, - filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) + cf = CustomField( + name='cf7', + type=CustomFieldTypeChoices.TYPE_URL, + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT + ) cf.save() cf.content_types.set([obj_type]) # Loose URL filtering - cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, - filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) + cf = CustomField( + name='cf8', + type=CustomFieldTypeChoices.TYPE_URL, + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE + ) cf.save() cf.content_types.set([obj_type]) # Selection filtering - cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Foo', 'Bar', 'Baz']) + cf = CustomField( + name='cf9', + type=CustomFieldTypeChoices.TYPE_SELECT, + choices=['Foo', 'Bar', 'Baz'] + ) cf.save() cf.content_types.set([obj_type]) # Multiselect filtering - cf = CustomField(name='cf10', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'B', 'C', 'X']) + cf = CustomField( + name='cf10', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + choices=['A', 'B', 'C', 'X'] + ) cf.save() cf.content_types.set([obj_type]) @@ -1151,7 +1172,7 @@ class CustomFieldModelFilterTest(TestCase): Site.objects.bulk_create([ Site(name='Site 1', slug='site-1', custom_field_data={ 'cf1': 100, - 'cf2': decimal.Decimal(100.25), + 'cf2': 100.1, 'cf3': True, 'cf4': 'foo', 'cf5': 'foo', @@ -1165,7 +1186,7 @@ class CustomFieldModelFilterTest(TestCase): }), Site(name='Site 2', slug='site-2', custom_field_data={ 'cf1': 200, - 'cf2': decimal.Decimal(200.25), + 'cf2': 200.2, 'cf3': True, 'cf4': 'foobar', 'cf5': 'foobar', @@ -1179,7 +1200,7 @@ class CustomFieldModelFilterTest(TestCase): }), Site(name='Site 3', slug='site-3', custom_field_data={ 'cf1': 300, - 'cf2': decimal.Decimal("300.25"), + 'cf2': 300.3, 'cf3': False, 'cf4': 'bar', 'cf5': 'bar', diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 9fa1c5cef..ce80cec3e 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -4,7 +4,6 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db.models.signals import class_prepared from django.dispatch import receiver -from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import ValidationError from django.db import models from taggit.managers import TaggableManager @@ -12,6 +11,7 @@ from taggit.managers import TaggableManager from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices from extras.utils import is_taggable, register_features from netbox.signals import post_clean +from utilities.json import CustomFieldJSONEncoder from utilities.utils import serialize_object __all__ = ( @@ -124,7 +124,7 @@ class CustomFieldsMixin(models.Model): Enables support for custom fields. """ custom_field_data = models.JSONField( - encoder=DjangoJSONEncoder, + encoder=CustomFieldJSONEncoder, blank=True, default=dict ) diff --git a/netbox/utilities/json.py b/netbox/utilities/json.py new file mode 100644 index 000000000..5574ff36f --- /dev/null +++ b/netbox/utilities/json.py @@ -0,0 +1,17 @@ +import decimal + +from django.core.serializers.json import DjangoJSONEncoder + +__all__ = ( + 'CustomFieldJSONEncoder', +) + + +class CustomFieldJSONEncoder(DjangoJSONEncoder): + """ + Override Django's built-in JSON encoder to save decimal values as JSON numbers. + """ + def default(self, o): + if isinstance(o, decimal.Decimal): + return float(o) + return super().default(o)