jeremystretch 4 лет назад
Родитель
Сommit
276ded0119

+ 0 - 46
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 <code>{{ obj }}</code>. '
-                         'Links which render as empty text will not be displayed.',
-            'link_url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
-        }
-
-
-@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
 #

+ 84 - 4
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
 #

+ 10 - 2
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),
+        ),
     ]

+ 4 - 1
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

+ 18 - 0
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
 #

+ 36 - 1
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
 

+ 21 - 6
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/<int:pk>/', views.CustomFieldView.as_view(), name='customfield'),
     path('custom-fields/<int:pk>/edit/', views.CustomFieldEditView.as_view(), name='customfield_edit'),
     path('custom-fields/<int:pk>/delete/', views.CustomFieldDeleteView.as_view(), name='customfield_delete'),
-    path('custom-fields/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='customfield_changelog', kwargs={'model': CustomField}),
+    path('custom-fields/<int:pk>/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/<int:pk>/', views.CustomLinkView.as_view(), name='customlink'),
+    path('custom-links/<int:pk>/edit/', views.CustomLinkEditView.as_view(), name='customlink_edit'),
+    path('custom-links/<int:pk>/delete/', views.CustomLinkDeleteView.as_view(), name='customlink_delete'),
+    path('custom-links/<int:pk>/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/<int:pk>/', views.TagView.as_view(), name='tag'),
     path('tags/<int:pk>/edit/', views.TagEditView.as_view(), name='tag_edit'),
     path('tags/<int:pk>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
-    path('tags/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
+    path('tags/<int:pk>/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/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
     path('config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
     path('config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
-    path('config-contexts/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='configcontext_changelog', kwargs={'model': ConfigContext}),
+    path('config-contexts/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='configcontext_changelog',
+         kwargs={'model': models.ConfigContext}),
 
     # Image attachments
     path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
@@ -51,7 +65,8 @@ urlpatterns = [
     path('journal-entries/<int:pk>/', views.JournalEntryView.as_view(), name='journalentry'),
     path('journal-entries/<int:pk>/edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'),
     path('journal-entries/<int:pk>/delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'),
-    path('journal-entries/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='journalentry_changelog', kwargs={'model': JournalEntry}),
+    path('journal-entries/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='journalentry_changelog',
+         kwargs={'model': models.JournalEntry}),
 
     # Change logging
     path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),

+ 43 - 0
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
 #

+ 74 - 0
netbox/templates/extras/customlink.html

@@ -0,0 +1,74 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li class="breadcrumb-item"><a href="{% url 'extras:customlink_list' %}">Cusotm Links</a></li>
+  <li class="breadcrumb-item">{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row mb-3">
+	<div class="col col-md-6">
+    <div class="card">
+      <h5 class="card-header">
+        Custom Link
+      </h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">Content Type</th>
+            <td>{{ object.content_type }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Name</th>
+            <td>{{ object.name }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Group Name</th>
+            <td>{{ object.group_name|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Weight</th>
+            <td>{{ object.weight }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Button Class</th>
+            <td>{{ object.get_button_class_display }}</td>
+          </tr>
+          <tr>
+            <th scope="row">New Window</th>
+            <td>
+              {% if object.new_window %}
+                <i class="mdi mdi-check-bold text-success" title="Yes"></i>
+              {% else %}
+                <i class="mdi mdi-close-thick text-danger" title="No"></i>
+              {% endif %}
+            </td>
+          </tr>
+        </table>
+      </div>
+    </div>
+    {% plugin_left_page object %}
+	</div>
+	<div class="col col-md-6">
+    <div class="card">
+      <h5 class="card-header">
+        Link Text
+      </h5>
+      <div class="card-body">
+        <pre>{{ object.link_text }}</pre>
+      </div>
+    </div>
+    <div class="card">
+      <h5 class="card-header">
+        Link URL
+      </h5>
+      <div class="card-body">
+        <pre>{{ object.link_url }}</pre>
+      </div>
+    </div>
+    {% plugin_right_page object %}
+  </div>
+</div>
+{% endblock %}

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