From b017927c69aa0727318a9dafdb6d0f39597a76f9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 22 Jun 2021 16:28:06 -0400 Subject: [PATCH 1/6] 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: From 276ded0119d741c834db70ebacd70734e14c6f23 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 23 Jun 2021 17:09:15 -0400 Subject: [PATCH 2/6] Add UI views for custom links --- netbox/extras/admin.py | 46 ---------- netbox/extras/forms.py | 88 ++++++++++++++++++- .../migrations/0061_extras_change_logging.py | 12 ++- netbox/extras/models/models.py | 5 +- netbox/extras/tables.py | 18 ++++ netbox/extras/tests/test_views.py | 37 +++++++- netbox/extras/urls.py | 27 ++++-- netbox/extras/views.py | 43 +++++++++ netbox/templates/extras/customlink.html | 74 ++++++++++++++++ netbox/utilities/templatetags/nav.py | 2 + 10 files changed, 292 insertions(+), 60 deletions(-) create mode 100644 netbox/templates/extras/customlink.html diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 57cbfbc1c..1555206ed 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -57,52 +57,6 @@ class WebhookAdmin(admin.ModelAdmin): return ', '.join([ct.name for ct in obj.content_types.all()]) -# -# Custom links -# - -class CustomLinkForm(forms.ModelForm): - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links') - ) - - class Meta: - model = CustomLink - exclude = [] - widgets = { - 'link_text': forms.Textarea, - 'link_url': forms.Textarea, - } - help_texts = { - 'weight': 'A numeric weight to influence the ordering of this link among its peers. Lower weights appear ' - 'first in a list.', - 'link_text': 'Jinja2 template code for the link text. Reference the object as {{ obj }}. ' - 'Links which render as empty text will not be displayed.', - 'link_url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}.', - } - - -@admin.register(CustomLink) -class CustomLinkAdmin(admin.ModelAdmin): - fieldsets = ( - ('Custom Link', { - 'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window') - }), - ('Templates', { - 'fields': ('link_text', 'link_url'), - 'classes': ('monospace',) - }) - ) - list_display = [ - 'name', 'content_type', 'group_name', 'weight', - ] - list_filter = [ - 'content_type', - ] - form = CustomLinkForm - - # # Export templates # diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 4a0ce4416..afc32f8b6 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -8,13 +8,13 @@ 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, CSVMultipleContentTypeField, DateTimePicker, - DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2, StaticSelect2Multiple, - BOOLEAN_WITH_BLANK_CHOICES, + CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, CSVContentTypeField, CSVModelForm, + CSVMultipleContentTypeField, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2, + StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup from .choices import * -from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag +from .models import * from .utils import FeatureQuery @@ -100,6 +100,86 @@ class CustomFieldFilterForm(BootstrapMixin, forms.Form): ) +# +# Custom links +# + +class CustomLinkForm(BootstrapMixin, forms.ModelForm): + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_links') + ) + + class Meta: + model = CustomLink + fields = '__all__' + fieldsets = ( + ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')), + ('Templates', ('link_text', 'link_url')), + ) + + +class CustomLinkCSVForm(CSVModelForm): + content_type = CSVContentTypeField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_links'), + help_text="One or more assigned object types" + ) + + class Meta: + model = CustomLink + fields = ( + 'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url', + ) + + +class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=CustomLink.objects.all(), + widget=forms.MultipleHiddenInput + ) + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + required=False + ) + new_window = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + weight = forms.IntegerField( + required=False + ) + button_class = forms.ChoiceField( + choices=CustomLinkButtonClassChoices, + required=False, + widget=StaticSelect2() + ) + + class Meta: + nullable_fields = [] + + +class CustomLinkFilterForm(BootstrapMixin, forms.Form): + field_groups = [ + ['content_type'], + ['weight', 'new_window'], + ] + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields') + ) + weight = forms.IntegerField( + required=False + ) + new_window = forms.NullBooleanField( + required=False, + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + # # Custom field models # diff --git a/netbox/extras/migrations/0061_extras_change_logging.py b/netbox/extras/migrations/0061_extras_change_logging.py index 3081c7ddf..405855e54 100644 --- a/netbox/extras/migrations/0061_extras_change_logging.py +++ b/netbox/extras/migrations/0061_extras_change_logging.py @@ -1,5 +1,3 @@ -# Generated by Django 3.2.4 on 2021-06-23 17:37 - from django.db import migrations, models @@ -20,4 +18,14 @@ class Migration(migrations.Migration): name='last_updated', field=models.DateTimeField(auto_now=True, null=True), ), + migrations.AddField( + model_name='customlink', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='customlink', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index ab9cbe9f3..b8e1acc81 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -171,7 +171,7 @@ class Webhook(BigIDModel): # Custom links # -class CustomLink(BigIDModel): +class CustomLink(ChangeLoggedModel): """ A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template code to be rendered with an object as context. @@ -221,6 +221,9 @@ class CustomLink(BigIDModel): def __str__(self): return self.name + def get_absolute_url(self): + return reverse('extras:customlink', args=[self.pk]) + # # Export templates diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index f6bb2f000..94ee6db64 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -46,6 +46,24 @@ class CustomFieldTable(BaseTable): default_columns = ('pk', 'name', 'label', 'type', 'required', 'description') +# +# Custom links +# + +class CustomLinkTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + + class Meta(BaseTable.Meta): + model = CustomLink + fields = ( + 'pk', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window', + ) + default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window') + + # # Tags # diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 41de01ee2..75c1bbbaa 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse from dcim.models import Site -from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, ObjectChangeActionChoices +from extras.choices import * from extras.models import * from utilities.testing import ViewTestCases, TestCase @@ -51,6 +51,41 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = CustomLink + + @classmethod + def setUpTestData(cls): + + site_ct = ContentType.objects.get_for_model(Site) + CustomLink.objects.bulk_create(( + CustomLink(name='Custom Link 1', content_type=site_ct, link_text='Link 1', link_url='http://example.com/?1'), + CustomLink(name='Custom Link 2', content_type=site_ct, link_text='Link 2', link_url='http://example.com/?2'), + CustomLink(name='Custom Link 3', content_type=site_ct, link_text='Link 3', link_url='http://example.com/?3'), + )) + + cls.form_data = { + 'name': 'Custom Link X', + 'content_type': site_ct.pk, + 'weight': 100, + 'button_class': CustomLinkButtonClassChoices.CLASS_DEFAULT, + 'link_text': 'Link X', + 'link_url': 'http://example.com/?x' + } + + cls.csv_data = ( + "name,content_type,weight,button_class,link_text,link_url", + "Custom Link 4,dcim.site,100,primary,Link 4,http://exmaple.com/?4", + "Custom Link 5,dcim.site,100,primary,Link 5,http://exmaple.com/?5", + "Custom Link 6,dcim.site,100,primary,Link 6,http://exmaple.com/?6", + ) + + cls.bulk_edit_data = { + 'button_class': CustomLinkButtonClassChoices.CLASS_INFO, + 'weight': 200, + } + + class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Tag diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 0e87277fb..64e6814eb 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,7 +1,6 @@ from django.urls import path -from extras import views -from extras.models import ConfigContext, CustomField, JournalEntry, Tag +from extras import models, views app_name = 'extras' @@ -16,7 +15,20 @@ urlpatterns = [ 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}), + path('custom-fields//changelog/', views.ObjectChangeLogView.as_view(), name='customfield_changelog', + kwargs={'model': models.CustomField}), + + # Custom links + path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'), + path('custom-links/add/', views.CustomLinkEditView.as_view(), name='customlink_add'), + path('custom-links/import/', views.CustomLinkBulkImportView.as_view(), name='customlink_import'), + path('custom-links/edit/', views.CustomLinkBulkEditView.as_view(), name='customlink_bulk_edit'), + path('custom-links/delete/', views.CustomLinkBulkDeleteView.as_view(), name='customlink_bulk_delete'), + path('custom-links//', views.CustomLinkView.as_view(), name='customlink'), + path('custom-links//edit/', views.CustomLinkEditView.as_view(), name='customlink_edit'), + path('custom-links//delete/', views.CustomLinkDeleteView.as_view(), name='customlink_delete'), + path('custom-links//changelog/', views.ObjectChangeLogView.as_view(), name='customlink_changelog', + kwargs={'model': models.CustomLink}), # Tags path('tags/', views.TagListView.as_view(), name='tag_list'), @@ -27,7 +39,8 @@ urlpatterns = [ path('tags//', views.TagView.as_view(), name='tag'), path('tags//edit/', views.TagEditView.as_view(), name='tag_edit'), path('tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), - path('tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), + path('tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', + kwargs={'model': models.Tag}), # Config contexts path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), @@ -37,7 +50,8 @@ urlpatterns = [ path('config-contexts//', views.ConfigContextView.as_view(), name='configcontext'), path('config-contexts//edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'), path('config-contexts//delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), - path('config-contexts//changelog/', views.ObjectChangeLogView.as_view(), name='configcontext_changelog', kwargs={'model': ConfigContext}), + path('config-contexts//changelog/', views.ObjectChangeLogView.as_view(), name='configcontext_changelog', + kwargs={'model': models.ConfigContext}), # Image attachments path('image-attachments//edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), @@ -51,7 +65,8 @@ urlpatterns = [ path('journal-entries//', views.JournalEntryView.as_view(), name='journalentry'), path('journal-entries//edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'), path('journal-entries//delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'), - path('journal-entries//changelog/', views.ObjectChangeLogView.as_view(), name='journalentry_changelog', kwargs={'model': JournalEntry}), + path('journal-entries//changelog/', views.ObjectChangeLogView.as_view(), name='journalentry_changelog', + kwargs={'model': models.JournalEntry}), # Change logging path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 644908013..0259173cd 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -63,6 +63,49 @@ class CustomFieldBulkDeleteView(generic.BulkDeleteView): table = tables.CustomFieldTable +# +# Custom links +# + +class CustomLinkListView(generic.ObjectListView): + queryset = CustomLink.objects.all() + filterset = filtersets.CustomLinkFilterSet + filterset_form = forms.CustomLinkFilterForm + table = tables.CustomLinkTable + + +class CustomLinkView(generic.ObjectView): + queryset = CustomLink.objects.all() + + +class CustomLinkEditView(generic.ObjectEditView): + queryset = CustomLink.objects.all() + model_form = forms.CustomLinkForm + + +class CustomLinkDeleteView(generic.ObjectDeleteView): + queryset = CustomLink.objects.all() + + +class CustomLinkBulkImportView(generic.BulkImportView): + queryset = CustomLink.objects.all() + model_form = forms.CustomLinkCSVForm + table = tables.CustomLinkTable + + +class CustomLinkBulkEditView(generic.BulkEditView): + queryset = CustomLink.objects.all() + filterset = filtersets.CustomLinkFilterSet + table = tables.CustomLinkTable + form = forms.CustomLinkBulkEditForm + + +class CustomLinkBulkDeleteView(generic.BulkDeleteView): + queryset = CustomLink.objects.all() + filterset = filtersets.CustomLinkFilterSet + table = tables.CustomLinkTable + + # # Tags # diff --git a/netbox/templates/extras/customlink.html b/netbox/templates/extras/customlink.html new file mode 100644 index 000000000..488d76f86 --- /dev/null +++ b/netbox/templates/extras/customlink.html @@ -0,0 +1,74 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + + +{% endblock %} + +{% block content %} +
+
+
+
+ Custom Link +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Content Type{{ object.content_type }}
Name{{ object.name }}
Group Name{{ object.group_name|placeholder }}
Weight{{ object.weight }}
Button Class{{ object.get_button_class_display }}
New Window + {% if object.new_window %} + + {% else %} + + {% endif %} +
+
+
+ {% plugin_left_page object %} +
+
+
+
+ Link Text +
+
+
{{ object.link_text }}
+
+
+
+
+ Link URL +
+
+
{{ object.link_url }}
+
+
+ {% plugin_right_page object %} +
+
+{% endblock %} diff --git a/netbox/utilities/templatetags/nav.py b/netbox/utilities/templatetags/nav.py index 372ccf23f..3373320c6 100644 --- a/netbox/utilities/templatetags/nav.py +++ b/netbox/utilities/templatetags/nav.py @@ -294,6 +294,8 @@ OTHER_MENU = Menu( items=( MenuItem(label="Custom Fields", url="extras:customfield_list", add_url="extras:customfield_add", import_url="extras:customfield_import"), + MenuItem(label="Custom Links", url="extras:customlink_list", + add_url="extras:customlink_add", import_url="extras:customlink_import"), ), ), MenuGroup( From 10cbbee9472b39122da73cfe501d871b3234ddc5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 23 Jun 2021 20:39:35 -0400 Subject: [PATCH 3/6] Add UI views for export templates --- netbox/extras/admin.py | 39 +------- netbox/extras/forms.py | 90 ++++++++++++++++++- .../migrations/0061_extras_change_logging.py | 10 +++ netbox/extras/models/models.py | 7 +- netbox/extras/tables.py | 22 +++++ netbox/extras/tests/test_views.py | 34 +++++++ netbox/extras/urls.py | 12 +++ netbox/extras/views.py | 43 +++++++++ netbox/templates/extras/customlink.html | 2 +- netbox/templates/extras/exporttemplate.html | 66 ++++++++++++++ netbox/utilities/templatetags/buttons.py | 2 +- netbox/utilities/templatetags/nav.py | 2 + 12 files changed, 288 insertions(+), 41 deletions(-) create mode 100644 netbox/templates/extras/exporttemplate.html diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 1555206ed..3cf1794f2 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -2,8 +2,8 @@ from django import forms from django.contrib import admin from django.contrib.contenttypes.models import ContentType -from utilities.forms import ContentTypeChoiceField, ContentTypeMultipleChoiceField, LaxURLField -from .models import CustomLink, ExportTemplate, JobResult, Webhook +from utilities.forms import ContentTypeMultipleChoiceField, LaxURLField +from .models import JobResult, Webhook from .utils import FeatureQuery @@ -57,41 +57,6 @@ class WebhookAdmin(admin.ModelAdmin): return ', '.join([ct.name for ct in obj.content_types.all()]) -# -# Export templates -# - -class ExportTemplateForm(forms.ModelForm): - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links') - ) - - class Meta: - model = ExportTemplate - exclude = [] - - -@admin.register(ExportTemplate) -class ExportTemplateAdmin(admin.ModelAdmin): - fieldsets = ( - ('Export Template', { - 'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment') - }), - ('Content', { - 'fields': ('template_code',), - 'classes': ('monospace',) - }) - ) - list_display = [ - 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', - ] - list_filter = [ - 'content_type', - ] - form = ExportTemplateForm - - # # Reports # diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index afc32f8b6..7a8a37ff9 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -123,7 +123,7 @@ class CustomLinkCSVForm(CSVModelForm): content_type = CSVContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_links'), - help_text="One or more assigned object types" + help_text="Assigned object type" ) class Meta: @@ -180,6 +180,94 @@ class CustomLinkFilterForm(BootstrapMixin, forms.Form): ) +# +# Export templates +# + +class ExportTemplateForm(BootstrapMixin, forms.ModelForm): + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_links') + ) + + class Meta: + model = ExportTemplate + fields = '__all__' + fieldsets = ( + ('Custom Link', ('name', 'content_type', 'description')), + ('Template', ('template_code',)), + ('Rendering', ('mime_type', 'file_extension', 'as_attachment')), + ) + + +class ExportTemplateCSVForm(CSVModelForm): + content_type = CSVContentTypeField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('export_templates'), + help_text="Assigned object type" + ) + + class Meta: + model = ExportTemplate + fields = ( + 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', + ) + + +class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ExportTemplate.objects.all(), + widget=forms.MultipleHiddenInput + ) + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + mime_type = forms.CharField( + max_length=50, + required=False + ) + file_extension = forms.CharField( + max_length=15, + required=False + ) + as_attachment = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + + class Meta: + nullable_fields = ['description', 'mime_type', 'file_extension'] + + +class ExportTemplateFilterForm(BootstrapMixin, forms.Form): + field_groups = [ + ['content_type', 'mime_type'], + ['file_extension', 'as_attachment'], + ] + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields') + ) + mime_type = forms.CharField( + required=False + ) + file_extension = forms.CharField( + required=False + ) + as_attachment = forms.NullBooleanField( + required=False, + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + # # Custom field models # diff --git a/netbox/extras/migrations/0061_extras_change_logging.py b/netbox/extras/migrations/0061_extras_change_logging.py index 405855e54..cd8531f89 100644 --- a/netbox/extras/migrations/0061_extras_change_logging.py +++ b/netbox/extras/migrations/0061_extras_change_logging.py @@ -28,4 +28,14 @@ class Migration(migrations.Migration): name='last_updated', field=models.DateTimeField(auto_now=True, null=True), ), + migrations.AddField( + model_name='exporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='exporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index b8e1acc81..6d38ed9c3 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -171,6 +171,7 @@ class Webhook(BigIDModel): # Custom links # +@extras_features('webhooks') class CustomLink(ChangeLoggedModel): """ A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template @@ -229,7 +230,8 @@ class CustomLink(ChangeLoggedModel): # Export templates # -class ExportTemplate(BigIDModel): +@extras_features('webhooks') +class ExportTemplate(ChangeLoggedModel): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, @@ -272,6 +274,9 @@ class ExportTemplate(BigIDModel): def __str__(self): return f"{self.content_type}: {self.name}" + def get_absolute_url(self): + return reverse('extras:exporttemplate', args=[self.pk]) + def clean(self): super().clean() diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 94ee6db64..66e623360 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -55,6 +55,7 @@ class CustomLinkTable(BaseTable): name = tables.Column( linkify=True ) + new_window = BooleanColumn() class Meta(BaseTable.Meta): model = CustomLink @@ -64,6 +65,27 @@ class CustomLinkTable(BaseTable): default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window') +# +# Export templates +# + +class ExportTemplateTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + as_attachment = BooleanColumn() + + class Meta(BaseTable.Meta): + model = ExportTemplate + fields = ( + 'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', + ) + default_columns = ( + 'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', + ) + + # # Tags # diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 75c1bbbaa..7a00f5f73 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -86,6 +86,40 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = ExportTemplate + + @classmethod + def setUpTestData(cls): + + site_ct = ContentType.objects.get_for_model(Site) + TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}""" + ExportTemplate.objects.bulk_create(( + ExportTemplate(name='Export Template 1', content_type=site_ct, template_code=TEMPLATE_CODE), + ExportTemplate(name='Export Template 2', content_type=site_ct, template_code=TEMPLATE_CODE), + ExportTemplate(name='Export Template 3', content_type=site_ct, template_code=TEMPLATE_CODE), + )) + + cls.form_data = { + 'name': 'Export Template X', + 'content_type': site_ct.pk, + 'template_code': TEMPLATE_CODE, + } + + cls.csv_data = ( + "name,content_type,template_code", + f"Export Template 4,dcim.site,{TEMPLATE_CODE}", + f"Export Template 5,dcim.site,{TEMPLATE_CODE}", + f"Export Template 6,dcim.site,{TEMPLATE_CODE}", + ) + + cls.bulk_edit_data = { + 'mime_type': 'text/html', + 'file_extension': 'html', + 'as_attachment': True, + } + + class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Tag diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 64e6814eb..6404f2677 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -30,6 +30,18 @@ urlpatterns = [ path('custom-links//changelog/', views.ObjectChangeLogView.as_view(), name='customlink_changelog', kwargs={'model': models.CustomLink}), + # Export templates + path('export-templates/', views.ExportTemplateListView.as_view(), name='exporttemplate_list'), + path('export-templates/add/', views.ExportTemplateEditView.as_view(), name='exporttemplate_add'), + path('export-templates/import/', views.ExportTemplateBulkImportView.as_view(), name='exporttemplate_import'), + path('export-templates/edit/', views.ExportTemplateBulkEditView.as_view(), name='exporttemplate_bulk_edit'), + path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'), + path('export-templates//', views.ExportTemplateView.as_view(), name='exporttemplate'), + path('export-templates//edit/', views.ExportTemplateEditView.as_view(), name='exporttemplate_edit'), + path('export-templates//delete/', views.ExportTemplateDeleteView.as_view(), name='exporttemplate_delete'), + path('export-templates//changelog/', views.ObjectChangeLogView.as_view(), name='exporttemplate_changelog', + kwargs={'model': models.ExportTemplate}), + # 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 0259173cd..28089bd39 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -106,6 +106,49 @@ class CustomLinkBulkDeleteView(generic.BulkDeleteView): table = tables.CustomLinkTable +# +# Export templates +# + +class ExportTemplateListView(generic.ObjectListView): + queryset = ExportTemplate.objects.all() + filterset = filtersets.ExportTemplateFilterSet + filterset_form = forms.ExportTemplateFilterForm + table = tables.ExportTemplateTable + + +class ExportTemplateView(generic.ObjectView): + queryset = ExportTemplate.objects.all() + + +class ExportTemplateEditView(generic.ObjectEditView): + queryset = ExportTemplate.objects.all() + model_form = forms.ExportTemplateForm + + +class ExportTemplateDeleteView(generic.ObjectDeleteView): + queryset = ExportTemplate.objects.all() + + +class ExportTemplateBulkImportView(generic.BulkImportView): + queryset = ExportTemplate.objects.all() + model_form = forms.ExportTemplateCSVForm + table = tables.ExportTemplateTable + + +class ExportTemplateBulkEditView(generic.BulkEditView): + queryset = ExportTemplate.objects.all() + filterset = filtersets.ExportTemplateFilterSet + table = tables.ExportTemplateTable + form = forms.ExportTemplateBulkEditForm + + +class ExportTemplateBulkDeleteView(generic.BulkDeleteView): + queryset = ExportTemplate.objects.all() + filterset = filtersets.ExportTemplateFilterSet + table = tables.ExportTemplateTable + + # # Tags # diff --git a/netbox/templates/extras/customlink.html b/netbox/templates/extras/customlink.html index 488d76f86..f337fd6e4 100644 --- a/netbox/templates/extras/customlink.html +++ b/netbox/templates/extras/customlink.html @@ -3,7 +3,7 @@ {% load plugins %} {% block breadcrumbs %} - + {% endblock %} diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html new file mode 100644 index 000000000..1da716d71 --- /dev/null +++ b/netbox/templates/extras/exporttemplate.html @@ -0,0 +1,66 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + + +{% endblock %} + +{% block content %} +
+
+
+
+ Export Template +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Content Type{{ object.content_type }}
Name{{ object.name }}
Description{{ object.description|placeholder }}
MIME Type{{ object.mime_type|placeholder }}
File Extension{{ object.file_extension|placeholder }}
Attachment + {% if object.as_attachment %} + + {% else %} + + {% endif %} +
+
+
+ {% plugin_left_page object %} +
+
+
+
+ Template +
+
+
{{ object.template_code }}
+
+
+ {% plugin_right_page object %} +
+
+{% endblock %} diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index e7ebd26a0..3608f5a7c 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -88,7 +88,7 @@ def export_button(context, content_type=None): user = context['request'].user export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_type=content_type) if user.is_staff and user.has_perm('extras.add_exporttemplate'): - add_exporttemplate_link = f"{reverse('admin:extras_exporttemplate_add')}?content_type={content_type.pk}" + add_exporttemplate_link = f"{reverse('extras:exporttemplate_add')}?content_type={content_type.pk}" else: export_templates = [] diff --git a/netbox/utilities/templatetags/nav.py b/netbox/utilities/templatetags/nav.py index 3373320c6..884a5971b 100644 --- a/netbox/utilities/templatetags/nav.py +++ b/netbox/utilities/templatetags/nav.py @@ -296,6 +296,8 @@ OTHER_MENU = Menu( add_url="extras:customfield_add", import_url="extras:customfield_import"), MenuItem(label="Custom Links", url="extras:customlink_list", add_url="extras:customlink_add", import_url="extras:customlink_import"), + MenuItem(label="Export Templates", url="extras:exporttemplate_list", + add_url="extras:exporttemplate_add", import_url="extras:exporttemplate_import"), ), ), MenuGroup( From 4e0b795a3ce37db38f2adba0c8b9063d41e02ef5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 23 Jun 2021 21:24:23 -0400 Subject: [PATCH 4/6] Add UI views for webhooks --- netbox/extras/admin.py | 56 +----- netbox/extras/forms.py | 123 +++++++++++++ .../migrations/0061_extras_change_logging.py | 10 ++ netbox/extras/models/models.py | 6 +- netbox/extras/tables.py | 21 +++ netbox/extras/tests/test_views.py | 43 +++++ netbox/extras/urls.py | 12 ++ netbox/extras/views.py | 43 +++++ netbox/templates/extras/customfield.html | 2 +- netbox/templates/extras/webhook.html | 165 ++++++++++++++++++ netbox/utilities/templatetags/nav.py | 2 + 11 files changed, 426 insertions(+), 57 deletions(-) create mode 100644 netbox/templates/extras/webhook.html diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 3cf1794f2..dae21c2c9 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,60 +1,6 @@ -from django import forms from django.contrib import admin -from django.contrib.contenttypes.models import ContentType -from utilities.forms import ContentTypeMultipleChoiceField, LaxURLField -from .models import JobResult, Webhook -from .utils import FeatureQuery - - -# -# Webhooks -# - -class WebhookForm(forms.ModelForm): - content_types = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('webhooks') - ) - payload_url = LaxURLField( - label='URL' - ) - - class Meta: - model = Webhook - exclude = () - - -@admin.register(Webhook) -class WebhookAdmin(admin.ModelAdmin): - list_display = [ - 'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update', 'type_delete', - 'ssl_verification', - ] - list_filter = [ - 'enabled', 'type_create', 'type_update', 'type_delete', 'content_types', - ] - form = WebhookForm - fieldsets = ( - (None, { - 'fields': ('name', 'content_types', 'enabled') - }), - ('Events', { - 'fields': ('type_create', 'type_update', 'type_delete') - }), - ('HTTP Request', { - 'fields': ( - 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', - ), - 'classes': ('monospace',) - }), - ('SSL', { - 'fields': ('ssl_verification', 'ca_file_path') - }) - ) - - def models(self, obj): - return ', '.join([ct.name for ct in obj.content_types.all()]) +from .models import JobResult # diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 7a8a37ff9..02b41e7e1 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -268,6 +268,129 @@ class ExportTemplateFilterForm(BootstrapMixin, forms.Form): ) +# +# Webhooks +# + +class WebhookForm(BootstrapMixin, forms.ModelForm): + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('webhooks') + ) + + class Meta: + model = Webhook + fields = '__all__' + fieldsets = ( + ('Webhook', ('name', 'enabled')), + ('Assigned Models', ('content_types',)), + ('Events', ('type_create', 'type_update', 'type_delete')), + ('HTTP Request', ( + 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', + )), + ('SSL', ('ssl_verification', 'ca_file_path')), + ) + + +class WebhookCSVForm(CSVModelForm): + content_types = CSVMultipleContentTypeField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('webhooks'), + help_text="One or more assigned object types" + ) + + class Meta: + model = Webhook + fields = ( + 'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'payload_url', + 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification', + 'ca_file_path' + ) + + +class WebhookBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Webhook.objects.all(), + widget=forms.MultipleHiddenInput + ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + type_create = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + type_update = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + type_delete = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + http_method = forms.ChoiceField( + choices=WebhookHttpMethodChoices, + required=False + ) + payload_url = forms.CharField( + required=False + ) + ssl_verification = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + secret = forms.CharField( + required=False + ) + ca_file_path = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ['secret', 'ca_file_path'] + + +class WebhookFilterForm(BootstrapMixin, forms.Form): + field_groups = [ + ['content_types', 'http_method'], + ['enabled', 'type_create', 'type_update', 'type_delete'], + ] + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields') + ) + http_method = forms.MultipleChoiceField( + choices=WebhookHttpMethodChoices, + required=False, + widget=StaticSelect2Multiple() + ) + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + type_create = forms.NullBooleanField( + required=False, + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + type_update = forms.NullBooleanField( + required=False, + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + type_delete = forms.NullBooleanField( + required=False, + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + # # Custom field models # diff --git a/netbox/extras/migrations/0061_extras_change_logging.py b/netbox/extras/migrations/0061_extras_change_logging.py index cd8531f89..4ee532fd5 100644 --- a/netbox/extras/migrations/0061_extras_change_logging.py +++ b/netbox/extras/migrations/0061_extras_change_logging.py @@ -38,4 +38,14 @@ class Migration(migrations.Migration): name='last_updated', field=models.DateTimeField(auto_now=True, null=True), ), + migrations.AddField( + model_name='webhook', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='webhook', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 6d38ed9c3..58d2d857e 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -36,7 +36,8 @@ __all__ = ( # Webhooks # -class Webhook(BigIDModel): +@extras_features('webhooks') +class Webhook(ChangeLoggedModel): """ A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or delete in NetBox. The request will contain a representation of the object, which the remote application can act on. @@ -129,6 +130,9 @@ class Webhook(BigIDModel): def __str__(self): return self.name + def get_absolute_url(self): + return reverse('extras:webhook', args=[self.pk]) + def clean(self): super().clean() diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 66e623360..77304edc6 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -86,6 +86,27 @@ class ExportTemplateTable(BaseTable): ) +# +# Webhooks +# + +class WebhookTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + + class Meta(BaseTable.Meta): + model = Webhook + fields = ( + 'pk', 'name', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', 'payload_url', + 'secret', 'ssl_validation', 'ca_file_path', + ) + default_columns = ( + 'pk', 'name', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', 'payload_url', + ) + + # # Tags # diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 7a00f5f73..704f6f8bb 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -120,6 +120,49 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Webhook + + @classmethod + def setUpTestData(cls): + + site_ct = ContentType.objects.get_for_model(Site) + webhooks = ( + Webhook(name='Webhook 1', payload_url='http://example.com/?1', type_create=True, http_method='POST'), + Webhook(name='Webhook 2', payload_url='http://example.com/?2', type_create=True, http_method='POST'), + Webhook(name='Webhook 3', payload_url='http://example.com/?3', type_create=True, http_method='POST'), + ) + for webhook in webhooks: + webhook.save() + webhook.content_types.add(site_ct) + + cls.form_data = { + 'name': 'Webhook X', + 'content_types': [site_ct.pk], + 'type_create': False, + 'type_update': True, + 'type_delete': True, + 'payload_url': 'http://example.com/?x', + 'http_method': 'GET', + 'http_content_type': 'application/foo', + } + + cls.csv_data = ( + "name,content_types,type_create,payload_url,http_method,http_content_type", + "Webhook 4,dcim.site,True,http://example.com/?4,GET,application/json", + "Webhook 5,dcim.site,True,http://example.com/?5,GET,application/json", + "Webhook 6,dcim.site,True,http://example.com/?6,GET,application/json", + ) + + cls.bulk_edit_data = { + 'enabled': False, + 'type_create': False, + 'type_update': True, + 'type_delete': True, + 'http_method': 'GET', + } + + class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Tag diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 6404f2677..4dafb25f2 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -42,6 +42,18 @@ urlpatterns = [ path('export-templates//changelog/', views.ObjectChangeLogView.as_view(), name='exporttemplate_changelog', kwargs={'model': models.ExportTemplate}), + # Webhooks + path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'), + path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'), + path('webhooks/import/', views.WebhookBulkImportView.as_view(), name='webhook_import'), + path('webhooks/edit/', views.WebhookBulkEditView.as_view(), name='webhook_bulk_edit'), + path('webhooks/delete/', views.WebhookBulkDeleteView.as_view(), name='webhook_bulk_delete'), + path('webhooks//', views.WebhookView.as_view(), name='webhook'), + path('webhooks//edit/', views.WebhookEditView.as_view(), name='webhook_edit'), + path('webhooks//delete/', views.WebhookDeleteView.as_view(), name='webhook_delete'), + path('webhooks//changelog/', views.ObjectChangeLogView.as_view(), name='webhook_changelog', + kwargs={'model': models.Webhook}), + # 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 28089bd39..10bf2f6c8 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -149,6 +149,49 @@ class ExportTemplateBulkDeleteView(generic.BulkDeleteView): table = tables.ExportTemplateTable +# +# Webhooks +# + +class WebhookListView(generic.ObjectListView): + queryset = Webhook.objects.all() + filterset = filtersets.WebhookFilterSet + filterset_form = forms.WebhookFilterForm + table = tables.WebhookTable + + +class WebhookView(generic.ObjectView): + queryset = Webhook.objects.all() + + +class WebhookEditView(generic.ObjectEditView): + queryset = Webhook.objects.all() + model_form = forms.WebhookForm + + +class WebhookDeleteView(generic.ObjectDeleteView): + queryset = Webhook.objects.all() + + +class WebhookBulkImportView(generic.BulkImportView): + queryset = Webhook.objects.all() + model_form = forms.WebhookCSVForm + table = tables.WebhookTable + + +class WebhookBulkEditView(generic.BulkEditView): + queryset = Webhook.objects.all() + filterset = filtersets.WebhookFilterSet + table = tables.WebhookTable + form = forms.WebhookBulkEditForm + + +class WebhookBulkDeleteView(generic.BulkDeleteView): + queryset = Webhook.objects.all() + filterset = filtersets.WebhookFilterSet + table = tables.WebhookTable + + # # Tags # diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index 64999e2f5..f0694f33a 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -3,7 +3,7 @@ {% load plugins %} {% block breadcrumbs %} - + {% endblock %} diff --git a/netbox/templates/extras/webhook.html b/netbox/templates/extras/webhook.html new file mode 100644 index 000000000..9546952e5 --- /dev/null +++ b/netbox/templates/extras/webhook.html @@ -0,0 +1,165 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + + +{% endblock %} + +{% block content %} +
+
+
+
+ Webhook +
+
+ + + + + + + + + +
Name{{ object.name }}
Enabled + {% if object.enabled %} + + {% else %} + + {% endif %} +
+
+
+
+
+ Events +
+
+ + + + + + + + + + + + + +
Create + {% if object.type_create %} + + {% else %} + + {% endif %} +
Update + {% if object.type_create %} + + {% else %} + + {% endif %} +
Delete + {% if object.type_create %} + + {% else %} + + {% endif %} +
+
+
+
+
+ HTTP Request +
+
+ + + + + + + + + + + + + + + + + +
HTTP Method{{ object.get_http_method_display }}
Payload URL{{ object.payload_url }}
HTTP Content Type{{ object.http_content_type }}
Secret{{ object.secret|placeholder }}
+
+
+
+
+ SSL +
+
+ + + + + + + + + +
SSL Verification + {% if object.ssl_verification %} + + {% else %} + + {% endif %} +
CA File Path + {% if object.ca_file_path %} + {{ object.ca_file_path }} + {% else %} + &mdash + {% endif %} +
+
+
+ {% plugin_left_page object %} +
+
+
+
+ Assigned Models +
+
+ + {% for ct in object.content_types.all %} + + + + {% endfor %} +
{{ ct }}
+
+
+
+
+ Additional Headers +
+
+
{{ object.additional_headers }}
+
+
+
+
+ Body Template +
+
+
{{ object.body_template }}
+
+
+ {% plugin_right_page object %} +
+
+{% endblock %} diff --git a/netbox/utilities/templatetags/nav.py b/netbox/utilities/templatetags/nav.py index 884a5971b..01c611a1b 100644 --- a/netbox/utilities/templatetags/nav.py +++ b/netbox/utilities/templatetags/nav.py @@ -287,6 +287,8 @@ OTHER_MENU = Menu( add_url=None, import_url=None), MenuItem(label="Journal Entries", url="extras:journalentry_list", add_url=None, import_url=None), + MenuItem(label="Webhooks", url="extras:webhook_list", + add_url="extras:webhook_add", import_url="extras:webhook_import"), ), ), MenuGroup( From 036a068b832f1ba1db3cab09f5ce07d49b62781f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 24 Jun 2021 08:37:06 -0400 Subject: [PATCH 5/6] Update extras documentation --- docs/additional-features/webhooks.md | 2 +- docs/configuration/optional-settings.md | 6 +++--- .../custom-fields.md | 2 +- .../custom-links.md | 6 +++--- .../custom-scripts.md | 0 .../custom-validation.md | 0 .../export-templates.md | 2 +- .../reports.md | 0 docs/development/models.md | 4 ++-- docs/release-notes/version-2.6.md | 2 +- docs/release-notes/version-3.0.md | 2 +- mkdocs.yml | 13 +++++++------ 12 files changed, 20 insertions(+), 19 deletions(-) rename docs/{additional-features => customization}/custom-fields.md (97%) rename docs/{additional-features => customization}/custom-links.md (75%) rename docs/{additional-features => customization}/custom-scripts.md (100%) rename docs/{additional-features => customization}/custom-validation.md (100%) rename docs/{additional-features => customization}/export-templates.md (98%) rename docs/{additional-features => customization}/reports.md (100%) diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md index f3dd80337..e3b352125 100644 --- a/docs/additional-features/webhooks.md +++ b/docs/additional-features/webhooks.md @@ -1,6 +1,6 @@ # Webhooks -A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are configured in the admin UI under Extras > Webhooks. +A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are managed under Logging > Webhooks. ## Configuration diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 2a3d267ba..9653968fe 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -98,7 +98,7 @@ CORS_ORIGIN_WHITELIST = [ ## CUSTOM_VALIDATORS -This is a mapping of models to [custom validators](../additional-features/custom-validation.md) that have been defined locally to enforce custom validation logic. +This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. --- @@ -501,7 +501,7 @@ This parameter defines the URL of the repository that will be checked periodical Default: `$INSTALL_ROOT/netbox/reports/` -The file path to the location where custom reports will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path. +The file path to the location where [custom reports](../customization/reports.md) will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path. --- @@ -517,7 +517,7 @@ The maximum execution time of a background task (such as running a custom script Default: `$INSTALL_ROOT/netbox/scripts/` -The file path to the location where custom scripts will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path. +The file path to the location where [custom scripts](../customization/custom-scripts.md) will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path. --- diff --git a/docs/additional-features/custom-fields.md b/docs/customization/custom-fields.md similarity index 97% rename from docs/additional-features/custom-fields.md rename to docs/customization/custom-fields.md index 649f69256..a9acfb3f7 100644 --- a/docs/additional-features/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -8,7 +8,7 @@ Within the database, custom fields are stored as JSON data directly alongside ea ## Creating Custom Fields -Custom fields must be created through the admin UI under Extras > Custom Fields. NetBox supports six types of custom field: +Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field: * Text: Free-form text (up to 255 characters) * Integer: A whole number (positive or negative) diff --git a/docs/additional-features/custom-links.md b/docs/customization/custom-links.md similarity index 75% rename from docs/additional-features/custom-links.md rename to docs/customization/custom-links.md index 0a00b6d68..dc8f28b71 100644 --- a/docs/additional-features/custom-links.md +++ b/docs/customization/custom-links.md @@ -1,8 +1,8 @@ # Custom Links -Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside of NetBox. For example, you might create a custom link on the device view which links to the current device in a network monitoring system. +Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a network monitoring system. -Custom links are created under the admin UI. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object. +Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object. For example, you might define a link like this: @@ -15,7 +15,7 @@ When viewing a device named Router4, this link would render as: View NMS ``` -Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links. +Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links. ## Context Data diff --git a/docs/additional-features/custom-scripts.md b/docs/customization/custom-scripts.md similarity index 100% rename from docs/additional-features/custom-scripts.md rename to docs/customization/custom-scripts.md diff --git a/docs/additional-features/custom-validation.md b/docs/customization/custom-validation.md similarity index 100% rename from docs/additional-features/custom-validation.md rename to docs/customization/custom-validation.md diff --git a/docs/additional-features/export-templates.md b/docs/customization/export-templates.md similarity index 98% rename from docs/additional-features/export-templates.md rename to docs/customization/export-templates.md index c80d5b8a1..0d0f7169e 100644 --- a/docs/additional-features/export-templates.md +++ b/docs/customization/export-templates.md @@ -1,6 +1,6 @@ # Export Templates -NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface. +NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Customization > Export Templates. Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension. diff --git a/docs/additional-features/reports.md b/docs/customization/reports.md similarity index 100% rename from docs/additional-features/reports.md rename to docs/customization/reports.md diff --git a/docs/development/models.md b/docs/development/models.md index 29d17c29d..93a10fff6 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -10,8 +10,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/ * [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log * [Webhooks](../additional-features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects -* [Custom fields](../additional-features/custom-fields.md) - These models support the addition of user-defined fields -* [Export templates](../additional-features/export-templates.md) - Users can create custom export templates for these models +* [Custom fields](../customization/custom-fields.md) - These models support the addition of user-defined fields +* [Export templates](../customization/export-templates.md) - Users can create custom export templates for these models * [Tagging](../models/extras/tag.md) - The models can be tagged with user-defined tags * [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary * Nesting - These models can be nested recursively to create a hierarchy diff --git a/docs/release-notes/version-2.6.md b/docs/release-notes/version-2.6.md index e8228920d..7e9e8fea3 100644 --- a/docs/release-notes/version-2.6.md +++ b/docs/release-notes/version-2.6.md @@ -218,7 +218,7 @@ #### Custom Scripts ([#3415](https://github.com/netbox-community/netbox/issues/3415)) -Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://netbox.readthedocs.io/en/stable/additional-features/custom-scripts/) for more detail. +Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://netbox.readthedocs.io/en/stable/customization/custom-scripts/) for more detail. Note: There are currently no API endpoints for this feature. These are planned for the upcoming v2.7 release. diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 7674d57a6..f408bae6b 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -46,7 +46,7 @@ CUSTOM_VALIDATORS = { } ``` -CustomValidator can also be subclassed to enforce more complex logic by overriding its `validate()` method. See the [custom validation](../additional-features/custom-validation.md) documentation for more details. +CustomValidator can also be subclassed to enforce more complex logic by overriding its `validate()` method. See the [custom validation](../customization/custom-validation.md) documentation for more details. ### Enhancements diff --git a/mkdocs.yml b/mkdocs.yml index 875d04d3e..89b72d433 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,19 +59,20 @@ nav: - Circuits: 'core-functionality/circuits.md' - Power Tracking: 'core-functionality/power.md' - Tenancy: 'core-functionality/tenancy.md' + - Customization: + - Custom Fields: 'customization/custom-fields.md' + - Custom Validation: 'customization/custom-validation.md' + - Custom Links: 'customization/custom-links.md' + - Export Templates: 'customization/export-templates.md' + - Custom Scripts: 'customization/custom-scripts.md' + - Reports: 'customization/reports.md' - Additional Features: - Caching: 'additional-features/caching.md' - Change Logging: 'additional-features/change-logging.md' - Context Data: 'models/extras/configcontext.md' - - Custom Fields: 'additional-features/custom-fields.md' - - Custom Validation: 'additional-features/custom-validation.md' - - Custom Links: 'additional-features/custom-links.md' - - Custom Scripts: 'additional-features/custom-scripts.md' - - Export Templates: 'additional-features/export-templates.md' - Journaling: 'additional-features/journaling.md' - NAPALM: 'additional-features/napalm.md' - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - - Reports: 'additional-features/reports.md' - Tags: 'models/extras/tag.md' - Webhooks: 'additional-features/webhooks.md' - Plugins: From 6cc4f27ee306b2779467bb360c538d409e960383 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 24 Jun 2021 08:51:29 -0400 Subject: [PATCH 6/6] Tables cleanup --- netbox/extras/tables.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 77304edc6..a3c103773 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -37,6 +37,7 @@ class CustomFieldTable(BaseTable): name = tables.Column( linkify=True ) + required = BooleanColumn() class Meta(BaseTable.Meta): model = CustomField @@ -55,6 +56,7 @@ class CustomLinkTable(BaseTable): name = tables.Column( linkify=True ) + content_type = ContentTypeColumn() new_window = BooleanColumn() class Meta(BaseTable.Meta): @@ -74,6 +76,7 @@ class ExportTemplateTable(BaseTable): name = tables.Column( linkify=True ) + content_type = ContentTypeColumn() as_attachment = BooleanColumn() class Meta(BaseTable.Meta): @@ -95,6 +98,10 @@ class WebhookTable(BaseTable): name = tables.Column( linkify=True ) + enabled = BooleanColumn() + type_create = BooleanColumn() + type_update = BooleanColumn() + type_delete = BooleanColumn() class Meta(BaseTable.Meta): model = Webhook