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

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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
   * Added the `site_groups` many-to-many field to track the assignment of ConfigContexts to SiteGroups
 * extras.CustomField
 * extras.CustomField
   * Added new custom field type: `multi-select`
   * Added new custom field type: `multi-select`
+* extras.CustomLink
+  * Added the `/api/extras/custom-links/` endpoint
 * extras.ObjectChange
 * extras.ObjectChange
   * Added the `prechange_data` field
   * Added the `prechange_data` field
   * Renamed `object_data` to `postchange_data`
   * Renamed `object_data` to `postchange_data`

+ 6 - 6
netbox/extras/admin.py

@@ -132,15 +132,15 @@ class CustomLinkForm(forms.ModelForm):
         model = CustomLink
         model = CustomLink
         exclude = []
         exclude = []
         widgets = {
         widgets = {
-            'text': forms.Textarea,
-            'url': forms.Textarea,
+            'link_text': forms.Textarea,
+            'link_url': forms.Textarea,
         }
         }
         help_texts = {
         help_texts = {
             'weight': 'A numeric weight to influence the ordering of this link among its peers. Lower weights appear '
             'weight': 'A numeric weight to influence the ordering of this link among its peers. Lower weights appear '
                       'first in a list.',
                       '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):
     def __init__(self, *args, **kwargs):
@@ -158,7 +158,7 @@ class CustomLinkAdmin(admin.ModelAdmin):
             'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window')
             'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window')
         }),
         }),
         ('Templates', {
         ('Templates', {
-            'fields': ('text', 'url'),
+            'fields': ('link_text', 'link_url'),
             'classes': ('monospace',)
             'classes': ('monospace',)
         })
         })
     )
     )

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

@@ -7,6 +7,7 @@ from users.api.nested_serializers import NestedUserSerializer
 __all__ = [
 __all__ = [
     'NestedConfigContextSerializer',
     'NestedConfigContextSerializer',
     'NestedCustomFieldSerializer',
     'NestedCustomFieldSerializer',
+    'NestedCustomLinkSerializer',
     'NestedExportTemplateSerializer',
     'NestedExportTemplateSerializer',
     'NestedImageAttachmentSerializer',
     'NestedImageAttachmentSerializer',
     'NestedJobResultSerializer',
     'NestedJobResultSerializer',
@@ -22,6 +23,14 @@ class NestedCustomFieldSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'name']
         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):
 class NestedConfigContextSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
     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 dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from extras.choices import *
 from extras.choices import *
 from extras.models 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 extras.utils import FeatureQuery
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
 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
 # Export templates
 #
 #

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

@@ -8,6 +8,9 @@ router.APIRootView = views.ExtrasRootView
 # Custom fields
 # Custom fields
 router.register('custom-fields', views.CustomFieldViewSet)
 router.register('custom-fields', views.CustomFieldViewSet)
 
 
+# Custom links
+router.register('custom-links', views.CustomLinkViewSet)
+
 # Export templates
 # Export templates
 router.register('export-templates', views.ExportTemplateViewSet)
 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 import filters
 from extras.choices import JobResultStatusChoices
 from extras.choices import JobResultStatusChoices
 from extras.models import (
 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.models import CustomField
 from extras.reports import get_report, get_reports, run_report
 from extras.reports import get_report, get_reports, run_report
@@ -84,6 +84,17 @@ class CustomFieldModelViewSet(ModelViewSet):
         return context
         return context
 
 
 
 
+#
+# Custom links
+#
+
+class CustomLinkViewSet(ModelViewSet):
+    metadata_class = ContentTypeMetadata
+    queryset = CustomLink.objects.all()
+    serializer_class = serializers.CustomLinkSerializer
+    filterset_class = filters.CustomLinkFilterSet
+
+
 #
 #
 # Export templates
 # 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 utilities.filters import BaseFilterSet, ContentTypeFilter
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 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__ = (
 __all__ = (
@@ -17,6 +19,7 @@ __all__ = (
     'ContentTypeFilterSet',
     'ContentTypeFilterSet',
     'CreatedUpdatedFilterSet',
     'CreatedUpdatedFilterSet',
     'CustomFieldFilter',
     'CustomFieldFilter',
+    'CustomLinkFilterSet',
     'CustomFieldModelFilterSet',
     'CustomFieldModelFilterSet',
     'ExportTemplateFilterSet',
     'ExportTemplateFilterSet',
     'ImageAttachmentFilterSet',
     'ImageAttachmentFilterSet',
@@ -79,6 +82,13 @@ class CustomFieldFilterSet(django_filters.FilterSet):
         fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
         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 ExportTemplateFilterSet(BaseFilterSet):
 
 
     class Meta:
     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,
         max_length=100,
         unique=True
         unique=True
     )
     )
-    text = models.CharField(
+    link_text = models.CharField(
         max_length=500,
         max_length=500,
         help_text="Jinja2 template code for link text"
         help_text="Jinja2 template code for link text"
     )
     )
-    url = models.CharField(
+    link_url = models.CharField(
         max_length=500,
         max_length=500,
-        verbose_name='URL',
+        verbose_name='Link URL',
         help_text="Jinja2 template code for link URL"
         help_text="Jinja2 template code for link URL"
     )
     )
     weight = models.PositiveSmallIntegerField(
     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"
         help_text="The class of the first link in a group will be used for the dropdown button"
     )
     )
     new_window = models.BooleanField(
     new_window = models.BooleanField(
+        default=False,
         help_text="Force link to open in a new window"
         help_text="Force link to open in a new window"
     )
     )
 
 
+    objects = RestrictedQuerySet.as_manager()
+
     class Meta:
     class Meta:
         ordering = ['group_name', 'weight', 'name']
         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
         # Add non-grouped links
         else:
         else:
             try:
             try:
-                text_rendered = render_jinja2(cl.text, link_context)
+                text_rendered = render_jinja2(cl.link_text, link_context)
                 if text_rendered:
                 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 ''
                     link_target = ' target="_blank"' if cl.new_window else ''
                     template_code += LINK_BUTTON.format(
                     template_code += LINK_BUTTON.format(
                         link_rendered, link_target, cl.button_class, text_rendered
                         link_rendered, link_target, cl.button_class, text_rendered
@@ -70,10 +70,10 @@ def custom_links(context, obj):
 
 
         for cl in links:
         for cl in links:
             try:
             try:
-                text_rendered = render_jinja2(cl.text, link_context)
+                text_rendered = render_jinja2(cl.link_text, link_context)
                 if text_rendered:
                 if text_rendered:
                     link_target = ' target="_blank"' if cl.new_window else ''
                     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(
                     links_rendered.append(
                         GROUP_LINK.format(link_rendered, link_target, text_rendered)
                         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 dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from extras.api.views import ReportViewSet, ScriptViewSet
 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.reports import Report
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
@@ -77,6 +77,60 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
             cf.content_types.add(site_ct)
             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):
 class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ExportTemplate
     model = ExportTemplate
     brief_fields = ['id', 'name', 'url']
     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 dcim.models import DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from extras.choices import ObjectChangeActionChoices
 from extras.choices import ObjectChangeActionChoices
 from extras.filters import *
 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 ipam.models import IPAddress
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 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):
 class ExportTemplateTestCase(TestCase):
     queryset = ExportTemplate.objects.all()
     queryset = ExportTemplate.objects.all()
     filterset = ExportTemplateFilterSet
     filterset = ExportTemplateFilterSet

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

@@ -135,8 +135,8 @@ class CustomLinkTest(TestCase):
         customlink = CustomLink(
         customlink = CustomLink(
             content_type=ContentType.objects.get_for_model(Site),
             content_type=ContentType.objects.get_for_model(Site),
             name='Test',
             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
             new_window=False
         )
         )
         customlink.save()
         customlink.save()