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()