Bladeren bron

10348 add decimal custom field (#10422)

* 10348 add decimal custom field

* 10348 fix tests

* 10348 add documentation

* Rearrange custom fields to be ordered consistently

* Rename number_field to integer_field for clarity

* Clean up validation logic

* Apply suggested changes from PR

* Store decimal custom field values natively

* Fix filter test

* Update custom field model migrations to use new encoder

Co-authored-by: jeremystretch <jstretch@ns1.com>
Arthur Hanson 3 jaren geleden
bovenliggende
commit
af8bb0c4b9

+ 1 - 0
docs/customization/custom-fields.md

@@ -13,6 +13,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net
 * Text: Free-form text (intended for single-line use)
 * Text: Free-form text (intended for single-line use)
 * Long text: Free-form of any length; supports Markdown rendering
 * Long text: Free-form of any length; supports Markdown rendering
 * Integer: A whole number (positive or negative)
 * Integer: A whole number (positive or negative)
+* Decimal: A fixed-precision decimal number (4 decimal places)
 * Boolean: True or false
 * Boolean: True or false
 * Date: A date in ISO 8601 format (YYYY-MM-DD)
 * Date: A date in ISO 8601 format (YYYY-MM-DD)
 * URL: This will be presented as a link in the web UI
 * URL: This will be presented as a link in the web UI

+ 5 - 5
netbox/circuits/migrations/0001_squashed.py

@@ -1,5 +1,5 @@
 import dcim.fields
 import dcim.fields
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 from django.db import migrations, models
 from django.db import migrations, models
 import django.db.models.deletion
 import django.db.models.deletion
 
 
@@ -21,7 +21,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('cid', models.CharField(max_length=100)),
                 ('cid', models.CharField(max_length=100)),
                 ('status', models.CharField(default='active', max_length=50)),
                 ('status', models.CharField(default='active', max_length=50)),
@@ -58,7 +58,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -73,7 +73,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -93,7 +93,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100)),
                 ('name', models.CharField(max_length=100)),
                 ('description', models.CharField(blank=True, max_length=200)),
                 ('description', models.CharField(blank=True, max_length=200)),

+ 2 - 2
netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py

@@ -1,4 +1,4 @@
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 from django.db import migrations, models
 from django.db import migrations, models
 import taggit.managers
 import taggit.managers
 
 
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name='circuittermination',
             model_name='circuittermination',
             name='custom_field_data',
             name='custom_field_data',
-            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+            field=models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder),
         ),
         ),
         migrations.AddField(
         migrations.AddField(
             model_name='circuittermination',
             model_name='circuittermination',

+ 26 - 26
netbox/dcim/migrations/0001_squashed.py

@@ -1,6 +1,6 @@
 import dcim.fields
 import dcim.fields
 import django.contrib.postgres.fields
 import django.contrib.postgres.fields
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 import django.core.validators
 import django.core.validators
 from django.db import migrations, models
 from django.db import migrations, models
 import django.db.models.deletion
 import django.db.models.deletion
@@ -28,7 +28,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('termination_a_id', models.PositiveIntegerField()),
                 ('termination_a_id', models.PositiveIntegerField()),
                 ('termination_b_id', models.PositiveIntegerField()),
                 ('termination_b_id', models.PositiveIntegerField()),
@@ -60,7 +60,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=64)),
                 ('name', models.CharField(max_length=64)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -96,7 +96,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=64)),
                 ('name', models.CharField(max_length=64)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -132,7 +132,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('local_context_data', models.JSONField(blank=True, null=True)),
                 ('local_context_data', models.JSONField(blank=True, null=True)),
                 ('name', models.CharField(blank=True, max_length=64, null=True)),
                 ('name', models.CharField(blank=True, max_length=64, null=True)),
@@ -155,7 +155,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=64)),
                 ('name', models.CharField(max_length=64)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -186,7 +186,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -203,7 +203,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('model', models.CharField(max_length=100)),
                 ('model', models.CharField(max_length=100)),
                 ('slug', models.SlugField(max_length=100)),
                 ('slug', models.SlugField(max_length=100)),
@@ -224,7 +224,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=64)),
                 ('name', models.CharField(max_length=64)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -261,7 +261,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=64)),
                 ('name', models.CharField(max_length=64)),
                 ('label', models.CharField(blank=True, max_length=64)),
                 ('label', models.CharField(blank=True, max_length=64)),
@@ -302,7 +302,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=64)),
                 ('name', models.CharField(max_length=64)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -326,7 +326,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100)),
                 ('name', models.CharField(max_length=100)),
                 ('slug', models.SlugField(max_length=100)),
                 ('slug', models.SlugField(max_length=100)),
@@ -345,7 +345,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -360,7 +360,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -377,7 +377,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('_cable_peer_id', models.PositiveIntegerField(blank=True, null=True)),
                 ('_cable_peer_id', models.PositiveIntegerField(blank=True, null=True)),
                 ('mark_connected', models.BooleanField(default=False)),
                 ('mark_connected', models.BooleanField(default=False)),
@@ -401,7 +401,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=64)),
                 ('name', models.CharField(max_length=64)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -438,7 +438,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100)),
                 ('name', models.CharField(max_length=100)),
             ],
             ],
@@ -451,7 +451,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=64)),
                 ('name', models.CharField(max_length=64)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -490,7 +490,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100)),
                 ('name', models.CharField(max_length=100)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -516,7 +516,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)),
                 ('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)),
                 ('description', models.CharField(max_length=200)),
                 ('description', models.CharField(max_length=200)),
@@ -530,7 +530,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -546,7 +546,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=64)),
                 ('name', models.CharField(max_length=64)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -583,7 +583,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -602,7 +602,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -630,7 +630,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -649,7 +649,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=64)),
                 ('name', models.CharField(max_length=64)),
                 ('domain', models.CharField(blank=True, max_length=30)),
                 ('domain', models.CharField(blank=True, max_length=30)),

+ 4 - 4
netbox/dcim/migrations/0146_modules.py

@@ -1,4 +1,4 @@
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 from django.db import migrations, models
 from django.db import migrations, models
 import django.db.models.deletion
 import django.db.models.deletion
 import taggit.managers
 import taggit.managers
@@ -107,7 +107,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('model', models.CharField(max_length=100)),
                 ('model', models.CharField(max_length=100)),
                 ('part_number', models.CharField(blank=True, max_length=50)),
                 ('part_number', models.CharField(blank=True, max_length=50)),
@@ -125,7 +125,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=64)),
                 ('name', models.CharField(max_length=64)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
                 ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -145,7 +145,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('local_context_data', models.JSONField(blank=True, null=True)),
                 ('local_context_data', models.JSONField(blank=True, null=True)),
                 ('serial', models.CharField(blank=True, max_length=50)),
                 ('serial', models.CharField(blank=True, max_length=50)),

+ 2 - 2
netbox/dcim/migrations/0147_inventoryitemrole.py

@@ -1,4 +1,4 @@
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 from django.db import migrations, models
 from django.db import migrations, models
 import django.db.models.deletion
 import django.db.models.deletion
 import taggit.managers
 import taggit.managers
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),

+ 2 - 0
netbox/extras/api/serializers.py

@@ -99,6 +99,8 @@ class CustomFieldSerializer(ValidatedModelSerializer):
         types = CustomFieldTypeChoices
         types = CustomFieldTypeChoices
         if obj.type == types.TYPE_INTEGER:
         if obj.type == types.TYPE_INTEGER:
             return 'integer'
             return 'integer'
+        if obj.type == types.TYPE_DECIMAL:
+            return 'decimal'
         if obj.type == types.TYPE_BOOLEAN:
         if obj.type == types.TYPE_BOOLEAN:
             return 'boolean'
             return 'boolean'
         if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT):
         if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT):

+ 2 - 0
netbox/extras/choices.py

@@ -10,6 +10,7 @@ class CustomFieldTypeChoices(ChoiceSet):
     TYPE_TEXT = 'text'
     TYPE_TEXT = 'text'
     TYPE_LONGTEXT = 'longtext'
     TYPE_LONGTEXT = 'longtext'
     TYPE_INTEGER = 'integer'
     TYPE_INTEGER = 'integer'
+    TYPE_DECIMAL = 'decimal'
     TYPE_BOOLEAN = 'boolean'
     TYPE_BOOLEAN = 'boolean'
     TYPE_DATE = 'date'
     TYPE_DATE = 'date'
     TYPE_URL = 'url'
     TYPE_URL = 'url'
@@ -23,6 +24,7 @@ class CustomFieldTypeChoices(ChoiceSet):
         (TYPE_TEXT, 'Text'),
         (TYPE_TEXT, 'Text'),
         (TYPE_LONGTEXT, 'Text (long)'),
         (TYPE_LONGTEXT, 'Text (long)'),
         (TYPE_INTEGER, 'Integer'),
         (TYPE_INTEGER, 'Integer'),
+        (TYPE_DECIMAL, 'Decimal'),
         (TYPE_BOOLEAN, 'Boolean (true/false)'),
         (TYPE_BOOLEAN, 'Boolean (true/false)'),
         (TYPE_DATE, 'Date'),
         (TYPE_DATE, 'Date'),
         (TYPE_URL, 'URL'),
         (TYPE_URL, 'URL'),

+ 2 - 2
netbox/extras/migrations/0073_journalentry_tags_custom_fields.py

@@ -1,4 +1,4 @@
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 from django.db import migrations, models
 from django.db import migrations, models
 import taggit.managers
 import taggit.managers
 
 
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name='journalentry',
             model_name='journalentry',
             name='custom_field_data',
             name='custom_field_data',
-            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+            field=models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder),
         ),
         ),
         migrations.AddField(
         migrations.AddField(
             model_name='journalentry',
             model_name='journalentry',

+ 37 - 13
netbox/extras/models/customfields.py

@@ -1,5 +1,6 @@
 import re
 import re
 from datetime import datetime, date
 from datetime import datetime, date
+import decimal
 
 
 import django_filters
 import django_filters
 from django import forms
 from django import forms
@@ -219,14 +220,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
                 })
                 })
 
 
         # Minimum/maximum values can be set only for numeric fields
         # Minimum/maximum values can be set only for numeric fields
-        if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER:
-            raise ValidationError({
-                'validation_minimum': "A minimum value may be set only for numeric fields"
-            })
-        if self.validation_maximum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER:
-            raise ValidationError({
-                'validation_maximum': "A maximum value may be set only for numeric fields"
-            })
+        if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL):
+            if self.validation_minimum:
+                raise ValidationError({'validation_minimum': "A minimum value may be set only for numeric fields"})
+            if self.validation_maximum:
+                raise ValidationError({'validation_maximum': "A maximum value may be set only for numeric fields"})
 
 
         # Regex validation can be set only for text fields
         # Regex validation can be set only for text fields
         regex_types = (
         regex_types = (
@@ -317,6 +315,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
                 max_value=self.validation_maximum
                 max_value=self.validation_maximum
             )
             )
 
 
+        # Decimal
+        elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
+            field = forms.DecimalField(
+                required=required,
+                initial=initial,
+                max_digits=12,
+                decimal_places=4,
+                min_value=self.validation_minimum,
+                max_value=self.validation_maximum
+            )
+
         # Boolean
         # Boolean
         elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
         elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
             choices = (
             choices = (
@@ -426,6 +435,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
         elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
         elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
             filter_class = filters.MultiValueNumberFilter
             filter_class = filters.MultiValueNumberFilter
 
 
+        # Decimal
+        elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
+            filter_class = filters.MultiValueDecimalFilter
+
         # Boolean
         # Boolean
         elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
         elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
             filter_class = django_filters.BooleanFilter
             filter_class = django_filters.BooleanFilter
@@ -475,7 +488,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
                     raise ValidationError(f"Value must match regex '{self.validation_regex}'")
                     raise ValidationError(f"Value must match regex '{self.validation_regex}'")
 
 
             # Validate integer
             # Validate integer
-            if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
+            elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
                 if type(value) is not int:
                 if type(value) is not int:
                     raise ValidationError("Value must be an integer.")
                     raise ValidationError("Value must be an integer.")
                 if self.validation_minimum is not None and value < self.validation_minimum:
                 if self.validation_minimum is not None and value < self.validation_minimum:
@@ -483,12 +496,23 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
                 if self.validation_maximum is not None and value > self.validation_maximum:
                 if self.validation_maximum is not None and value > self.validation_maximum:
                     raise ValidationError(f"Value must not exceed {self.validation_maximum}")
                     raise ValidationError(f"Value must not exceed {self.validation_maximum}")
 
 
+            # Validate decimal
+            elif self.type == CustomFieldTypeChoices.TYPE_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}")
+                if self.validation_maximum is not None and value > self.validation_maximum:
+                    raise ValidationError(f"Value must not exceed {self.validation_maximum}")
+
             # Validate boolean
             # Validate boolean
-            if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
+            elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
                 raise ValidationError("Value must be true or false.")
                 raise ValidationError("Value must be true or false.")
 
 
             # Validate date
             # Validate date
-            if self.type == CustomFieldTypeChoices.TYPE_DATE:
+            elif self.type == CustomFieldTypeChoices.TYPE_DATE:
                 if type(value) is not date:
                 if type(value) is not date:
                     try:
                     try:
                         datetime.strptime(value, '%Y-%m-%d')
                         datetime.strptime(value, '%Y-%m-%d')
@@ -496,14 +520,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
                         raise ValidationError("Date values must be in the format YYYY-MM-DD.")
                         raise ValidationError("Date values must be in the format YYYY-MM-DD.")
 
 
             # Validate selected choice
             # Validate selected choice
-            if self.type == CustomFieldTypeChoices.TYPE_SELECT:
+            elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
                 if value not in self.choices:
                 if value not in self.choices:
                     raise ValidationError(
                     raise ValidationError(
                         f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}"
                         f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}"
                     )
                     )
 
 
             # Validate all selected choices
             # Validate all selected choices
-            if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
+            elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
                 if not set(value).issubset(self.choices):
                 if not set(value).issubset(self.choices):
                     raise ValidationError(
                     raise ValidationError(
                         f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"
                         f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"

+ 192 - 108
netbox/extras/tests/test_customfields.py

@@ -1,3 +1,5 @@
+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
@@ -102,6 +104,32 @@ class CustomFieldTest(TestCase):
             instance.refresh_from_db()
             instance.refresh_from_db()
             self.assertIsNone(instance.custom_field_data.get(cf.name))
             self.assertIsNone(instance.custom_field_data.get(cf.name))
 
 
+    def test_decimal_field(self):
+
+        # Create a custom field & check that initial value is null
+        cf = CustomField.objects.create(
+            name='decimal_field',
+            type=CustomFieldTypeChoices.TYPE_DECIMAL,
+            required=False
+        )
+        cf.content_types.set([self.object_type])
+        instance = Site.objects.first()
+        self.assertIsNone(instance.custom_field_data[cf.name])
+
+        for value in (123456.54, 0, -123456.78):
+
+            # Assign a value and check that it is saved
+            instance.custom_field_data[cf.name] = value
+            instance.save()
+            instance.refresh_from_db()
+            self.assertEqual(instance.custom_field_data[cf.name], value)
+
+            # Delete the stored value and check that it is now null
+            instance.custom_field_data.pop(cf.name)
+            instance.save()
+            instance.refresh_from_db()
+            self.assertIsNone(instance.custom_field_data.get(cf.name))
+
     def test_boolean_field(self):
     def test_boolean_field(self):
 
 
         # Create a custom field & check that initial value is null
         # Create a custom field & check that initial value is null
@@ -373,7 +401,8 @@ class CustomFieldAPITest(APITestCase):
         custom_fields = (
         custom_fields = (
             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='number_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_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'),
@@ -424,14 +453,15 @@ 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: True,
-            custom_fields[4].name: '2020-01-02',
-            custom_fields[5].name: 'http://example.com/2',
-            custom_fields[6].name: '{"foo": 1, "bar": 2}',
-            custom_fields[7].name: 'Bar',
-            custom_fields[8].name: ['Bar', 'Baz'],
-            custom_fields[9].name: vlans[1].pk,
-            custom_fields[10].name: [vlans[2].pk, vlans[3].pk],
+            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',
+            custom_fields[7].name: '{"foo": 1, "bar": 2}',
+            custom_fields[8].name: 'Bar',
+            custom_fields[9].name: ['Bar', 'Baz'],
+            custom_fields[10].name: vlans[1].pk,
+            custom_fields[11].name: [vlans[2].pk, vlans[3].pk],
         }
         }
         sites[1].save()
         sites[1].save()
 
 
@@ -440,6 +470,7 @@ class CustomFieldAPITest(APITestCase):
             CustomFieldTypeChoices.TYPE_TEXT: 'string',
             CustomFieldTypeChoices.TYPE_TEXT: 'string',
             CustomFieldTypeChoices.TYPE_LONGTEXT: 'string',
             CustomFieldTypeChoices.TYPE_LONGTEXT: 'string',
             CustomFieldTypeChoices.TYPE_INTEGER: 'integer',
             CustomFieldTypeChoices.TYPE_INTEGER: 'integer',
+            CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal',
             CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean',
             CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean',
             CustomFieldTypeChoices.TYPE_DATE: 'string',
             CustomFieldTypeChoices.TYPE_DATE: 'string',
             CustomFieldTypeChoices.TYPE_URL: 'string',
             CustomFieldTypeChoices.TYPE_URL: 'string',
@@ -473,7 +504,8 @@ class CustomFieldAPITest(APITestCase):
         self.assertEqual(response.data['custom_fields'], {
         self.assertEqual(response.data['custom_fields'], {
             'text_field': None,
             'text_field': None,
             'longtext_field': None,
             'longtext_field': None,
-            'number_field': None,
+            'integer_field': None,
+            'decimal_field': None,
             'boolean_field': None,
             'boolean_field': None,
             'date_field': None,
             'date_field': None,
             'url_field': None,
             'url_field': None,
@@ -497,7 +529,8 @@ class CustomFieldAPITest(APITestCase):
         self.assertEqual(response.data['name'], site2.name)
         self.assertEqual(response.data['name'], site2.name)
         self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field'])
         self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field'])
         self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field'])
         self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field'])
-        self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field'])
+        self.assertEqual(response.data['custom_fields']['integer_field'], site2_cfvs['integer_field'])
+        self.assertEqual(response.data['custom_fields']['decimal_field'], site2_cfvs['decimal_field'])
         self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field'])
         self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field'])
         self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field'])
         self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field'])
         self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
         self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
@@ -531,7 +564,8 @@ class CustomFieldAPITest(APITestCase):
         response_cf = response.data['custom_fields']
         response_cf = response.data['custom_fields']
         self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
         self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
         self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
         self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
-        self.assertEqual(response_cf['number_field'], cf_defaults['number_field'])
+        self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field'])
+        self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
         self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
         self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
         self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
         self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
         self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
         self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
@@ -548,7 +582,8 @@ class CustomFieldAPITest(APITestCase):
         site = Site.objects.get(pk=response.data['id'])
         site = Site.objects.get(pk=response.data['id'])
         self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
         self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
         self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
         self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
-        self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field'])
+        self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
+        self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
         self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
         self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
         self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
         self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
         self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
         self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
@@ -568,7 +603,8 @@ class CustomFieldAPITest(APITestCase):
             'custom_fields': {
             'custom_fields': {
                 'text_field': 'bar',
                 'text_field': 'bar',
                 'longtext_field': 'blah blah blah',
                 'longtext_field': 'blah blah blah',
-                'number_field': 456,
+                'integer_field': 456,
+                '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',
@@ -590,7 +626,8 @@ class CustomFieldAPITest(APITestCase):
         data_cf = data['custom_fields']
         data_cf = data['custom_fields']
         self.assertEqual(response_cf['text_field'], data_cf['text_field'])
         self.assertEqual(response_cf['text_field'], data_cf['text_field'])
         self.assertEqual(response_cf['longtext_field'], data_cf['longtext_field'])
         self.assertEqual(response_cf['longtext_field'], data_cf['longtext_field'])
-        self.assertEqual(response_cf['number_field'], data_cf['number_field'])
+        self.assertEqual(response_cf['integer_field'], data_cf['integer_field'])
+        self.assertEqual(response_cf['decimal_field'], data_cf['decimal_field'])
         self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field'])
         self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field'])
         self.assertEqual(response_cf['date_field'], data_cf['date_field'])
         self.assertEqual(response_cf['date_field'], data_cf['date_field'])
         self.assertEqual(response_cf['url_field'], data_cf['url_field'])
         self.assertEqual(response_cf['url_field'], data_cf['url_field'])
@@ -607,7 +644,8 @@ class CustomFieldAPITest(APITestCase):
         site = Site.objects.get(pk=response.data['id'])
         site = Site.objects.get(pk=response.data['id'])
         self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field'])
         self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field'])
         self.assertEqual(site.custom_field_data['longtext_field'], data_cf['longtext_field'])
         self.assertEqual(site.custom_field_data['longtext_field'], data_cf['longtext_field'])
-        self.assertEqual(site.custom_field_data['number_field'], data_cf['number_field'])
+        self.assertEqual(site.custom_field_data['integer_field'], data_cf['integer_field'])
+        self.assertEqual(site.custom_field_data['decimal_field'], data_cf['decimal_field'])
         self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field'])
         self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field'])
         self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field'])
         self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field'])
         self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field'])
         self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field'])
@@ -652,7 +690,8 @@ class CustomFieldAPITest(APITestCase):
             response_cf = response.data[i]['custom_fields']
             response_cf = response.data[i]['custom_fields']
             self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
             self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
             self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
             self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
-            self.assertEqual(response_cf['number_field'], cf_defaults['number_field'])
+            self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field'])
+            self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
             self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
             self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
             self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
             self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
             self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
             self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
@@ -669,7 +708,8 @@ class CustomFieldAPITest(APITestCase):
             site = Site.objects.get(pk=response.data[i]['id'])
             site = Site.objects.get(pk=response.data[i]['id'])
             self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
             self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
             self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
             self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
-            self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field'])
+            self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
+            self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
             self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
             self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
             self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
             self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
             self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
             self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
@@ -686,7 +726,8 @@ class CustomFieldAPITest(APITestCase):
         custom_field_data = {
         custom_field_data = {
             'text_field': 'bar',
             'text_field': 'bar',
             'longtext_field': 'abcdefghij',
             'longtext_field': 'abcdefghij',
-            'number_field': 456,
+            'integer_field': 456,
+            '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 +767,8 @@ class CustomFieldAPITest(APITestCase):
             response_cf = response.data[i]['custom_fields']
             response_cf = response.data[i]['custom_fields']
             self.assertEqual(response_cf['text_field'], custom_field_data['text_field'])
             self.assertEqual(response_cf['text_field'], custom_field_data['text_field'])
             self.assertEqual(response_cf['longtext_field'], custom_field_data['longtext_field'])
             self.assertEqual(response_cf['longtext_field'], custom_field_data['longtext_field'])
-            self.assertEqual(response_cf['number_field'], custom_field_data['number_field'])
+            self.assertEqual(response_cf['integer_field'], custom_field_data['integer_field'])
+            self.assertEqual(response_cf['decimal_field'], custom_field_data['decimal_field'])
             self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field'])
             self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field'])
             self.assertEqual(response_cf['date_field'], custom_field_data['date_field'])
             self.assertEqual(response_cf['date_field'], custom_field_data['date_field'])
             self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
             self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
@@ -743,7 +785,8 @@ class CustomFieldAPITest(APITestCase):
             site = Site.objects.get(pk=response.data[i]['id'])
             site = Site.objects.get(pk=response.data[i]['id'])
             self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field'])
             self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field'])
             self.assertEqual(site.custom_field_data['longtext_field'], custom_field_data['longtext_field'])
             self.assertEqual(site.custom_field_data['longtext_field'], custom_field_data['longtext_field'])
-            self.assertEqual(site.custom_field_data['number_field'], custom_field_data['number_field'])
+            self.assertEqual(site.custom_field_data['integer_field'], custom_field_data['integer_field'])
+            self.assertEqual(site.custom_field_data['decimal_field'], custom_field_data['decimal_field'])
             self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field'])
             self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field'])
             self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field'])
             self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field'])
             self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
             self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
@@ -763,7 +806,7 @@ class CustomFieldAPITest(APITestCase):
         data = {
         data = {
             'custom_fields': {
             'custom_fields': {
                 'text_field': 'ABCD',
                 'text_field': 'ABCD',
-                'number_field': 1234,
+                'integer_field': 1234,
             },
             },
         }
         }
         url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
         url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
@@ -775,8 +818,9 @@ class CustomFieldAPITest(APITestCase):
         # Validate response data
         # Validate response data
         response_cf = response.data['custom_fields']
         response_cf = response.data['custom_fields']
         self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field'])
         self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field'])
-        self.assertEqual(response_cf['number_field'], data['custom_fields']['number_field'])
         self.assertEqual(response_cf['longtext_field'], original_cfvs['longtext_field'])
         self.assertEqual(response_cf['longtext_field'], original_cfvs['longtext_field'])
+        self.assertEqual(response_cf['integer_field'], data['custom_fields']['integer_field'])
+        self.assertEqual(response_cf['decimal_field'], original_cfvs['decimal_field'])
         self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field'])
         self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field'])
         self.assertEqual(response_cf['date_field'], original_cfvs['date_field'])
         self.assertEqual(response_cf['date_field'], original_cfvs['date_field'])
         self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
         self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
@@ -792,8 +836,9 @@ class CustomFieldAPITest(APITestCase):
         # Validate database data
         # Validate database data
         site2.refresh_from_db()
         site2.refresh_from_db()
         self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field'])
         self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field'])
-        self.assertEqual(site2.custom_field_data['number_field'], data['custom_fields']['number_field'])
         self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field'])
         self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field'])
+        self.assertEqual(site2.custom_field_data['integer_field'], data['custom_fields']['integer_field'])
+        self.assertEqual(site2.custom_field_data['decimal_field'], original_cfvs['decimal_field'])
         self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field'])
         self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field'])
         self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field'])
         self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field'])
         self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field'])
         self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field'])
@@ -808,20 +853,20 @@ class CustomFieldAPITest(APITestCase):
         url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
         url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
         self.add_permissions('dcim.change_site')
         self.add_permissions('dcim.change_site')
 
 
-        cf_integer = CustomField.objects.get(name='number_field')
+        cf_integer = CustomField.objects.get(name='integer_field')
         cf_integer.validation_minimum = 10
         cf_integer.validation_minimum = 10
         cf_integer.validation_maximum = 20
         cf_integer.validation_maximum = 20
         cf_integer.save()
         cf_integer.save()
 
 
-        data = {'custom_fields': {'number_field': 9}}
+        data = {'custom_fields': {'integer_field': 9}}
         response = self.client.patch(url, data, format='json', **self.header)
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
-        data = {'custom_fields': {'number_field': 21}}
+        data = {'custom_fields': {'integer_field': 21}}
         response = self.client.patch(url, data, format='json', **self.header)
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
-        data = {'custom_fields': {'number_field': 15}}
+        data = {'custom_fields': {'integer_field': 15}}
         response = self.client.patch(url, data, format='json', **self.header)
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
 
@@ -860,6 +905,7 @@ class CustomFieldImportTest(TestCase):
             CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
             CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
             CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
             CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
             CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER),
             CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER),
+            CustomField(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL),
             CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
             CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
             CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
             CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
             CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
             CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
@@ -880,10 +926,10 @@ class CustomFieldImportTest(TestCase):
         Import a Site in CSV format, including a value for each CustomField.
         Import a Site in CSV format, including a value for each CustomField.
         """
         """
         data = (
         data = (
-            ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
-            ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
-            ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
-            ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''),
+            ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
+            ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
+            ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
+            ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', ''),
         )
         )
         csv_data = '\n'.join(','.join(row) for row in data)
         csv_data = '\n'.join(','.join(row) for row in data)
 
 
@@ -893,10 +939,11 @@ class CustomFieldImportTest(TestCase):
 
 
         # Validate data for site 1
         # Validate data for site 1
         site1 = Site.objects.get(name='Site 1')
         site1 = Site.objects.get(name='Site 1')
-        self.assertEqual(len(site1.custom_field_data), 9)
+        self.assertEqual(len(site1.custom_field_data), 10)
         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['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')
@@ -906,10 +953,11 @@ class CustomFieldImportTest(TestCase):
 
 
         # Validate data for site 2
         # Validate data for site 2
         site2 = Site.objects.get(name='Site 2')
         site2 = Site.objects.get(name='Site 2')
-        self.assertEqual(len(site2.custom_field_data), 9)
+        self.assertEqual(len(site2.custom_field_data), 10)
         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['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')
@@ -1034,53 +1082,78 @@ class CustomFieldModelFilterTest(TestCase):
         cf.save()
         cf.save()
         cf.content_types.set([obj_type])
         cf.content_types.set([obj_type])
 
 
+        # Decimal filtering
+        cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_DECIMAL)
+        cf.save()
+        cf.content_types.set([obj_type])
+
         # Boolean filtering
         # Boolean filtering
-        cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
+        cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
         cf.save()
         cf.save()
         cf.content_types.set([obj_type])
         cf.content_types.set([obj_type])
 
 
         # Exact text filtering
         # Exact text filtering
-        cf = CustomField(name='cf3', 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.save()
         cf.content_types.set([obj_type])
         cf.content_types.set([obj_type])
 
 
         # Loose text filtering
         # Loose text filtering
-        cf = CustomField(name='cf4', 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.save()
         cf.content_types.set([obj_type])
         cf.content_types.set([obj_type])
 
 
         # Date filtering
         # Date filtering
-        cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_DATE)
+        cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_DATE)
         cf.save()
         cf.save()
         cf.content_types.set([obj_type])
         cf.content_types.set([obj_type])
 
 
         # Exact URL filtering
         # Exact URL filtering
-        cf = CustomField(name='cf6', 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.save()
         cf.content_types.set([obj_type])
         cf.content_types.set([obj_type])
 
 
         # Loose URL filtering
         # Loose URL filtering
-        cf = CustomField(name='cf7', 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.save()
         cf.content_types.set([obj_type])
         cf.content_types.set([obj_type])
 
 
         # Selection filtering
         # Selection filtering
-        cf = CustomField(name='cf8', 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='cf9', 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])
 
 
         # Object filtering
         # Object filtering
         cf = CustomField(
         cf = CustomField(
-            name='cf10',
+            name='cf11',
             type=CustomFieldTypeChoices.TYPE_OBJECT,
             type=CustomFieldTypeChoices.TYPE_OBJECT,
             object_type=ContentType.objects.get_for_model(Manufacturer)
             object_type=ContentType.objects.get_for_model(Manufacturer)
         )
         )
@@ -1089,7 +1162,7 @@ class CustomFieldModelFilterTest(TestCase):
 
 
         # Multi-object filtering
         # Multi-object filtering
         cf = CustomField(
         cf = CustomField(
-            name='cf11',
+            name='cf12',
             type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
             type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
             object_type=ContentType.objects.get_for_model(Manufacturer)
             object_type=ContentType.objects.get_for_model(Manufacturer)
         )
         )
@@ -1099,42 +1172,45 @@ 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': True,
-                'cf3': 'foo',
+                'cf2': 100.1,
+                'cf3': True,
                 'cf4': 'foo',
                 'cf4': 'foo',
-                'cf5': '2016-06-26',
-                'cf6': 'http://a.example.com',
+                'cf5': 'foo',
+                'cf6': '2016-06-26',
                 'cf7': 'http://a.example.com',
                 'cf7': 'http://a.example.com',
-                'cf8': 'Foo',
-                'cf9': ['A', 'X'],
-                'cf10': manufacturers[0].pk,
-                'cf11': [manufacturers[0].pk, manufacturers[3].pk],
+                'cf8': 'http://a.example.com',
+                'cf9': 'Foo',
+                'cf10': ['A', 'X'],
+                'cf11': manufacturers[0].pk,
+                'cf12': [manufacturers[0].pk, manufacturers[3].pk],
             }),
             }),
             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': True,
-                'cf3': 'foobar',
+                'cf2': 200.2,
+                'cf3': True,
                 'cf4': 'foobar',
                 'cf4': 'foobar',
-                'cf5': '2016-06-27',
-                'cf6': 'http://b.example.com',
+                'cf5': 'foobar',
+                'cf6': '2016-06-27',
                 'cf7': 'http://b.example.com',
                 'cf7': 'http://b.example.com',
-                'cf8': 'Bar',
-                'cf9': ['B', 'X'],
-                'cf10': manufacturers[1].pk,
-                'cf11': [manufacturers[1].pk, manufacturers[3].pk],
+                'cf8': 'http://b.example.com',
+                'cf9': 'Bar',
+                'cf10': ['B', 'X'],
+                'cf11': manufacturers[1].pk,
+                'cf12': [manufacturers[1].pk, manufacturers[3].pk],
             }),
             }),
             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': False,
-                'cf3': 'bar',
+                'cf2': 300.3,
+                'cf3': False,
                 'cf4': 'bar',
                 'cf4': 'bar',
-                'cf5': '2016-06-28',
-                'cf6': 'http://c.example.com',
+                'cf5': 'bar',
+                'cf6': '2016-06-28',
                 'cf7': 'http://c.example.com',
                 'cf7': 'http://c.example.com',
-                'cf8': 'Baz',
-                'cf9': ['C', 'X'],
-                'cf10': manufacturers[2].pk,
-                'cf11': [manufacturers[2].pk, manufacturers[3].pk],
+                'cf8': 'http://c.example.com',
+                'cf9': 'Baz',
+                'cf10': ['C', 'X'],
+                'cf11': manufacturers[2].pk,
+                'cf12': [manufacturers[2].pk, manufacturers[3].pk],
             }),
             }),
         ])
         ])
 
 
@@ -1146,60 +1222,68 @@ class CustomFieldModelFilterTest(TestCase):
         self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2)
 
 
+    def test_filter_decimal(self):
+        self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf2__n': [200.2]}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf2__gt': [200.2]}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf2__lte': [200.2]}, self.queryset).qs.count(), 2)
+
     def test_filter_boolean(self):
     def test_filter_boolean(self):
-        self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf3': False}, self.queryset).qs.count(), 1)
 
 
     def test_filter_text_strict(self):
     def test_filter_text_strict(self):
-        self.assertEqual(self.filterset({'cf_cf3': ['foo']}, self.queryset).qs.count(), 1)
-        self.assertEqual(self.filterset({'cf_cf3__n': ['foo']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf3__ic': ['foo']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf3__nic': ['foo']}, self.queryset).qs.count(), 1)
-        self.assertEqual(self.filterset({'cf_cf3__isw': ['foo']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf3__nisw': ['foo']}, self.queryset).qs.count(), 1)
-        self.assertEqual(self.filterset({'cf_cf3__iew': ['bar']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf3__niew': ['bar']}, self.queryset).qs.count(), 1)
-        self.assertEqual(self.filterset({'cf_cf3__ie': ['FOO']}, self.queryset).qs.count(), 1)
-        self.assertEqual(self.filterset({'cf_cf3__nie': ['FOO']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf4__n': ['foo']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf4__ic': ['foo']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf4__nic': ['foo']}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf4__isw': ['foo']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf4__nisw': ['foo']}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf4__iew': ['bar']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2)
 
 
     def test_filter_text_loose(self):
     def test_filter_text_loose(self):
-        self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2)
 
 
     def test_filter_date(self):
     def test_filter_date(self):
-        self.assertEqual(self.filterset({'cf_cf5': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf5__n': ['2016-06-27']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf5__gt': ['2016-06-27']}, self.queryset).qs.count(), 1)
-        self.assertEqual(self.filterset({'cf_cf5__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf5__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
-        self.assertEqual(self.filterset({'cf_cf5__lte': ['2016-06-27']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf6': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf6__n': ['2016-06-27']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf6__gt': ['2016-06-27']}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2)
 
 
     def test_filter_url_strict(self):
     def test_filter_url_strict(self):
-        self.assertEqual(self.filterset({'cf_cf6': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf6__n': ['http://b.example.com']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf6__ic': ['b']}, self.queryset).qs.count(), 1)
-        self.assertEqual(self.filterset({'cf_cf6__nic': ['b']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf6__isw': ['http://']}, self.queryset).qs.count(), 3)
-        self.assertEqual(self.filterset({'cf_cf6__nisw': ['http://']}, self.queryset).qs.count(), 0)
-        self.assertEqual(self.filterset({'cf_cf6__iew': ['.com']}, self.queryset).qs.count(), 3)
-        self.assertEqual(self.filterset({'cf_cf6__niew': ['.com']}, self.queryset).qs.count(), 0)
-        self.assertEqual(self.filterset({'cf_cf6__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
-        self.assertEqual(self.filterset({'cf_cf6__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf7': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf7__n': ['http://b.example.com']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf7__ic': ['b']}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf7__nic': ['b']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf7__isw': ['http://']}, self.queryset).qs.count(), 3)
+        self.assertEqual(self.filterset({'cf_cf7__nisw': ['http://']}, self.queryset).qs.count(), 0)
+        self.assertEqual(self.filterset({'cf_cf7__iew': ['.com']}, self.queryset).qs.count(), 3)
+        self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0)
+        self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2)
 
 
     def test_filter_url_loose(self):
     def test_filter_url_loose(self):
-        self.assertEqual(self.filterset({'cf_cf7': ['example.com']}, self.queryset).qs.count(), 3)
+        self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
 
 
     def test_filter_select(self):
     def test_filter_select(self):
-        self.assertEqual(self.filterset({'cf_cf8': ['Foo', 'Bar']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf9': ['Foo', 'Bar']}, self.queryset).qs.count(), 2)
 
 
     def test_filter_multiselect(self):
     def test_filter_multiselect(self):
-        self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf9': ['X']}, self.queryset).qs.count(), 3)
+        self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3)
 
 
     def test_filter_object(self):
     def test_filter_object(self):
         manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
         manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
-        self.assertEqual(self.filterset({'cf_cf10': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2)
 
 
     def test_filter_multiobject(self):
     def test_filter_multiobject(self):
         manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
         manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
-        self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3)
+        self.assertEqual(self.filterset({'cf_cf12': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3)

+ 3 - 0
netbox/extras/tests/test_forms.py

@@ -23,6 +23,9 @@ class CustomFieldModelFormTest(TestCase):
         cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER)
         cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER)
         cf_integer.content_types.set([obj_type])
         cf_integer.content_types.set([obj_type])
 
 
+        cf_integer = CustomField.objects.create(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL)
+        cf_integer.content_types.set([obj_type])
+
         cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
         cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
         cf_boolean.content_types.set([obj_type])
         cf_boolean.content_types.set([obj_type])
 
 

+ 11 - 11
netbox/ipam/migrations/0001_squashed.py

@@ -1,5 +1,5 @@
 import django.contrib.postgres.fields
 import django.contrib.postgres.fields
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 import django.core.validators
 import django.core.validators
 from django.db import migrations, models
 from django.db import migrations, models
 import django.db.models.deletion
 import django.db.models.deletion
@@ -29,7 +29,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('prefix', ipam.fields.IPNetworkField()),
                 ('prefix', ipam.fields.IPNetworkField()),
                 ('date_added', models.DateField(blank=True, null=True)),
                 ('date_added', models.DateField(blank=True, null=True)),
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('address', ipam.fields.IPAddressField()),
                 ('address', ipam.fields.IPAddressField()),
                 ('status', models.CharField(default='active', max_length=50)),
                 ('status', models.CharField(default='active', max_length=50)),
@@ -64,7 +64,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('prefix', ipam.fields.IPNetworkField()),
                 ('prefix', ipam.fields.IPNetworkField()),
                 ('status', models.CharField(default='active', max_length=50)),
                 ('status', models.CharField(default='active', max_length=50)),
@@ -81,7 +81,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -99,7 +99,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -115,7 +115,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=21, unique=True)),
                 ('name', models.CharField(max_length=21, unique=True)),
                 ('description', models.CharField(blank=True, max_length=200)),
                 ('description', models.CharField(blank=True, max_length=200)),
@@ -129,7 +129,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100)),
                 ('name', models.CharField(max_length=100)),
                 ('rd', models.CharField(blank=True, max_length=21, null=True, unique=True)),
                 ('rd', models.CharField(blank=True, max_length=21, null=True, unique=True)),
@@ -151,7 +151,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100)),
                 ('name', models.CharField(max_length=100)),
                 ('slug', models.SlugField(max_length=100)),
                 ('slug', models.SlugField(max_length=100)),
@@ -170,7 +170,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('vid', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)])),
                 ('vid', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)])),
                 ('name', models.CharField(max_length=64)),
                 ('name', models.CharField(max_length=64)),
@@ -193,7 +193,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100)),
                 ('name', models.CharField(max_length=100)),
                 ('protocol', models.CharField(max_length=50)),
                 ('protocol', models.CharField(max_length=50)),

+ 2 - 2
netbox/ipam/migrations/0050_iprange.py

@@ -1,6 +1,6 @@
 # Generated by Django 3.2.5 on 2021-07-16 14:15
 # Generated by Django 3.2.5 on 2021-07-16 14:15
 
 
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 from django.db import migrations, models
 from django.db import migrations, models
 import django.db.models.deletion
 import django.db.models.deletion
 import django.db.models.expressions
 import django.db.models.expressions
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('start_address', ipam.fields.IPAddressField()),
                 ('start_address', ipam.fields.IPAddressField()),
                 ('end_address', ipam.fields.IPAddressField()),
                 ('end_address', ipam.fields.IPAddressField()),

+ 2 - 2
netbox/ipam/migrations/0052_fhrpgroup.py

@@ -1,4 +1,4 @@
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 import django.core.validators
 import django.core.validators
 from django.db import migrations, models
 from django.db import migrations, models
 import django.db.models.deletion
 import django.db.models.deletion
@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('group_id', models.PositiveSmallIntegerField()),
                 ('group_id', models.PositiveSmallIntegerField()),
                 ('protocol', models.CharField(max_length=50)),
                 ('protocol', models.CharField(max_length=50)),

+ 2 - 2
netbox/ipam/migrations/0053_asn_model.py

@@ -1,7 +1,7 @@
 # Generated by Django 3.2.8 on 2021-11-02 16:16
 # Generated by Django 3.2.8 on 2021-11-02 16:16
 
 
 import dcim.fields
 import dcim.fields
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 from django.db import migrations, models
 from django.db import migrations, models
 import django.db.models.deletion
 import django.db.models.deletion
 import taggit.managers
 import taggit.managers
@@ -21,7 +21,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('asn', dcim.fields.ASNField(unique=True)),
                 ('asn', dcim.fields.ASNField(unique=True)),
                 ('description', models.CharField(blank=True, max_length=200)),
                 ('description', models.CharField(blank=True, max_length=200)),

+ 2 - 2
netbox/ipam/migrations/0055_servicetemplate.py

@@ -1,5 +1,5 @@
 import django.contrib.postgres.fields
 import django.contrib.postgres.fields
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 import django.core.validators
 import django.core.validators
 from django.db import migrations, models
 from django.db import migrations, models
 import taggit.managers
 import taggit.managers
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('protocol', models.CharField(max_length=50)),
                 ('protocol', models.CharField(max_length=50)),
                 ('ports', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)]), size=None)),
                 ('ports', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)]), size=None)),

+ 3 - 3
netbox/ipam/migrations/0059_l2vpn.py

@@ -1,4 +1,4 @@
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 from django.db import migrations, models
 from django.db import migrations, models
 import django.db.models.deletion
 import django.db.models.deletion
 import taggit.managers
 import taggit.managers
@@ -20,7 +20,7 @@ class Migration(migrations.Migration):
                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
                 ('created', models.DateTimeField(auto_now_add=True, null=True)),
                 ('created', models.DateTimeField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField()),
                 ('slug', models.SlugField()),
                 ('type', models.CharField(max_length=50)),
                 ('type', models.CharField(max_length=50)),
@@ -42,7 +42,7 @@ class Migration(migrations.Migration):
                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
                 ('created', models.DateTimeField(auto_now_add=True, null=True)),
                 ('created', models.DateTimeField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('assigned_object_id', models.PositiveBigIntegerField()),
                 ('assigned_object_id', models.PositiveBigIntegerField()),
                 ('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
                 ('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
                 ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='ipam.l2vpn')),
                 ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='ipam.l2vpn')),

+ 2 - 1
netbox/netbox/filtersets.py

@@ -46,7 +46,7 @@ class BaseFilterSet(django_filters.FilterSet):
             'filter_class': filters.MultiValueDateTimeFilter
             'filter_class': filters.MultiValueDateTimeFilter
         },
         },
         models.DecimalField: {
         models.DecimalField: {
-            'filter_class': filters.MultiValueNumberFilter
+            'filter_class': filters.MultiValueDecimalFilter
         },
         },
         models.EmailField: {
         models.EmailField: {
             'filter_class': filters.MultiValueCharFilter
             'filter_class': filters.MultiValueCharFilter
@@ -95,6 +95,7 @@ class BaseFilterSet(django_filters.FilterSet):
             filters.MultiValueDateFilter,
             filters.MultiValueDateFilter,
             filters.MultiValueDateTimeFilter,
             filters.MultiValueDateTimeFilter,
             filters.MultiValueNumberFilter,
             filters.MultiValueNumberFilter,
+            filters.MultiValueDecimalFilter,
             filters.MultiValueTimeFilter
             filters.MultiValueTimeFilter
         )):
         )):
             return FILTER_NUMERIC_BASED_LOOKUP_MAP
             return FILTER_NUMERIC_BASED_LOOKUP_MAP

+ 2 - 2
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.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
     )
     )

+ 3 - 3
netbox/tenancy/migrations/0001_squashed_0012.py

@@ -1,4 +1,4 @@
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 from django.db import migrations, models
 from django.db import migrations, models
 import django.db.models.deletion
 import django.db.models.deletion
 import mptt.fields
 import mptt.fields
@@ -34,7 +34,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -54,7 +54,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),

+ 4 - 4
netbox/tenancy/migrations/0003_contacts.py

@@ -1,4 +1,4 @@
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 from django.db import migrations, models
 from django.db import migrations, models
 import django.db.models.deletion
 import django.db.models.deletion
 import mptt.fields
 import mptt.fields
@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -34,7 +34,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100)),
                 ('name', models.CharField(max_length=100)),
                 ('slug', models.SlugField(max_length=100)),
                 ('slug', models.SlugField(max_length=100)),
@@ -55,7 +55,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100)),
                 ('name', models.CharField(max_length=100)),
                 ('title', models.CharField(blank=True, max_length=100)),
                 ('title', models.CharField(blank=True, max_length=100)),

+ 5 - 3
netbox/utilities/filters.py

@@ -3,8 +3,6 @@ from django import forms
 from django.conf import settings
 from django.conf import settings
 from django_filters.constants import EMPTY_VALUES
 from django_filters.constants import EMPTY_VALUES
 
 
-from utilities.forms import MACAddressField
-
 
 
 def multivalue_field_factory(field_class):
 def multivalue_field_factory(field_class):
     """
     """
@@ -31,7 +29,7 @@ def multivalue_field_factory(field_class):
             for v in value:
             for v in value:
                 super().validate(v)
                 super().validate(v)
 
 
-    return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict())
+    return type(f'MultiValue{field_class.__name__}', (NewField,), dict())
 
 
 
 
 #
 #
@@ -54,6 +52,10 @@ class MultiValueNumberFilter(django_filters.MultipleChoiceFilter):
     field_class = multivalue_field_factory(forms.IntegerField)
     field_class = multivalue_field_factory(forms.IntegerField)
 
 
 
 
+class MultiValueDecimalFilter(django_filters.MultipleChoiceFilter):
+    field_class = multivalue_field_factory(forms.DecimalField)
+
+
 class MultiValueTimeFilter(django_filters.MultipleChoiceFilter):
 class MultiValueTimeFilter(django_filters.MultipleChoiceFilter):
     field_class = multivalue_field_factory(forms.TimeField)
     field_class = multivalue_field_factory(forms.TimeField)
 
 

+ 17 - 0
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)

+ 6 - 6
netbox/virtualization/migrations/0001_squashed_0022.py

@@ -1,5 +1,5 @@
 import dcim.fields
 import dcim.fields
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 import django.core.validators
 import django.core.validators
 from django.db import migrations, models
 from django.db import migrations, models
 import django.db.models.deletion
 import django.db.models.deletion
@@ -51,7 +51,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('comments', models.TextField(blank=True)),
                 ('comments', models.TextField(blank=True)),
@@ -65,7 +65,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -80,7 +80,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -95,7 +95,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('local_context_data', models.JSONField(blank=True, null=True)),
                 ('local_context_data', models.JSONField(blank=True, null=True)),
                 ('name', models.CharField(max_length=64)),
                 ('name', models.CharField(max_length=64)),
@@ -147,7 +147,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('enabled', models.BooleanField(default=True)),
                 ('enabled', models.BooleanField(default=True)),
                 ('mac_address', dcim.fields.MACAddressField(blank=True, null=True)),
                 ('mac_address', dcim.fields.MACAddressField(blank=True, null=True)),

+ 4 - 4
netbox/wireless/migrations/0001_wireless.py

@@ -1,4 +1,4 @@
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
 from django.db import migrations, models
 from django.db import migrations, models
 import django.db.models.deletion
 import django.db.models.deletion
 import mptt.fields
 import mptt.fields
@@ -21,7 +21,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('ssid', models.CharField(max_length=32)),
                 ('ssid', models.CharField(max_length=32)),
                 ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wireless_lans', to='wireless.wirelesslangroup')),
                 ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wireless_lans', to='wireless.wirelesslangroup')),
@@ -65,7 +65,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('created', models.DateField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('id', models.BigAutoField(primary_key=True, serialize=False)),
                 ('ssid', models.CharField(blank=True, max_length=32)),
                 ('ssid', models.CharField(blank=True, max_length=32)),
                 ('status', models.CharField(default='connected', max_length=50)),
                 ('status', models.CharField(default='connected', max_length=50)),