diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index de59d0327..1dd19a5c0 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -4,4 +4,10 @@ ### Enhancements +* [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results + +### REST API Changes + +* extras.CustomField + * Added `group_name` field diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index e05d4083c..eed7f7603 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -88,8 +88,8 @@ class CustomFieldSerializer(ValidatedModelSerializer): class Meta: model = CustomField fields = [ - 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'description', - 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', + 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', + 'description', 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 25477fbda..467ae23af 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -62,7 +62,7 @@ class CustomFieldFilterSet(BaseFilterSet): class Meta: model = CustomField - fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight', 'description'] + fields = ['id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'weight', 'description'] def search(self, queryset, name, value): if not value.strip(): @@ -70,6 +70,7 @@ class CustomFieldFilterSet(BaseFilterSet): return queryset.filter( Q(name__icontains=value) | Q(label__icontains=value) | + Q(group_name__icontains=value) | Q(description__icontains=value) ) diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index e16f8aeac..b722bd751 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -24,6 +24,9 @@ class CustomFieldBulkEditForm(BulkEditForm): queryset=CustomField.objects.all(), widget=forms.MultipleHiddenInput ) + group_name = forms.CharField( + required=False + ) description = forms.CharField( required=False ) @@ -35,7 +38,7 @@ class CustomFieldBulkEditForm(BulkEditForm): required=False ) - nullable_fields = ('description',) + nullable_fields = ('group_name', 'description',) class CustomLinkBulkEditForm(BulkEditForm): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index fa6d8af55..dabf2f811 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -36,8 +36,8 @@ class CustomFieldCSVForm(CSVModelForm): class Meta: model = CustomField fields = ( - 'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default', - 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', + 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 5d66c8be8..1710ecb89 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -32,7 +32,7 @@ __all__ = ( class CustomFieldFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('type', 'content_types', 'weight', 'required')), + ('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required')), ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), @@ -44,6 +44,9 @@ class CustomFieldFilterForm(FilterForm): required=False, label=_('Field type') ) + group_name = forms.CharField( + required=False + ) weight = forms.IntegerField( required=False ) diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 112911f42..b07853f86 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -40,7 +40,9 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): ) fieldsets = ( - ('Custom Field', ('content_types', 'name', 'label', 'type', 'object_type', 'weight', 'required', 'description')), + ('Custom Field', ( + 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', + )), ('Behavior', ('filter_logic',)), ('Values', ('default', 'choices')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), diff --git a/netbox/extras/migrations/0074_customfield_group_name.py b/netbox/extras/migrations/0074_customfield_group_name.py new file mode 100644 index 000000000..e1be76b1f --- /dev/null +++ b/netbox/extras/migrations/0074_customfield_group_name.py @@ -0,0 +1,22 @@ +# Generated by Django 4.0.4 on 2022-04-15 17:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0073_journalentry_tags_custom_fields'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customfield', + options={'ordering': ['group_name', 'weight', 'name']}, + ), + migrations.AddField( + model_name='customfield', + name='group_name', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 49afe1bba..55caa4a70 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -79,6 +79,11 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): help_text='Name of the field as displayed to users (if not provided, ' 'the field\'s name will be used)' ) + group_name = models.CharField( + max_length=50, + blank=True, + help_text="Custom fields within the same group will be displayed together" + ) description = models.CharField( max_length=200, blank=True @@ -134,7 +139,7 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): objects = CustomFieldManager() class Meta: - ordering = ['weight', 'name'] + ordering = ['group_name', 'weight', 'name'] def __str__(self): return self.label or self.name.replace('_', ' ').capitalize() diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index a13054d56..1a0f5d58a 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -32,10 +32,10 @@ class CustomFieldTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = CustomField fields = ( - 'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default', + 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default', 'description', 'filter_logic', 'choices', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description') + default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') # diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index e443dde5f..4bd1b0e9c 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -1,3 +1,5 @@ +from collections import defaultdict + from django.contrib.contenttypes.fields import GenericRelation from django.db.models.signals import class_prepared from django.dispatch import receiver @@ -117,6 +119,16 @@ class CustomFieldsMixin(models.Model): return data + def get_custom_fields_by_group(self): + """ + Return a dictionary of custom field/value mappings organized by group. + """ + grouped_custom_fields = defaultdict(dict) + for cf, value in self.get_custom_fields().items(): + grouped_custom_fields[cf.group_name][cf] = value + + return dict(grouped_custom_fields) + def clean(self): super().clean() from extras.models import CustomField diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index 9be7a485a..0d9856938 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -19,6 +19,10 @@ Label {{ object.label|placeholder }} + + Group Name + {{ object.group_name|placeholder }} + Type {{ object.get_type_display }} diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index 32e586d3a..b18d44030 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -1,49 +1,54 @@ {% load helpers %} -{% with custom_fields=object.get_custom_fields %} - {% if custom_fields %} -
-
Custom Fields
-
- - {% for field, value in custom_fields.items %} - - - - +{% with custom_fields=object.get_custom_fields_by_group %} + {% if custom_fields %} +
+
Custom Fields
+
+ {% for group_name, fields in custom_fields.items %} + {% if group_name %} +
{{ group_name }}
+ {% endif %} +
- {{ field }} - - {% if field.type == 'integer' and value is not None %} - {{ value }} - {% elif field.type == 'longtext' and value %} - {{ value|markdown }} - {% elif field.type == 'boolean' and value == True %} - {% checkmark value true="True" %} - {% elif field.type == 'boolean' and value == False %} - {% checkmark value false="False" %} - {% elif field.type == 'url' and value %} - {{ value|truncatechars:70 }} - {% elif field.type == 'json' and value %} -
{{ value|json }}
- {% elif field.type == 'multiselect' and value %} - {{ value|join:", " }} - {% elif field.type == 'object' and value %} - {{ value|linkify }} - {% elif field.type == 'multiobject' and value %} - {% for obj in value %} - {{ obj|linkify }}{% if not forloop.last %}
{% endif %} - {% endfor %} - {% elif value %} - {{ value }} - {% elif field.required %} - Not defined - {% else %} - - {% endif %} -
+ {% for field, value in fields.items %} + + +
+ {{ field }} + + {% if field.type == 'integer' and value is not None %} + {{ value }} + {% elif field.type == 'longtext' and value %} + {{ value|markdown }} + {% elif field.type == 'boolean' and value == True %} + {% checkmark value true="True" %} + {% elif field.type == 'boolean' and value == False %} + {% checkmark value false="False" %} + {% elif field.type == 'url' and value %} + {{ value|truncatechars:70 }} + {% elif field.type == 'json' and value %} +
{{ value|json }}
+ {% elif field.type == 'multiselect' and value %} + {{ value|join:", " }} + {% elif field.type == 'object' and value %} + {{ value|linkify }} + {% elif field.type == 'multiobject' and value %} + {% for obj in value %} + {{ obj|linkify }}{% if not forloop.last %}
{% endif %} {% endfor %} -
-
-
- {% endif %} + {% elif value %} + {{ value }} + {% elif field.required %} + Not defined + {% else %} + {{ ''|placeholder }} + {% endif %} + + + {% endfor %} + + {% endfor %} + + + {% endif %} {% endwith %}