1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Closes #5608: Add REST API endpoint for custom links

This commit is contained in:
Jeremy Stretch
2021-03-08 20:57:44 -05:00
parent 14bc3a3cf8
commit 38ded66c4e
13 changed files with 218 additions and 20 deletions

View File

@ -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`

View File

@ -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',)
})
)

View File

@ -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')

View File

@ -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
#

View File

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

View File

@ -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
#

View File

@ -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:

View File

@ -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),
),
]

View File

@ -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']

View File

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

View File

@ -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']

View File

@ -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

View File

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