diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 8c0855719..e83165aaf 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -2,7 +2,6 @@ from django import forms from django.contrib import admin from utilities.forms import LaxURLField -from .choices import CustomFieldTypeChoices from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook @@ -76,6 +75,7 @@ class CustomFieldForm(forms.ModelForm): model = CustomField exclude = [] widgets = { + 'default': forms.TextInput(), 'validation_regex': forms.Textarea( attrs={ 'cols': 80, diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 229efa3b2..c8c4ba89e 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -26,15 +26,8 @@ class CustomFieldDefaultValues: # Populate the default value for each CustomField value = {} for field in fields: - if field.default: - if field.type == CustomFieldTypeChoices.TYPE_INTEGER: - field_value = int(field.default) - elif field.type == CustomFieldTypeChoices.TYPE_BOOLEAN: - # TODO: Fix default value assignment for boolean custom fields - field_value = False if field.default.lower() == 'false' else bool(field.default) - else: - field_value = field.default - value[field.name] = field_value + if field.default is not None: + value[field.name] = field.default else: value[field.name] = None diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 54bf8103c..7b341f74d 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -78,7 +78,7 @@ class CustomFieldFilterSet(django_filters.FilterSet): class Meta: model = CustomField - fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'default', 'weight'] + fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight'] class ExportTemplateFilterSet(BaseFilterSet): diff --git a/netbox/extras/migrations/0050_customfield_changes.py b/netbox/extras/migrations/0050_customfield_changes.py index 7d0a4c575..711b21041 100644 --- a/netbox/extras/migrations/0050_customfield_changes.py +++ b/netbox/extras/migrations/0050_customfield_changes.py @@ -34,6 +34,12 @@ class Migration(migrations.Migration): size=None ), ), + # Introduce new default field (to be renamed later) + migrations.AddField( + model_name='customfield', + name='default2', + field=models.JSONField(blank=True, null=True), + ), # Rename obj_type to content_types migrations.RenameField( model_name='customfield', diff --git a/netbox/extras/migrations/0051_migrate_customfields.py b/netbox/extras/migrations/0051_migrate_customfields.py index e20618703..2c992c700 100644 --- a/netbox/extras/migrations/0051_migrate_customfields.py +++ b/netbox/extras/migrations/0051_migrate_customfields.py @@ -16,6 +16,28 @@ def deserialize_value(field, value): return value +def migrate_customfield_defaults(apps, schema_editor): + """ + Copy old serialized defaults to native JSON types. + """ + CustomField = apps.get_model('extras', 'CustomField') + + for customfield in CustomField.objects.exclude(default=''): + try: + if customfield.type == CustomFieldTypeChoices.TYPE_INTEGER: + value = int(customfield.default) + elif customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN: + value = customfield.default in ['true', 'yes', '1'] + else: + value = customfield.default + except ValueError: + raise ValueError( + f'Invalid default value "{customfield.default}" found for {customfield.type} ' + f'custom field {customfield.name}' + ) + CustomField.objects.filter(pk=customfield.pk).update(default2=value) + + def migrate_customfieldchoices(apps, schema_editor): """ Collect all CustomFieldChoices for each applicable CustomField, and save them locally as an array on @@ -73,6 +95,9 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RunPython( + code=migrate_customfield_defaults + ), migrations.RunPython( code=migrate_customfieldchoices ), diff --git a/netbox/extras/migrations/0052_delete_customfieldchoice_customfieldvalue.py b/netbox/extras/migrations/0052_customfield_cleanup.py similarity index 57% rename from netbox/extras/migrations/0052_delete_customfieldchoice_customfieldvalue.py rename to netbox/extras/migrations/0052_customfield_cleanup.py index 8b5e2ba45..543cdee97 100644 --- a/netbox/extras/migrations/0052_delete_customfieldchoice_customfieldvalue.py +++ b/netbox/extras/migrations/0052_customfield_cleanup.py @@ -8,6 +8,15 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RemoveField( + model_name='CustomField', + name='default', + ), + migrations.RenameField( + model_name='CustomField', + old_name='default2', + new_name='default' + ), migrations.DeleteModel( name='CustomFieldChoice', ), diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 039d6fc44..be06eea8a 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -115,10 +115,11 @@ class CustomField(models.Model): help_text='Loose matches any instance of a given string; exact ' 'matches the entire field.' ) - default = models.CharField( - max_length=100, + default = models.JSONField( blank=True, - help_text='Default value for the field. Use "true" or "false" for booleans.' + null=True, + help_text='Default value for the field (must be a JSON value). Encapsulate ' + 'strings with double quotes (e.g. "Foo").' ) weight = models.PositiveSmallIntegerField( default=100, @@ -171,6 +172,15 @@ class CustomField(models.Model): obj.save() def clean(self): + # Validate the field's default value (if any) + if self.default is not None: + try: + self.validate(self.default) + except ValidationError as err: + raise ValidationError({ + 'default': f'Invalid default value "{self.default}": {err.message}' + }) + # 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({ @@ -232,8 +242,6 @@ class CustomField(models.Model): (True, 'True'), (False, 'False'), ) - if initial is not None: - initial = bool(initial) field = forms.NullBooleanField( required=required, initial=initial, widget=StaticSelect2(choices=choices) ) diff --git a/netbox/ipam/migrations/0041_routetarget.py b/netbox/ipam/migrations/0041_routetarget.py index 9cc37b742..2c2909279 100644 --- a/netbox/ipam/migrations/0041_routetarget.py +++ b/netbox/ipam/migrations/0041_routetarget.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ ('tenancy', '0010_custom_field_data'), - ('extras', '0052_delete_customfieldchoice_customfieldvalue'), + ('extras', '0052_customfield_cleanup'), ('ipam', '0040_service_drop_port'), ]