mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Closes #8495: Enable custom field grouping
This commit is contained in:
@ -4,4 +4,10 @@
|
|||||||
|
|
||||||
### Enhancements
|
### 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
|
* [#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
|
||||||
|
@ -88,8 +88,8 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = CustomField
|
model = CustomField
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'description',
|
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
|
||||||
'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum',
|
'description', 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum',
|
||||||
'validation_regex', 'choices', 'created', 'last_updated',
|
'validation_regex', 'choices', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -62,7 +62,7 @@ class CustomFieldFilterSet(BaseFilterSet):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomField
|
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):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
@ -70,6 +70,7 @@ class CustomFieldFilterSet(BaseFilterSet):
|
|||||||
return queryset.filter(
|
return queryset.filter(
|
||||||
Q(name__icontains=value) |
|
Q(name__icontains=value) |
|
||||||
Q(label__icontains=value) |
|
Q(label__icontains=value) |
|
||||||
|
Q(group_name__icontains=value) |
|
||||||
Q(description__icontains=value)
|
Q(description__icontains=value)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -24,6 +24,9 @@ class CustomFieldBulkEditForm(BulkEditForm):
|
|||||||
queryset=CustomField.objects.all(),
|
queryset=CustomField.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput
|
widget=forms.MultipleHiddenInput
|
||||||
)
|
)
|
||||||
|
group_name = forms.CharField(
|
||||||
|
required=False
|
||||||
|
)
|
||||||
description = forms.CharField(
|
description = forms.CharField(
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -35,7 +38,7 @@ class CustomFieldBulkEditForm(BulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
nullable_fields = ('description',)
|
nullable_fields = ('group_name', 'description',)
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkBulkEditForm(BulkEditForm):
|
class CustomLinkBulkEditForm(BulkEditForm):
|
||||||
|
@ -36,8 +36,8 @@ class CustomFieldCSVForm(CSVModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = CustomField
|
model = CustomField
|
||||||
fields = (
|
fields = (
|
||||||
'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
|
'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic',
|
||||||
'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -32,7 +32,7 @@ __all__ = (
|
|||||||
class CustomFieldFilterForm(FilterForm):
|
class CustomFieldFilterForm(FilterForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q',)),
|
(None, ('q',)),
|
||||||
('Attributes', ('type', 'content_types', 'weight', 'required')),
|
('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required')),
|
||||||
)
|
)
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.all(),
|
||||||
@ -44,6 +44,9 @@ class CustomFieldFilterForm(FilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Field type')
|
label=_('Field type')
|
||||||
)
|
)
|
||||||
|
group_name = forms.CharField(
|
||||||
|
required=False
|
||||||
|
)
|
||||||
weight = forms.IntegerField(
|
weight = forms.IntegerField(
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
@ -40,7 +40,9 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
fieldsets = (
|
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',)),
|
('Behavior', ('filter_logic',)),
|
||||||
('Values', ('default', 'choices')),
|
('Values', ('default', 'choices')),
|
||||||
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
|
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
|
||||||
|
22
netbox/extras/migrations/0074_customfield_group_name.py
Normal file
22
netbox/extras/migrations/0074_customfield_group_name.py
Normal file
@ -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),
|
||||||
|
),
|
||||||
|
]
|
@ -79,6 +79,11 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
|||||||
help_text='Name of the field as displayed to users (if not provided, '
|
help_text='Name of the field as displayed to users (if not provided, '
|
||||||
'the field\'s name will be used)'
|
'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(
|
description = models.CharField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
blank=True
|
blank=True
|
||||||
@ -134,7 +139,7 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
|||||||
objects = CustomFieldManager()
|
objects = CustomFieldManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['weight', 'name']
|
ordering = ['group_name', 'weight', 'name']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.label or self.name.replace('_', ' ').capitalize()
|
return self.label or self.name.replace('_', ' ').capitalize()
|
||||||
|
@ -32,10 +32,10 @@ class CustomFieldTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = CustomField
|
model = CustomField
|
||||||
fields = (
|
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',
|
'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')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
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
|
||||||
@ -117,6 +119,16 @@ class CustomFieldsMixin(models.Model):
|
|||||||
|
|
||||||
return data
|
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):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
from extras.models import CustomField
|
from extras.models import CustomField
|
||||||
|
@ -19,6 +19,10 @@
|
|||||||
<th scope="row">Label</th>
|
<th scope="row">Label</th>
|
||||||
<td>{{ object.label|placeholder }}</td>
|
<td>{{ object.label|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Group Name</th>
|
||||||
|
<td>{{ object.group_name|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Type</th>
|
<th scope="row">Type</th>
|
||||||
<td>{{ object.get_type_display }}</td>
|
<td>{{ object.get_type_display }}</td>
|
||||||
|
@ -1,49 +1,54 @@
|
|||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
|
||||||
{% with custom_fields=object.get_custom_fields %}
|
{% with custom_fields=object.get_custom_fields_by_group %}
|
||||||
{% if custom_fields %}
|
{% if custom_fields %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">Custom Fields</h5>
|
<h5 class="card-header">Custom Fields</h5>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table table-hover attr-table">
|
{% for group_name, fields in custom_fields.items %}
|
||||||
{% for field, value in custom_fields.items %}
|
{% if group_name %}
|
||||||
<tr>
|
<h6><strong>{{ group_name }}</strong></h6>
|
||||||
<td>
|
{% endif %}
|
||||||
<span title="{{ field.description|escape }}">{{ field }}</span>
|
<table class="table table-hover attr-table">
|
||||||
</td>
|
{% for field, value in fields.items %}
|
||||||
<td>
|
<tr>
|
||||||
{% if field.type == 'integer' and value is not None %}
|
<td>
|
||||||
{{ value }}
|
<span title="{{ field.description|escape }}">{{ field }}</span>
|
||||||
{% elif field.type == 'longtext' and value %}
|
</td>
|
||||||
{{ value|markdown }}
|
<td>
|
||||||
{% elif field.type == 'boolean' and value == True %}
|
{% if field.type == 'integer' and value is not None %}
|
||||||
{% checkmark value true="True" %}
|
{{ value }}
|
||||||
{% elif field.type == 'boolean' and value == False %}
|
{% elif field.type == 'longtext' and value %}
|
||||||
{% checkmark value false="False" %}
|
{{ value|markdown }}
|
||||||
{% elif field.type == 'url' and value %}
|
{% elif field.type == 'boolean' and value == True %}
|
||||||
<a href="{{ value }}">{{ value|truncatechars:70 }}</a>
|
{% checkmark value true="True" %}
|
||||||
{% elif field.type == 'json' and value %}
|
{% elif field.type == 'boolean' and value == False %}
|
||||||
<pre>{{ value|json }}</pre>
|
{% checkmark value false="False" %}
|
||||||
{% elif field.type == 'multiselect' and value %}
|
{% elif field.type == 'url' and value %}
|
||||||
{{ value|join:", " }}
|
<a href="{{ value }}">{{ value|truncatechars:70 }}</a>
|
||||||
{% elif field.type == 'object' and value %}
|
{% elif field.type == 'json' and value %}
|
||||||
{{ value|linkify }}
|
<pre>{{ value|json }}</pre>
|
||||||
{% elif field.type == 'multiobject' and value %}
|
{% elif field.type == 'multiselect' and value %}
|
||||||
{% for obj in value %}
|
{{ value|join:", " }}
|
||||||
{{ obj|linkify }}{% if not forloop.last %}<br />{% endif %}
|
{% elif field.type == 'object' and value %}
|
||||||
{% endfor %}
|
{{ value|linkify }}
|
||||||
{% elif value %}
|
{% elif field.type == 'multiobject' and value %}
|
||||||
{{ value }}
|
{% for obj in value %}
|
||||||
{% elif field.required %}
|
{{ obj|linkify }}{% if not forloop.last %}<br />{% endif %}
|
||||||
<span class="text-warning">Not defined</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
{% elif value %}
|
||||||
</div>
|
{{ value }}
|
||||||
</div>
|
{% elif field.required %}
|
||||||
{% endif %}
|
<span class="text-warning"><i class="mdi mdi-alert"></i> Not defined</span>
|
||||||
|
{% else %}
|
||||||
|
{{ ''|placeholder }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
Reference in New Issue
Block a user