ソースを参照

Add UI views for custom fields

jeremystretch 4 年 前
コミット
b017927c69

+ 1 - 60
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('<br/>'.join(ct_names))
-
-
 #
 # Custom links
 #

+ 85 - 2
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.

+ 23 - 0
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),
+        ),
+    ]

+ 8 - 3
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)
 

+ 23 - 1
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(

+ 42 - 2
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
 

+ 12 - 1
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/<int:pk>/', views.CustomFieldView.as_view(), name='customfield'),
+    path('custom-fields/<int:pk>/edit/', views.CustomFieldEditView.as_view(), name='customfield_edit'),
+    path('custom-fields/<int:pk>/delete/', views.CustomFieldDeleteView.as_view(), name='customfield_delete'),
+    path('custom-fields/<int:pk>/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'),

+ 44 - 1
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
 #

+ 120 - 0
netbox/templates/extras/customfield.html

@@ -0,0 +1,120 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li class="breadcrumb-item"><a href="{% url 'extras:customfield_list' %}">Cusotm Fields</a></li>
+  <li class="breadcrumb-item">{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row mb-3">
+	<div class="col col-md-6">
+    <div class="card">
+      <h5 class="card-header">
+        Custom Field
+      </h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">Name</th>
+            <td>{{ object.name }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Label</th>
+            <td>{{ object.label|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Type</th>
+            <td>{{ object.get_type_display }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Description</th>
+            <td>{{ object.description|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Required</th>
+            <td>
+              {% if object.required %}
+                <i class="mdi mdi-check-bold text-success" title="Yes"></i>
+              {% else %}
+                <i class="mdi mdi-close-thick text-danger" title="No"></i>
+              {% endif %}
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">Weight</th>
+            <td>{{ object.weight }}</td>
+          </tr>
+        </table>
+      </div>
+    </div>
+    <div class="card">
+      <h5 class="card-header">
+        Values
+      </h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">Default Value</th>
+            <td>{{ object.default }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Choices</th>
+            <td>{{ object.choices|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Filter Logic</th>
+            <td>{{ object.get_filter_logic_display }}</td>
+          </tr>
+        </table>
+      </div>
+    </div>
+    {% plugin_left_page object %}
+	</div>
+	<div class="col col-md-6">
+    <div class="card">
+      <h5 class="card-header">
+        Assigned Models
+      </h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          {% for ct in object.content_types.all %}
+            <tr>
+              <td>{{ ct }}</td>
+            </tr>
+          {% endfor %}
+        </table>
+      </div>
+    </div>
+    <div class="card">
+      <h5 class="card-header">
+        Validation Rules
+      </h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">Minimum Value</th>
+            <td>{{ object.validation_minimum|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Maximum Value</th>
+            <td>{{ object.validation_maximum|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Regular Expression</th>
+            <td>
+              {% if object.validation_regex %}
+                <code>{{ object.validation_regex }}</code>
+              {% else %}
+                &mdash;
+              {% endif %}
+            </td>
+          </tr>
+        </table>
+      </div>
+    </div>
+    {% plugin_right_page object %}
+  </div>
+</div>
+{% endblock %}

+ 17 - 1
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
 #

+ 7 - 0
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=(

+ 2 - 2
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 <app_label>.<model>
                 if type(getattr(instance, key)) is ContentType: