Просмотр исходного кода

Closes #5608: Add REST API endpoint for custom links

Jeremy Stretch 5 лет назад
Родитель
Сommit
38ded66c4e

+ 3 - 0
docs/release-notes/version-2.11.md

@@ -43,6 +43,7 @@ The ObjectChange model (which is used to record the creation, modification, and
 * [#5375](https://github.com/netbox-community/netbox/issues/5375) - Add `speed` attribute to console port models
 * [#5401](https://github.com/netbox-community/netbox/issues/5401) - Extend custom field support to device component models
 * [#5451](https://github.com/netbox-community/netbox/issues/5451) - Add support for multiple-selection custom fields
+* [#5608](https://github.com/netbox-community/netbox/issues/5608) - Add REST API endpoint for custom links
 * [#5894](https://github.com/netbox-community/netbox/issues/5894) - Use primary keys when filtering object lists by related objects in the UI
 * [#5895](https://github.com/netbox-community/netbox/issues/5895) - Rename RackGroup to Location
 * [#5901](https://github.com/netbox-community/netbox/issues/5901) - Add `created` and `last_updated` fields to device component models
@@ -83,6 +84,8 @@ The ObjectChange model (which is used to record the creation, modification, and
   * Added the `site_groups` many-to-many field to track the assignment of ConfigContexts to SiteGroups
 * extras.CustomField
   * Added new custom field type: `multi-select`
+* extras.CustomLink
+  * Added the `/api/extras/custom-links/` endpoint
 * extras.ObjectChange
   * Added the `prechange_data` field
   * Renamed `object_data` to `postchange_data`

+ 6 - 6
netbox/extras/admin.py

@@ -132,15 +132,15 @@ class CustomLinkForm(forms.ModelForm):
         model = CustomLink
         exclude = []
         widgets = {
-            'text': forms.Textarea,
-            'url': forms.Textarea,
+            '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.',
-            '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.',
-            'url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
+            '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>.',
         }
 
     def __init__(self, *args, **kwargs):
@@ -158,7 +158,7 @@ class CustomLinkAdmin(admin.ModelAdmin):
             'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window')
         }),
         ('Templates', {
-            'fields': ('text', 'url'),
+            'fields': ('link_text', 'link_url'),
             'classes': ('monospace',)
         })
     )

+ 9 - 0
netbox/extras/api/nested_serializers.py

@@ -7,6 +7,7 @@ from users.api.nested_serializers import NestedUserSerializer
 __all__ = [
     'NestedConfigContextSerializer',
     'NestedCustomFieldSerializer',
+    'NestedCustomLinkSerializer',
     'NestedExportTemplateSerializer',
     'NestedImageAttachmentSerializer',
     'NestedJobResultSerializer',
@@ -22,6 +23,14 @@ class NestedCustomFieldSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'name']
 
 
+class NestedCustomLinkSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
+
+    class Meta:
+        model = models.CustomLink
+        fields = ['id', 'url', 'name']
+
+
 class NestedConfigContextSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
 

+ 19 - 1
netbox/extras/api/serializers.py

@@ -10,7 +10,7 @@ from dcim.api.nested_serializers import (
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from extras.choices import *
 from extras.models import (
-    ConfigContext, CustomField, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag,
+    ConfigContext, CustomField, CustomLink, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag,
 )
 from extras.utils import FeatureQuery
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
@@ -45,6 +45,24 @@ class CustomFieldSerializer(ValidatedModelSerializer):
         ]
 
 
+#
+# Custom links
+#
+
+class CustomLinkSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
+    content_type = ContentTypeField(
+        queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query())
+    )
+
+    class Meta:
+        model = CustomLink
+        fields = [
+            'id', 'url', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'button_class',
+            'new_window',
+        ]
+
+
 #
 # Export templates
 #

+ 3 - 0
netbox/extras/api/urls.py

@@ -8,6 +8,9 @@ router.APIRootView = views.ExtrasRootView
 # Custom fields
 router.register('custom-fields', views.CustomFieldViewSet)
 
+# Custom links
+router.register('custom-links', views.CustomLinkViewSet)
+
 # Export templates
 router.register('export-templates', views.ExportTemplateViewSet)
 

+ 12 - 1
netbox/extras/api/views.py

@@ -12,7 +12,7 @@ from rq import Worker
 from extras import filters
 from extras.choices import JobResultStatusChoices
 from extras.models import (
-    ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem,
+    ConfigContext, CustomLink, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem,
 )
 from extras.models import CustomField
 from extras.reports import get_report, get_reports, run_report
@@ -84,6 +84,17 @@ class CustomFieldModelViewSet(ModelViewSet):
         return context
 
 
+#
+# Custom links
+#
+
+class CustomLinkViewSet(ModelViewSet):
+    metadata_class = ContentTypeMetadata
+    queryset = CustomLink.objects.all()
+    serializer_class = serializers.CustomLinkSerializer
+    filterset_class = filters.CustomLinkFilterSet
+
+
 #
 # Export templates
 #

+ 11 - 1
netbox/extras/filters.py

@@ -9,7 +9,9 @@ from tenancy.models import Tenant, TenantGroup
 from utilities.filters import BaseFilterSet, ContentTypeFilter
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
-from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag
+from .models import (
+    ConfigContext, CustomField, CustomLink, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag,
+)
 
 
 __all__ = (
@@ -17,6 +19,7 @@ __all__ = (
     'ContentTypeFilterSet',
     'CreatedUpdatedFilterSet',
     'CustomFieldFilter',
+    'CustomLinkFilterSet',
     'CustomFieldModelFilterSet',
     'ExportTemplateFilterSet',
     'ImageAttachmentFilterSet',
@@ -79,6 +82,13 @@ class CustomFieldFilterSet(django_filters.FilterSet):
         fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
 
 
+class CustomLinkFilterSet(BaseFilterSet):
+
+    class Meta:
+        model = CustomLink
+        fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window']
+
+
 class ExportTemplateFilterSet(BaseFilterSet):
 
     class Meta:

+ 28 - 0
netbox/extras/migrations/0057_customlink_rename_fields.py

@@ -0,0 +1,28 @@
+# Generated by Django 3.2b1 on 2021-03-09 01:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0056_sitegroup'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='customlink',
+            old_name='text',
+            new_name='link_text',
+        ),
+        migrations.RenameField(
+            model_name='customlink',
+            old_name='url',
+            new_name='link_url',
+        ),
+        migrations.AlterField(
+            model_name='customlink',
+            name='new_window',
+            field=models.BooleanField(default=False),
+        ),
+    ]

+ 6 - 3
netbox/extras/models/models.py

@@ -172,13 +172,13 @@ class CustomLink(BigIDModel):
         max_length=100,
         unique=True
     )
-    text = models.CharField(
+    link_text = models.CharField(
         max_length=500,
         help_text="Jinja2 template code for link text"
     )
-    url = models.CharField(
+    link_url = models.CharField(
         max_length=500,
-        verbose_name='URL',
+        verbose_name='Link URL',
         help_text="Jinja2 template code for link URL"
     )
     weight = models.PositiveSmallIntegerField(
@@ -196,9 +196,12 @@ class CustomLink(BigIDModel):
         help_text="The class of the first link in a group will be used for the dropdown button"
     )
     new_window = models.BooleanField(
+        default=False,
         help_text="Force link to open in a new window"
     )
 
+    objects = RestrictedQuerySet.as_manager()
+
     class Meta:
         ordering = ['group_name', 'weight', 'name']
 

+ 4 - 4
netbox/extras/templatetags/custom_links.py

@@ -52,9 +52,9 @@ def custom_links(context, obj):
         # Add non-grouped links
         else:
             try:
-                text_rendered = render_jinja2(cl.text, link_context)
+                text_rendered = render_jinja2(cl.link_text, link_context)
                 if text_rendered:
-                    link_rendered = render_jinja2(cl.url, link_context)
+                    link_rendered = render_jinja2(cl.link_url, link_context)
                     link_target = ' target="_blank"' if cl.new_window else ''
                     template_code += LINK_BUTTON.format(
                         link_rendered, link_target, cl.button_class, text_rendered
@@ -70,10 +70,10 @@ def custom_links(context, obj):
 
         for cl in links:
             try:
-                text_rendered = render_jinja2(cl.text, link_context)
+                text_rendered = render_jinja2(cl.link_text, link_context)
                 if text_rendered:
                     link_target = ' target="_blank"' if cl.new_window else ''
-                    link_rendered = render_jinja2(cl.url, link_context)
+                    link_rendered = render_jinja2(cl.link_url, link_context)
                     links_rendered.append(
                         GROUP_LINK.format(link_rendered, link_target, text_rendered)
                     )

+ 55 - 1
netbox/extras/tests/test_api.py

@@ -11,7 +11,7 @@ from rq import Worker
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from extras.api.views import ReportViewSet, ScriptViewSet
-from extras.models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, Tag
+from extras.models import ConfigContext, CustomField, CustomLink, ExportTemplate, ImageAttachment, Tag
 from extras.reports import Report
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
 from utilities.testing import APITestCase, APIViewTestCases
@@ -77,6 +77,60 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
             cf.content_types.add(site_ct)
 
 
+class CustomLinkTest(APIViewTestCases.APIViewTestCase):
+    model = CustomLink
+    brief_fields = ['id', 'name', 'url']
+    create_data = [
+        {
+            'content_type': 'dcim.site',
+            'name': 'Custom Link 4',
+            'link_text': 'Link 4',
+            'link_url': 'http://example.com/?4',
+        },
+        {
+            'content_type': 'dcim.site',
+            'name': 'Custom Link 5',
+            'link_text': 'Link 5',
+            'link_url': 'http://example.com/?5',
+        },
+        {
+            'content_type': 'dcim.site',
+            'name': 'Custom Link 6',
+            'link_text': 'Link 6',
+            'link_url': 'http://example.com/?6',
+        },
+    ]
+    bulk_update_data = {
+        'new_window': True,
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        site_ct = ContentType.objects.get_for_model(Site)
+
+        custom_links = (
+            CustomLink(
+                content_type=site_ct,
+                name='Custom Link 1',
+                link_text='Link 1',
+                link_url='http://example.com/?1',
+            ),
+            CustomLink(
+                content_type=site_ct,
+                name='Custom Link 2',
+                link_text='Link 2',
+                link_url='http://example.com/?2',
+            ),
+            CustomLink(
+                content_type=site_ct,
+                name='Custom Link 3',
+                link_text='Link 3',
+                link_url='http://example.com/?3',
+            ),
+        )
+        CustomLink.objects.bulk_create(custom_links)
+
+
 class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ExportTemplate
     brief_fields = ['id', 'name', 'url']

+ 60 - 1
netbox/extras/tests/test_filters.py

@@ -7,12 +7,71 @@ from django.test import TestCase
 from dcim.models import DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from extras.choices import ObjectChangeActionChoices
 from extras.filters import *
-from extras.models import ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, Tag
+from extras.models import ConfigContext, CustomLink, ExportTemplate, ImageAttachment, ObjectChange, Tag
 from ipam.models import IPAddress
 from tenancy.models import Tenant, TenantGroup
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
+class CustomLinkTestCase(TestCase):
+    queryset = CustomLink.objects.all()
+    filterset = CustomLinkFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
+
+        custom_links = (
+            CustomLink(
+                name='Custom Link 1',
+                content_type=content_types[0],
+                weight=100,
+                new_window=False,
+                link_text='Link 1',
+                link_url='http://example.com/?1'
+            ),
+            CustomLink(
+                name='Custom Link 2',
+                content_type=content_types[1],
+                weight=200,
+                new_window=False,
+                link_text='Link 1',
+                link_url='http://example.com/?2'
+            ),
+            CustomLink(
+                name='Custom Link 3',
+                content_type=content_types[2],
+                weight=300,
+                new_window=True,
+                link_text='Link 1',
+                link_url='http://example.com/?3'
+            ),
+        )
+        CustomLink.objects.bulk_create(custom_links)
+
+    def test_id(self):
+        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_name(self):
+        params = {'name': ['Custom Link 1', 'Custom Link 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_content_type(self):
+        params = {'content_type': ContentType.objects.get(model='site').pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_weight(self):
+        params = {'weight': [100, 200]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_new_window(self):
+        params = {'new_window': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'new_window': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+
 class ExportTemplateTestCase(TestCase):
     queryset = ExportTemplate.objects.all()
     filterset = ExportTemplateFilterSet

+ 2 - 2
netbox/extras/tests/test_views.py

@@ -135,8 +135,8 @@ class CustomLinkTest(TestCase):
         customlink = CustomLink(
             content_type=ContentType.objects.get_for_model(Site),
             name='Test',
-            text='FOO {{ obj.name }} BAR',
-            url='http://example.com/?site={{ obj.slug }}',
+            link_text='FOO {{ obj.name }} BAR',
+            link_url='http://example.com/?site={{ obj.slug }}',
             new_window=False
         )
         customlink.save()