diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 42dc486b8..0def32fde 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -5,7 +5,7 @@ from django.db import transaction from rest_framework import serializers from rest_framework.exceptions import ValidationError -from extras.constants import * +from extras.choices import * from extras.models import CustomField, CustomFieldChoice, CustomFieldValue from utilities.api import ValidatedModelSerializer @@ -37,7 +37,7 @@ class CustomFieldsSerializer(serializers.BaseSerializer): if value not in [None, '']: # Validate integer - if cf.type == CF_TYPE_INTEGER: + if cf.type == CustomFieldTypeChoices.TYPE_INTEGER: try: int(value) except ValueError: @@ -46,13 +46,13 @@ class CustomFieldsSerializer(serializers.BaseSerializer): ) # Validate boolean - if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]: + if cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: raise ValidationError( "Invalid value for boolean field {}: {}".format(field_name, value) ) # Validate date - if cf.type == CF_TYPE_DATE: + if cf.type == CustomFieldTypeChoices.TYPE_DATE: try: datetime.strptime(value, '%Y-%m-%d') except ValueError: @@ -61,7 +61,7 @@ class CustomFieldsSerializer(serializers.BaseSerializer): ) # Validate selected choice - if cf.type == CF_TYPE_SELECT: + if cf.type == CustomFieldTypeChoices.TYPE_SELECT: try: value = int(value) except ValueError: @@ -100,7 +100,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): instance.custom_fields = {} for field in fields: value = instance.cf.get(field.name) - if field.type == CF_TYPE_SELECT and value is not None: + if field.type == CustomFieldTypeChoices.TYPE_SELECT and value is not None: instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data else: instance.custom_fields[field.name] = value diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py new file mode 100644 index 000000000..ec721c552 --- /dev/null +++ b/netbox/extras/choices.py @@ -0,0 +1,33 @@ +from utilities.choices import ChoiceSet + + +# +# CustomFields +# + +class CustomFieldTypeChoices(ChoiceSet): + + TYPE_TEXT = 'text' + TYPE_INTEGER = 'integer' + TYPE_BOOLEAN = 'boolean' + TYPE_DATE = 'date' + TYPE_URL = 'url' + TYPE_SELECT = 'select' + + CHOICES = ( + (TYPE_TEXT, 'Text'), + (TYPE_INTEGER, 'Integer'), + (TYPE_BOOLEAN, 'Boolean (true/false)'), + (TYPE_DATE, 'Date'), + (TYPE_URL, 'URL'), + (TYPE_SELECT, 'Selection'), + ) + + LEGACY_MAP = { + TYPE_TEXT: 100, + TYPE_INTEGER: 200, + TYPE_BOOLEAN: 300, + TYPE_DATE: 400, + TYPE_URL: 500, + TYPE_SELECT: 600, + } diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 2b4077372..850167235 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -19,22 +19,6 @@ CUSTOMFIELD_MODELS = [ 'virtualization.virtualmachine', ] -# Custom field types -CF_TYPE_TEXT = 100 -CF_TYPE_INTEGER = 200 -CF_TYPE_BOOLEAN = 300 -CF_TYPE_DATE = 400 -CF_TYPE_URL = 500 -CF_TYPE_SELECT = 600 -CUSTOMFIELD_TYPE_CHOICES = ( - (CF_TYPE_TEXT, 'Text'), - (CF_TYPE_INTEGER, 'Integer'), - (CF_TYPE_BOOLEAN, 'Boolean (true/false)'), - (CF_TYPE_DATE, 'Date'), - (CF_TYPE_URL, 'URL'), - (CF_TYPE_SELECT, 'Selection'), -) - # Custom field filter logic choices CF_FILTER_DISABLED = 0 CF_FILTER_LOOSE = 1 diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index b307aa308..a3c488281 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -4,6 +4,7 @@ from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup +from .choices import * from .constants import * from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag @@ -25,7 +26,7 @@ class CustomFieldFilter(django_filters.Filter): return queryset # Selection fields get special treatment (values must be integers) - if self.cf_type == CF_TYPE_SELECT: + if self.cf_type == CustomFieldTypeChoices.TYPE_SELECT: try: # Treat 0 as None if int(value) == 0: @@ -42,7 +43,7 @@ class CustomFieldFilter(django_filters.Filter): return queryset.none() # Apply the assigned filter logic (exact or loose) - if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT: + if self.cf_type == CustomFieldTypeChoices.TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT: queryset = queryset.filter( custom_field_values__field__name=self.field_name, custom_field_values__serialized_value=value diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index efb92b2ce..c6dbeaf8d 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -13,6 +13,7 @@ from utilities.forms import ( CommentField, ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, ) +from .choices import * from .constants import * from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag @@ -35,11 +36,11 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F initial = cf.default if not bulk_edit else None # Integer - if cf.type == CF_TYPE_INTEGER: + if cf.type == CustomFieldTypeChoices.TYPE_INTEGER: field = forms.IntegerField(required=cf.required, initial=initial) # Boolean - elif cf.type == CF_TYPE_BOOLEAN: + elif cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN: choices = ( (None, '---------'), (1, 'True'), @@ -56,11 +57,11 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F ) # Date - elif cf.type == CF_TYPE_DATE: + elif cf.type == CustomFieldTypeChoices.TYPE_DATE: field = forms.DateField(required=cf.required, initial=initial, help_text="Date format: YYYY-MM-DD") # Select - elif cf.type == CF_TYPE_SELECT: + elif cf.type == CustomFieldTypeChoices.TYPE_SELECT: choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] if not cf.required or bulk_edit or filterable_only: choices = [(None, '---------')] + choices @@ -74,7 +75,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required, initial=default_choice) # URL - elif cf.type == CF_TYPE_URL: + elif cf.type == CustomFieldTypeChoices.TYPE_URL: field = LaxURLField(required=cf.required, initial=initial) # Text diff --git a/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py b/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py index c6167ff9f..098f234dc 100644 --- a/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py +++ b/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py @@ -8,8 +8,6 @@ import django.db.models.deletion import extras.models from django.db.utils import OperationalError -from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT - def verify_postgresql_version(apps, schema_editor): """ diff --git a/netbox/extras/migrations/0010_customfield_filter_logic.py b/netbox/extras/migrations/0010_customfield_filter_logic.py index dbff03e2d..f153c9d11 100644 --- a/netbox/extras/migrations/0010_customfield_filter_logic.py +++ b/netbox/extras/migrations/0010_customfield_filter_logic.py @@ -2,7 +2,7 @@ # Generated by Django 1.11.9 on 2018-02-21 19:48 from django.db import migrations, models -from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT +from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE def is_filterable_to_filter_logic(apps, schema_editor): @@ -10,7 +10,7 @@ def is_filterable_to_filter_logic(apps, schema_editor): CustomField.objects.filter(is_filterable=False).update(filter_logic=CF_FILTER_DISABLED) CustomField.objects.filter(is_filterable=True).update(filter_logic=CF_FILTER_LOOSE) # Select fields match on primary key only - CustomField.objects.filter(is_filterable=True, type=CF_TYPE_SELECT).update(filter_logic=CF_FILTER_EXACT) + CustomField.objects.filter(is_filterable=True, type=600).update(filter_logic=CF_FILTER_EXACT) def filter_logic_to_is_filterable(apps, schema_editor): diff --git a/netbox/extras/migrations/0029_3569_customfield_fields.py b/netbox/extras/migrations/0029_3569_customfield_fields.py new file mode 100644 index 000000000..436f1c9e8 --- /dev/null +++ b/netbox/extras/migrations/0029_3569_customfield_fields.py @@ -0,0 +1,36 @@ +from django.db import migrations, models + + +CUSTOMFIELD_TYPE_CHOICES = ( + (100, 'text'), + (200, 'integer'), + (300, 'boolean'), + (400, 'date'), + (500, 'url'), + (600, 'select') +) + + +def customfield_type_to_slug(apps, schema_editor): + CustomField = apps.get_model('extras', 'CustomField') + for id, slug in CUSTOMFIELD_TYPE_CHOICES: + CustomField.objects.filter(type=str(id)).update(type=slug) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ('extras', '0028_remove_topology_maps'), + ] + + operations = [ + migrations.AlterField( + model_name='customfield', + name='type', + field=models.CharField(default='text', max_length=50), + ), + migrations.RunPython( + code=customfield_type_to_slug + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 8f652f4eb..163258b15 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -15,6 +15,7 @@ from taggit.models import TagBase, GenericTaggedItemBase from utilities.fields import ColorField from utilities.utils import deepmerge, model_names_to_filter_dict +from .choices import * from .constants import * from .querysets import ConfigContextQuerySet @@ -182,9 +183,10 @@ class CustomField(models.Model): limit_choices_to=get_custom_field_models, help_text='The object(s) to which this field applies.' ) - type = models.PositiveSmallIntegerField( - choices=CUSTOMFIELD_TYPE_CHOICES, - default=CF_TYPE_TEXT + type = models.CharField( + max_length=50, + choices=CustomFieldTypeChoices, + default=CustomFieldTypeChoices.TYPE_TEXT ) name = models.CharField( max_length=50, @@ -233,15 +235,15 @@ class CustomField(models.Model): """ if value is None: return '' - if self.type == CF_TYPE_BOOLEAN: + if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: return str(int(bool(value))) - if self.type == CF_TYPE_DATE: + if self.type == CustomFieldTypeChoices.TYPE_DATE: # Could be date/datetime object or string try: return value.strftime('%Y-%m-%d') except AttributeError: return value - if self.type == CF_TYPE_SELECT: + if self.type == CustomFieldTypeChoices.TYPE_SELECT: # Could be ModelChoiceField or TypedChoiceField return str(value.id) if hasattr(value, 'id') else str(value) return value @@ -252,14 +254,14 @@ class CustomField(models.Model): """ if serialized_value == '': return None - if self.type == CF_TYPE_INTEGER: + if self.type == CustomFieldTypeChoices.TYPE_INTEGER: return int(serialized_value) - if self.type == CF_TYPE_BOOLEAN: + if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: return bool(int(serialized_value)) - if self.type == CF_TYPE_DATE: + if self.type == CustomFieldTypeChoices.TYPE_DATE: # Read date as YYYY-MM-DD return date(*[int(n) for n in serialized_value.split('-')]) - if self.type == CF_TYPE_SELECT: + if self.type == CustomFieldTypeChoices.TYPE_SELECT: return self.choices.get(pk=int(serialized_value)) return serialized_value @@ -312,7 +314,7 @@ class CustomFieldChoice(models.Model): to='extras.CustomField', on_delete=models.CASCADE, related_name='choices', - limit_choices_to={'type': CF_TYPE_SELECT} + limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT} ) value = models.CharField( max_length=100 @@ -330,14 +332,17 @@ class CustomFieldChoice(models.Model): return self.value def clean(self): - if self.field.type != CF_TYPE_SELECT: + if self.field.type != CustomFieldTypeChoices.TYPE_SELECT: raise ValidationError("Custom field choices can only be assigned to selection fields.") def delete(self, using=None, keep_parents=False): # When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it pk = self.pk super().delete(using, keep_parents) - CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete() + CustomFieldValue.objects.filter( + field__type=CustomFieldTypeChoices.TYPE_SELECT, + serialized_value=str(pk) + ).delete() # diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index 22b4912b9..961adfd40 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -3,6 +3,7 @@ from django.urls import reverse from rest_framework import status from dcim.models import Site +from extras.choices import * from extras.constants import * from extras.models import CustomField, CustomFieldValue, ObjectChange from utilities.testing import APITestCase @@ -17,7 +18,7 @@ class ChangeLogTest(APITestCase): # Create a custom field on the Site model ct = ContentType.objects.get_for_model(Site) cf = CustomField( - type=CF_TYPE_TEXT, + type=CustomFieldTypeChoices.TYPE_TEXT, name='my_field', required=False ) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 96f3483bc..362b96931 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -6,7 +6,7 @@ from django.urls import reverse from rest_framework import status from dcim.models import Site -from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL, CF_TYPE_SELECT +from extras.choices import * from extras.models import CustomField, CustomFieldValue, CustomFieldChoice from utilities.testing import APITestCase from virtualization.models import VirtualMachine @@ -25,13 +25,13 @@ class CustomFieldTest(TestCase): def test_simple_fields(self): DATA = ( - {'field_type': CF_TYPE_TEXT, 'field_value': 'Foobar!', 'empty_value': ''}, - {'field_type': CF_TYPE_INTEGER, 'field_value': 0, 'empty_value': None}, - {'field_type': CF_TYPE_INTEGER, 'field_value': 42, 'empty_value': None}, - {'field_type': CF_TYPE_BOOLEAN, 'field_value': True, 'empty_value': None}, - {'field_type': CF_TYPE_BOOLEAN, 'field_value': False, 'empty_value': None}, - {'field_type': CF_TYPE_DATE, 'field_value': date(2016, 6, 23), 'empty_value': None}, - {'field_type': CF_TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''}, + {'field_type': CustomFieldTypeChoices.TYPE_TEXT, 'field_value': 'Foobar!', 'empty_value': ''}, + {'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 0, 'empty_value': None}, + {'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 42, 'empty_value': None}, + {'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': True, 'empty_value': None}, + {'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': False, 'empty_value': None}, + {'field_type': CustomFieldTypeChoices.TYPE_DATE, 'field_value': date(2016, 6, 23), 'empty_value': None}, + {'field_type': CustomFieldTypeChoices.TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''}, ) obj_type = ContentType.objects.get_for_model(Site) @@ -67,7 +67,7 @@ class CustomFieldTest(TestCase): obj_type = ContentType.objects.get_for_model(Site) # Create a custom field - cf = CustomField(type=CF_TYPE_SELECT, name='my_field', required=False) + cf = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='my_field', required=False) cf.save() cf.obj_type.set([obj_type]) cf.save() @@ -107,37 +107,37 @@ class CustomFieldAPITest(APITestCase): content_type = ContentType.objects.get_for_model(Site) # Text custom field - self.cf_text = CustomField(type=CF_TYPE_TEXT, name='magic_word') + self.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='magic_word') self.cf_text.save() self.cf_text.obj_type.set([content_type]) self.cf_text.save() # Integer custom field - self.cf_integer = CustomField(type=CF_TYPE_INTEGER, name='magic_number') + self.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='magic_number') self.cf_integer.save() self.cf_integer.obj_type.set([content_type]) self.cf_integer.save() # Boolean custom field - self.cf_boolean = CustomField(type=CF_TYPE_BOOLEAN, name='is_magic') + self.cf_boolean = CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='is_magic') self.cf_boolean.save() self.cf_boolean.obj_type.set([content_type]) self.cf_boolean.save() # Date custom field - self.cf_date = CustomField(type=CF_TYPE_DATE, name='magic_date') + self.cf_date = CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='magic_date') self.cf_date.save() self.cf_date.obj_type.set([content_type]) self.cf_date.save() # URL custom field - self.cf_url = CustomField(type=CF_TYPE_URL, name='magic_url') + self.cf_url = CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='magic_url') self.cf_url.save() self.cf_url.obj_type.set([content_type]) self.cf_url.save() # Select custom field - self.cf_select = CustomField(type=CF_TYPE_SELECT, name='magic_choice') + self.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='magic_choice') self.cf_select.save() self.cf_select.obj_type.set([content_type]) self.cf_select.save() @@ -308,8 +308,8 @@ class CustomFieldChoiceAPITest(APITestCase): vm_content_type = ContentType.objects.get_for_model(VirtualMachine) - self.cf_1 = CustomField.objects.create(name="cf_1", type=CF_TYPE_SELECT) - self.cf_2 = CustomField.objects.create(name="cf_2", type=CF_TYPE_SELECT) + self.cf_1 = CustomField.objects.create(name="cf_1", type=CustomFieldTypeChoices.TYPE_SELECT) + self.cf_2 = CustomField.objects.create(name="cf_2", type=CustomFieldTypeChoices.TYPE_SELECT) self.cf_choice_1 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_1", weight=100) self.cf_choice_2 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_2", weight=50)