From 276ded0119d741c834db70ebacd70734e14c6f23 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 23 Jun 2021 17:09:15 -0400 Subject: [PATCH] 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(