From 38ded66c4e2c0268a4017d013e4d13e0593a9f58 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Mar 2021 20:57:44 -0500 Subject: [PATCH] Closes #5608: Add REST API endpoint for custom links --- docs/release-notes/version-2.11.md | 3 + netbox/extras/admin.py | 12 ++-- netbox/extras/api/nested_serializers.py | 9 +++ netbox/extras/api/serializers.py | 20 +++++- netbox/extras/api/urls.py | 3 + netbox/extras/api/views.py | 13 +++- netbox/extras/filters.py | 12 +++- .../0057_customlink_rename_fields.py | 28 +++++++++ netbox/extras/models/models.py | 9 ++- netbox/extras/templatetags/custom_links.py | 8 +-- netbox/extras/tests/test_api.py | 56 ++++++++++++++++- netbox/extras/tests/test_filters.py | 61 ++++++++++++++++++- netbox/extras/tests/test_views.py | 4 +- 13 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 netbox/extras/migrations/0057_customlink_rename_fields.py diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 1dca76e37..a4676b1e7 100644 --- a/docs/release-notes/version-2.11.md +++ b/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` diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index a4786610d..4e11307fc 100644 --- a/netbox/extras/admin.py +++ b/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 {{ obj }}. Links ' - 'which render as empty text will not be displayed.', - 'url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}.', + '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 }}.', } 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',) }) ) diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 5635f401b..3e6067198 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/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') diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index dc903a0ab..b23114e13 100644 --- a/netbox/extras/api/serializers.py +++ b/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 # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index da62b3d72..ec82333c6 100644 --- a/netbox/extras/api/urls.py +++ b/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) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index b46c2367b..941559e23 100644 --- a/netbox/extras/api/views.py +++ b/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 # diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 4a7b36d49..fb9c9c7f1 100644 --- a/netbox/extras/filters.py +++ b/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: diff --git a/netbox/extras/migrations/0057_customlink_rename_fields.py b/netbox/extras/migrations/0057_customlink_rename_fields.py new file mode 100644 index 000000000..4ed5c7bc7 --- /dev/null +++ b/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), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 53ac9a590..70cdcbba2 100644 --- a/netbox/extras/models/models.py +++ b/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'] diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index 30375c562..39017adfd 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/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) ) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 248a3995b..21622cedd 100644 --- a/netbox/extras/tests/test_api.py +++ b/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'] diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index 8f53aa521..79300ee61 100644 --- a/netbox/extras/tests/test_filters.py +++ b/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 diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 45a246f38..703072601 100644 --- a/netbox/extras/tests/test_views.py +++ b/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()