From b017927c69aa0727318a9dafdb6d0f39597a76f9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 22 Jun 2021 16:28:06 -0400 Subject: [PATCH] Add UI views for custom fields --- netbox/extras/admin.py | 61 +-------- netbox/extras/forms.py | 87 ++++++++++++- .../migrations/0061_extras_change_logging.py | 23 ++++ netbox/extras/models/customfields.py | 11 +- netbox/extras/tables.py | 24 +++- netbox/extras/tests/test_views.py | 44 ++++++- netbox/extras/urls.py | 13 +- netbox/extras/views.py | 45 ++++++- netbox/templates/extras/customfield.html | 120 ++++++++++++++++++ netbox/utilities/forms/fields.py | 18 ++- netbox/utilities/templatetags/nav.py | 7 + netbox/utilities/testing/base.py | 4 +- 12 files changed, 384 insertions(+), 73 deletions(-) create mode 100644 netbox/extras/migrations/0061_extras_change_logging.py create mode 100644 netbox/templates/extras/customfield.html diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 0ceb1cc5b..57cbfbc1c 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,11 +1,9 @@ from django import forms from django.contrib import admin from django.contrib.contenttypes.models import ContentType -from django.utils.safestring import mark_safe from utilities.forms import ContentTypeChoiceField, ContentTypeMultipleChoiceField, LaxURLField -from utilities.utils import content_type_name -from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook +from .models import CustomLink, ExportTemplate, JobResult, Webhook from .utils import FeatureQuery @@ -59,63 +57,6 @@ class WebhookAdmin(admin.ModelAdmin): return ', '.join([ct.name for ct in obj.content_types.all()]) -# -# Custom fields -# - -class CustomFieldForm(forms.ModelForm): - content_types = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields') - ) - - class Meta: - model = CustomField - exclude = [] - widgets = { - 'default': forms.TextInput(), - 'validation_regex': forms.Textarea( - attrs={ - 'cols': 80, - 'rows': 3, - } - ) - } - - -@admin.register(CustomField) -class CustomFieldAdmin(admin.ModelAdmin): - actions = None - form = CustomFieldForm - list_display = [ - 'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description', - ] - list_filter = [ - 'type', 'required', 'content_types', - ] - fieldsets = ( - ('Custom Field', { - 'fields': ('type', 'name', 'weight', 'label', 'description', 'required', 'default', 'filter_logic') - }), - ('Assignment', { - 'description': 'A custom field must be assigned to one or more object types.', - 'fields': ('content_types',) - }), - ('Validation Rules', { - 'fields': ('validation_minimum', 'validation_maximum', 'validation_regex'), - 'classes': ('monospace',) - }), - ('Choices', { - 'description': 'A selection field must have two or more choices assigned to it.', - 'fields': ('choices',) - }) - ) - - def models(self, obj): - ct_names = [content_type_name(ct) for ct in obj.content_types.all()] - return mark_safe('
'.join(ct_names)) - - # # Custom links # diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index c8af566b0..4a0ce4416 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -8,8 +8,9 @@ from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGrou from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, - CommentField, ContentTypeMultipleChoiceField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, - JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, + CommentField, ContentTypeMultipleChoiceField, CSVModelForm, CSVMultipleContentTypeField, DateTimePicker, + DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2, StaticSelect2Multiple, + BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup from .choices import * @@ -21,6 +22,88 @@ from .utils import FeatureQuery # Custom fields # +class CustomFieldForm(BootstrapMixin, forms.ModelForm): + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields') + ) + + class Meta: + model = CustomField + fields = '__all__' + fieldsets = ( + ('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')), + ('Assigned Models', ('content_types',)), + ('Behavior', ('filter_logic',)), + ('Values', ('default', 'choices')), + ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), + ) + + +class CustomFieldCSVForm(CSVModelForm): + content_types = CSVMultipleContentTypeField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + help_text="One or more assigned object types" + ) + + class Meta: + model = CustomField + fields = ( + 'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default', + 'weight', + ) + + +class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=CustomField.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + required=False + ) + required = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + weight = forms.IntegerField( + required=False + ) + + class Meta: + nullable_fields = [] + + +class CustomFieldFilterForm(BootstrapMixin, forms.Form): + field_groups = [ + ['type', 'content_types'], + ['weight', 'required'], + ] + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields') + ) + type = forms.MultipleChoiceField( + choices=CustomFieldTypeChoices, + required=False, + widget=StaticSelect2Multiple() + ) + weight = forms.IntegerField( + required=False + ) + required = forms.NullBooleanField( + required=False, + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +# +# Custom field models +# + class CustomFieldsMixin: """ Extend a Form to include custom field support. diff --git a/netbox/extras/migrations/0061_extras_change_logging.py b/netbox/extras/migrations/0061_extras_change_logging.py new file mode 100644 index 000000000..3081c7ddf --- /dev/null +++ b/netbox/extras/migrations/0061_extras_change_logging.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.4 on 2021-06-23 17:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0060_customlink_button_class'), + ] + + operations = [ + migrations.AddField( + model_name='customfield', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='customfield', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 60c6adce9..4ac70bb58 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -6,11 +6,12 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.validators import RegexValidator, ValidationError from django.db import models +from django.urls import reverse from django.utils.safestring import mark_safe from extras.choices import * -from extras.utils import FeatureQuery -from netbox.models import BigIDModel +from extras.utils import FeatureQuery, extras_features +from netbox.models import ChangeLoggedModel from utilities.forms import ( CSVChoiceField, DatePicker, LaxURLField, StaticSelect2Multiple, StaticSelect2, add_blank_choice, ) @@ -29,7 +30,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): return self.get_queryset().filter(content_types=content_type) -class CustomField(BigIDModel): +@extras_features('webhooks') +class CustomField(ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, related_name='custom_fields', @@ -114,6 +116,9 @@ class CustomField(BigIDModel): def __str__(self): return self.label or self.name.replace('_', ' ').capitalize() + def get_absolute_url(self): + return reverse('extras:customfield', args=[self.pk]) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 633261448..f6bb2f000 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -4,7 +4,7 @@ from django.conf import settings from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ToggleColumn, ) -from .models import ConfigContext, JournalEntry, ObjectChange, Tag, TaggedItem +from .models import * CONFIGCONTEXT_ACTIONS = """ {% if perms.extras.change_configcontext %} @@ -28,6 +28,28 @@ OBJECTCHANGE_REQUEST_ID = """ """ +# +# Custom fields +# + +class CustomFieldTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + + class Meta(BaseTable.Meta): + model = CustomField + fields = ( + 'pk', 'name', 'label', 'type', 'required', 'weight', 'default', 'description', 'filter_logic', 'choices', + ) + default_columns = ('pk', 'name', 'label', 'type', 'required', 'description') + + +# +# Tags +# + class TagTable(BaseTable): pk = ToggleColumn() name = tables.Column( diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 286fa7613..41de01ee2 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -6,11 +6,51 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse from dcim.models import Site -from extras.choices import ObjectChangeActionChoices -from extras.models import ConfigContext, CustomLink, JournalEntry, ObjectChange, Tag +from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, ObjectChangeActionChoices +from extras.models import * from utilities.testing import ViewTestCases, TestCase +class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = CustomField + + @classmethod + def setUpTestData(cls): + + site_ct = ContentType.objects.get_for_model(Site) + custom_fields = ( + CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT), + CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT), + CustomField(name='field3', label='Field 3', type=CustomFieldTypeChoices.TYPE_TEXT), + ) + for customfield in custom_fields: + customfield.save() + customfield.content_types.add(site_ct) + + cls.form_data = { + 'name': 'field_x', + 'label': 'Field X', + 'type': 'text', + 'content_types': [site_ct.pk], + 'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT, + 'default': None, + 'weight': 200, + 'required': True, + } + + cls.csv_data = ( + "name,label,type,content_types,weight,filter_logic", + "field4,Field 4,text,dcim.site,100,exact", + "field5,Field 5,text,dcim.site,100,exact", + "field6,Field 6,text,dcim.site,100,exact", + ) + + cls.bulk_edit_data = { + 'required': True, + 'weight': 200, + } + + class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Tag diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 9ec19d215..0e87277fb 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,12 +1,23 @@ from django.urls import path from extras import views -from extras.models import ConfigContext, JournalEntry, Tag +from extras.models import ConfigContext, CustomField, JournalEntry, Tag app_name = 'extras' urlpatterns = [ + # Custom fields + path('custom-fields/', views.CustomFieldListView.as_view(), name='customfield_list'), + path('custom-fields/add/', views.CustomFieldEditView.as_view(), name='customfield_add'), + path('custom-fields/import/', views.CustomFieldBulkImportView.as_view(), name='customfield_import'), + path('custom-fields/edit/', views.CustomFieldBulkEditView.as_view(), name='customfield_bulk_edit'), + path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'), + path('custom-fields//', views.CustomFieldView.as_view(), name='customfield'), + path('custom-fields//edit/', views.CustomFieldEditView.as_view(), name='customfield_edit'), + path('custom-fields//delete/', views.CustomFieldDeleteView.as_view(), name='customfield_delete'), + path('custom-fields//changelog/', views.ObjectChangeLogView.as_view(), name='customfield_changelog', kwargs={'model': CustomField}), + # Tags path('tags/', views.TagListView.as_view(), name='tag_list'), path('tags/add/', views.TagEditView.as_view(), name='tag_add'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 5316cfbec..644908013 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -15,11 +15,54 @@ from utilities.utils import copy_safe_request, count_related, shallow_compare_di from utilities.views import ContentTypePermissionRequiredMixin from . import filtersets, forms, tables from .choices import JobResultStatusChoices -from .models import ConfigContext, ImageAttachment, JournalEntry, ObjectChange, JobResult, Tag, TaggedItem +from .models import * from .reports import get_report, get_reports, run_report from .scripts import get_scripts, run_script +# +# Custom fields +# + +class CustomFieldListView(generic.ObjectListView): + queryset = CustomField.objects.all() + filterset = filtersets.CustomFieldFilterSet + filterset_form = forms.CustomFieldFilterForm + table = tables.CustomFieldTable + + +class CustomFieldView(generic.ObjectView): + queryset = CustomField.objects.all() + + +class CustomFieldEditView(generic.ObjectEditView): + queryset = CustomField.objects.all() + model_form = forms.CustomFieldForm + + +class CustomFieldDeleteView(generic.ObjectDeleteView): + queryset = CustomField.objects.all() + + +class CustomFieldBulkImportView(generic.BulkImportView): + queryset = CustomField.objects.all() + model_form = forms.CustomFieldCSVForm + table = tables.CustomFieldTable + + +class CustomFieldBulkEditView(generic.BulkEditView): + queryset = CustomField.objects.all() + filterset = filtersets.CustomFieldFilterSet + table = tables.CustomFieldTable + form = forms.CustomFieldBulkEditForm + + +class CustomFieldBulkDeleteView(generic.BulkDeleteView): + queryset = CustomField.objects.all() + filterset = filtersets.CustomFieldFilterSet + table = tables.CustomFieldTable + + # # Tags # diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html new file mode 100644 index 000000000..64999e2f5 --- /dev/null +++ b/netbox/templates/extras/customfield.html @@ -0,0 +1,120 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + + +{% endblock %} + +{% block content %} +
+
+
+
+ Custom Field +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Label{{ object.label|placeholder }}
Type{{ object.get_type_display }}
Description{{ object.description|placeholder }}
Required + {% if object.required %} + + {% else %} + + {% endif %} +
Weight{{ object.weight }}
+
+
+
+
+ Values +
+
+ + + + + + + + + + + + + +
Default Value{{ object.default }}
Choices{{ object.choices|placeholder }}
Filter Logic{{ object.get_filter_logic_display }}
+
+
+ {% plugin_left_page object %} +
+
+
+
+ Assigned Models +
+
+ + {% for ct in object.content_types.all %} + + + + {% endfor %} +
{{ ct }}
+
+
+
+
+ Validation Rules +
+
+ + + + + + + + + + + + + +
Minimum Value{{ object.validation_minimum|placeholder }}
Maximum Value{{ object.validation_maximum|placeholder }}
Regular Expression + {% if object.validation_regex %} + {{ object.validation_regex }} + {% else %} + — + {% endif %} +
+
+
+ {% plugin_right_page object %} +
+
+{% endblock %} diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 095d8aff2..d3e085741 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -6,8 +6,9 @@ from io import StringIO import django_filters from django import forms from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist -from django.db.models import Count +from django.db.models import Count, Q from django.forms import BoundField from django.forms.fields import JSONField as _JSONField, InvalidJSONInput from django.urls import reverse @@ -28,6 +29,7 @@ __all__ = ( 'CSVContentTypeField', 'CSVDataField', 'CSVModelChoiceField', + 'CSVMultipleContentTypeField', 'CSVTypedChoiceField', 'DynamicModelChoiceField', 'DynamicModelMultipleChoiceField', @@ -281,6 +283,20 @@ class CSVContentTypeField(CSVModelChoiceField): raise forms.ValidationError(f'Invalid object type') +class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField): + STATIC_CHOICES = True + + # TODO: Improve validation of selected ContentTypes + def prepare_value(self, value): + if type(value) is str: + ct_filter = Q() + for name in value.split(','): + app_label, model = name.split('.') + ct_filter |= Q(app_label=app_label, model=model) + return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True)) + return super().prepare_value(value) + + # # Expansion fields # diff --git a/netbox/utilities/templatetags/nav.py b/netbox/utilities/templatetags/nav.py index cf9ae573c..372ccf23f 100644 --- a/netbox/utilities/templatetags/nav.py +++ b/netbox/utilities/templatetags/nav.py @@ -289,6 +289,13 @@ OTHER_MENU = Menu( url="extras:journalentry_list", add_url=None, import_url=None), ), ), + MenuGroup( + label="Customization", + items=( + MenuItem(label="Custom Fields", url="extras:customfield_list", + add_url="extras:customfield_add", import_url="extras:customfield_import"), + ), + ), MenuGroup( label="Miscellaneous", items=( diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py index 4ebb7ae09..dd7ca4236 100644 --- a/netbox/utilities/testing/base.py +++ b/netbox/utilities/testing/base.py @@ -109,12 +109,12 @@ class ModelTestCase(TestCase): # Handle ManyToManyFields if value and type(field) in (ManyToManyField, TaggableManager): - if field.related_model is ContentType: + if field.related_model is ContentType and api: model_dict[key] = sorted([f'{ct.app_label}.{ct.model}' for ct in value]) else: model_dict[key] = sorted([obj.pk for obj in value]) - if api: + elif api: # Replace ContentType numeric IDs with . if type(getattr(instance, key)) is ContentType: