mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-25 00:36:11 -06:00
Store decimal custom field values natively
This commit is contained in:
parent
1e5b20970a
commit
7f38aa1c68
@ -498,7 +498,9 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
|||||||
|
|
||||||
# Validate decimal
|
# Validate decimal
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_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.")
|
raise ValidationError("Value must be a decimal.")
|
||||||
if self.validation_minimum is not None and value < self.validation_minimum:
|
if self.validation_minimum is not None and value < self.validation_minimum:
|
||||||
raise ValidationError(f"Value must be at least {self.validation_minimum}")
|
raise ValidationError(f"Value must be at least {self.validation_minimum}")
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.urls import reverse
|
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_TEXT, name='text_field', default='foo'),
|
||||||
CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
|
CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
|
||||||
CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='integer_field', default=123),
|
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_BOOLEAN, name='boolean_field', default=False),
|
||||||
CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'),
|
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'),
|
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[0].name: 'bar',
|
||||||
custom_fields[1].name: 'DEF',
|
custom_fields[1].name: 'DEF',
|
||||||
custom_fields[2].name: 456,
|
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[4].name: True,
|
||||||
custom_fields[5].name: '2020-01-02',
|
custom_fields[5].name: '2020-01-02',
|
||||||
custom_fields[6].name: 'http://example.com/2',
|
custom_fields[6].name: 'http://example.com/2',
|
||||||
@ -603,7 +604,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
'text_field': 'bar',
|
'text_field': 'bar',
|
||||||
'longtext_field': 'blah blah blah',
|
'longtext_field': 'blah blah blah',
|
||||||
'integer_field': 456,
|
'integer_field': 456,
|
||||||
'decimal_field': '456.78',
|
'decimal_field': 456.78,
|
||||||
'boolean_field': True,
|
'boolean_field': True,
|
||||||
'date_field': '2020-01-02',
|
'date_field': '2020-01-02',
|
||||||
'url_field': 'http://example.com/2',
|
'url_field': 'http://example.com/2',
|
||||||
@ -726,7 +727,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
'text_field': 'bar',
|
'text_field': 'bar',
|
||||||
'longtext_field': 'abcdefghij',
|
'longtext_field': 'abcdefghij',
|
||||||
'integer_field': 456,
|
'integer_field': 456,
|
||||||
'decimal_field': '456.78',
|
'decimal_field': 456.78,
|
||||||
'boolean_field': True,
|
'boolean_field': True,
|
||||||
'date_field': '2020-01-02',
|
'date_field': '2020-01-02',
|
||||||
'url_field': 'http://example.com/2',
|
'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['text'], 'ABC')
|
||||||
self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
|
self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
|
||||||
self.assertEqual(site1.custom_field_data['integer'], 123)
|
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['boolean'], True)
|
||||||
self.assertEqual(site1.custom_field_data['date'], '2020-01-01')
|
self.assertEqual(site1.custom_field_data['date'], '2020-01-01')
|
||||||
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
|
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['text'], 'DEF')
|
||||||
self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
|
self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
|
||||||
self.assertEqual(site2.custom_field_data['integer'], 456)
|
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['boolean'], False)
|
||||||
self.assertEqual(site2.custom_field_data['date'], '2020-01-02')
|
self.assertEqual(site2.custom_field_data['date'], '2020-01-02')
|
||||||
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
|
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
|
||||||
@ -1092,14 +1093,20 @@ class CustomFieldModelFilterTest(TestCase):
|
|||||||
cf.content_types.set([obj_type])
|
cf.content_types.set([obj_type])
|
||||||
|
|
||||||
# Exact text filtering
|
# Exact text filtering
|
||||||
cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT,
|
cf = CustomField(
|
||||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT)
|
name='cf4',
|
||||||
|
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||||
|
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
|
||||||
|
)
|
||||||
cf.save()
|
cf.save()
|
||||||
cf.content_types.set([obj_type])
|
cf.content_types.set([obj_type])
|
||||||
|
|
||||||
# Loose text filtering
|
# Loose text filtering
|
||||||
cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_TEXT,
|
cf = CustomField(
|
||||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
|
name='cf5',
|
||||||
|
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||||
|
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
|
||||||
|
)
|
||||||
cf.save()
|
cf.save()
|
||||||
cf.content_types.set([obj_type])
|
cf.content_types.set([obj_type])
|
||||||
|
|
||||||
@ -1109,24 +1116,38 @@ class CustomFieldModelFilterTest(TestCase):
|
|||||||
cf.content_types.set([obj_type])
|
cf.content_types.set([obj_type])
|
||||||
|
|
||||||
# Exact URL filtering
|
# Exact URL filtering
|
||||||
cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL,
|
cf = CustomField(
|
||||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT)
|
name='cf7',
|
||||||
|
type=CustomFieldTypeChoices.TYPE_URL,
|
||||||
|
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
|
||||||
|
)
|
||||||
cf.save()
|
cf.save()
|
||||||
cf.content_types.set([obj_type])
|
cf.content_types.set([obj_type])
|
||||||
|
|
||||||
# Loose URL filtering
|
# Loose URL filtering
|
||||||
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL,
|
cf = CustomField(
|
||||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
|
name='cf8',
|
||||||
|
type=CustomFieldTypeChoices.TYPE_URL,
|
||||||
|
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
|
||||||
|
)
|
||||||
cf.save()
|
cf.save()
|
||||||
cf.content_types.set([obj_type])
|
cf.content_types.set([obj_type])
|
||||||
|
|
||||||
# Selection filtering
|
# 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.save()
|
||||||
cf.content_types.set([obj_type])
|
cf.content_types.set([obj_type])
|
||||||
|
|
||||||
# Multiselect filtering
|
# 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.save()
|
||||||
cf.content_types.set([obj_type])
|
cf.content_types.set([obj_type])
|
||||||
|
|
||||||
@ -1151,7 +1172,7 @@ class CustomFieldModelFilterTest(TestCase):
|
|||||||
Site.objects.bulk_create([
|
Site.objects.bulk_create([
|
||||||
Site(name='Site 1', slug='site-1', custom_field_data={
|
Site(name='Site 1', slug='site-1', custom_field_data={
|
||||||
'cf1': 100,
|
'cf1': 100,
|
||||||
'cf2': decimal.Decimal(100.25),
|
'cf2': 100.1,
|
||||||
'cf3': True,
|
'cf3': True,
|
||||||
'cf4': 'foo',
|
'cf4': 'foo',
|
||||||
'cf5': 'foo',
|
'cf5': 'foo',
|
||||||
@ -1165,7 +1186,7 @@ class CustomFieldModelFilterTest(TestCase):
|
|||||||
}),
|
}),
|
||||||
Site(name='Site 2', slug='site-2', custom_field_data={
|
Site(name='Site 2', slug='site-2', custom_field_data={
|
||||||
'cf1': 200,
|
'cf1': 200,
|
||||||
'cf2': decimal.Decimal(200.25),
|
'cf2': 200.2,
|
||||||
'cf3': True,
|
'cf3': True,
|
||||||
'cf4': 'foobar',
|
'cf4': 'foobar',
|
||||||
'cf5': 'foobar',
|
'cf5': 'foobar',
|
||||||
@ -1179,7 +1200,7 @@ class CustomFieldModelFilterTest(TestCase):
|
|||||||
}),
|
}),
|
||||||
Site(name='Site 3', slug='site-3', custom_field_data={
|
Site(name='Site 3', slug='site-3', custom_field_data={
|
||||||
'cf1': 300,
|
'cf1': 300,
|
||||||
'cf2': decimal.Decimal("300.25"),
|
'cf2': 300.3,
|
||||||
'cf3': False,
|
'cf3': False,
|
||||||
'cf4': 'bar',
|
'cf4': 'bar',
|
||||||
'cf5': 'bar',
|
'cf5': 'bar',
|
||||||
|
@ -4,7 +4,6 @@ from django.contrib.contenttypes.fields import GenericRelation
|
|||||||
from django.db.models.signals import class_prepared
|
from django.db.models.signals import class_prepared
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
|
||||||
from django.core.validators import ValidationError
|
from django.core.validators import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
@ -12,6 +11,7 @@ from taggit.managers import TaggableManager
|
|||||||
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
|
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
|
||||||
from extras.utils import is_taggable, register_features
|
from extras.utils import is_taggable, register_features
|
||||||
from netbox.signals import post_clean
|
from netbox.signals import post_clean
|
||||||
|
from utilities.json import CustomFieldJSONEncoder
|
||||||
from utilities.utils import serialize_object
|
from utilities.utils import serialize_object
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -124,7 +124,7 @@ class CustomFieldsMixin(models.Model):
|
|||||||
Enables support for custom fields.
|
Enables support for custom fields.
|
||||||
"""
|
"""
|
||||||
custom_field_data = models.JSONField(
|
custom_field_data = models.JSONField(
|
||||||
encoder=DjangoJSONEncoder,
|
encoder=CustomFieldJSONEncoder,
|
||||||
blank=True,
|
blank=True,
|
||||||
default=dict
|
default=dict
|
||||||
)
|
)
|
||||||
|
17
netbox/utilities/json.py
Normal file
17
netbox/utilities/json.py
Normal file
@ -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)
|
Loading…
Reference in New Issue
Block a user