Closes #4349: Drop support for embedded graphs

This commit is contained in:
Jeremy Stretch 2020-08-21 11:57:46 -04:00
parent ee34e28986
commit ec66e1a5c0
36 changed files with 33 additions and 595 deletions

View File

@ -1,30 +0,0 @@
# Graphs
!!! warning
Native support for embedded graphs is due to be removed in NetBox v2.10. It will likely be superseded by a plugin providing similar functionality.
NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters:
* **Type:** Site, device, provider, or interface. This determines in which view the graph will be displayed.
* **Weight:** Determines the order in which graphs are displayed (lower weights are displayed first). Graphs with equal weights will be ordered alphabetically by name.
* **Name:** The title to display above the graph.
* **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`.
* **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`.
Graph names and links can be rendered using Jinja2 or [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/).
## Examples
You only need to define one graph object for each graph you want to include when viewing an object. For example, if you want to include a graph of traffic through an interface over the past five minutes, your graph source might looks like this:
```
https://my.nms.local/graphs/?node={{ obj.device.name }}&interface={{ obj.name }}&duration=5m
```
You can define several graphs to provide multiple contexts when viewing an object. For example:
```
https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m
https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=24h
https://my.nms.local/graphs/?type=errors&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m
```

View File

@ -2,8 +2,11 @@
## v2.10-beta1 (FUTURE) ## v2.10-beta1 (FUTURE)
**NOTE:** This release completely removes support for embedded graphs.
### Other Changes ### Other Changes
* [#4349](https://github.com/netbox-community/netbox/issues/4349) - Dropped support for embedded graphs
* [#4360](https://github.com/netbox-community/netbox/issues/4360) - Remove support for the Django template language from export templates * [#4360](https://github.com/netbox-community/netbox/issues/4360) - Remove support for the Django template language from export templates
* [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method * [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method

View File

@ -49,7 +49,6 @@ nav:
- Custom Links: 'additional-features/custom-links.md' - Custom Links: 'additional-features/custom-links.md'
- Custom Scripts: 'additional-features/custom-scripts.md' - Custom Scripts: 'additional-features/custom-scripts.md'
- Export Templates: 'additional-features/export-templates.md' - Export Templates: 'additional-features/export-templates.md'
- Graphs: 'additional-features/graphs.md'
- NAPALM: 'additional-features/napalm.md' - NAPALM: 'additional-features/napalm.md'
- Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Prometheus Metrics: 'additional-features/prometheus-metrics.md'
- Reports: 'additional-features/reports.md' - Reports: 'additional-features/reports.md'

View File

@ -1,14 +1,9 @@
from django.db.models import Count, Prefetch from django.db.models import Count, Prefetch
from django.shortcuts import get_object_or_404
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from circuits import filters from circuits import filters
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
from extras.api.serializers import RenderedGraphSerializer
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
from extras.models import Graph
from utilities.api import ModelViewSet from utilities.api import ModelViewSet
from . import serializers from . import serializers
@ -32,16 +27,6 @@ class ProviderViewSet(CustomFieldModelViewSet):
serializer_class = serializers.ProviderSerializer serializer_class = serializers.ProviderSerializer
filterset_class = filters.ProviderFilterSet filterset_class = filters.ProviderFilterSet
@action(detail=True)
def graphs(self, request, pk):
"""
A convenience method for rendering graphs for a particular provider.
"""
provider = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='provider')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider})
return Response(serializer.data)
# #
# Circuit Types # Circuit Types

View File

@ -22,7 +22,7 @@ __all__ = (
) )
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Provider(ChangeLoggedModel, CustomFieldModel): class Provider(ChangeLoggedModel, CustomFieldModel):
""" """
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model

View File

@ -1,11 +1,8 @@
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from circuits.choices import * from circuits.choices import *
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.models import Site from dcim.models import Site
from extras.models import Graph
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
@ -46,27 +43,6 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
) )
Provider.objects.bulk_create(providers) Provider.objects.bulk_create(providers)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_provider_graphs(self):
"""
Test retrieval of Graphs assigned to Providers.
"""
provider = self.model.objects.first()
ct = ContentType.objects.get(app_label='circuits', model='provider')
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'),
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2'),
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'),
)
Graph.objects.bulk_create(graphs)
self.add_permissions('circuits.view_provider')
url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=provider-1&foo=1')
class CircuitTypeTest(APIViewTestCases.APIViewTestCase): class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
model = CircuitType model = CircuitType

View File

@ -1,11 +1,10 @@
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.db import transaction from django.db import transaction
from django.db.models import Count, Prefetch from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from extras.models import Graph
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.views import ( from utilities.views import (
@ -38,7 +37,6 @@ class ProviderView(ObjectView):
).prefetch_related( ).prefetch_related(
'type', 'tenant', 'terminations__site' 'type', 'tenant', 'terminations__site'
).annotate_sites() ).annotate_sites()
show_graphs = Graph.objects.filter(type__model='provider').exists()
circuits_table = tables.CircuitTable(circuits) circuits_table = tables.CircuitTable(circuits)
circuits_table.columns.hide('provider') circuits_table.columns.hide('provider')
@ -52,7 +50,6 @@ class ProviderView(ObjectView):
return render(request, 'circuits/provider.html', { return render(request, 'circuits/provider.html', {
'provider': provider, 'provider': provider,
'circuits_table': circuits_table, 'circuits_table': circuits_table,
'show_graphs': show_graphs,
}) })

View File

@ -23,9 +23,7 @@ from dcim.models import (
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis, VirtualChassis,
) )
from extras.api.serializers import RenderedGraphSerializer
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
from extras.models import Graph
from ipam.models import Prefix, VLAN from ipam.models import Prefix, VLAN
from utilities.api import ( from utilities.api import (
get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable,
@ -113,16 +111,6 @@ class SiteViewSet(CustomFieldModelViewSet):
serializer_class = serializers.SiteSerializer serializer_class = serializers.SiteSerializer
filterset_class = filters.SiteFilterSet filterset_class = filters.SiteFilterSet
@action(detail=True)
def graphs(self, request, pk):
"""
A convenience method for rendering graphs for a particular site.
"""
site = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='site')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site})
return Response(serializer.data)
# #
# Rack groups # Rack groups
@ -363,17 +351,6 @@ class DeviceViewSet(CustomFieldModelViewSet):
return serializers.DeviceWithConfigContextSerializer return serializers.DeviceWithConfigContextSerializer
@action(detail=True)
def graphs(self, request, pk):
"""
A convenience method for rendering graphs for a particular Device.
"""
device = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='device')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device})
return Response(serializer.data)
@swagger_auto_schema( @swagger_auto_schema(
manual_parameters=[ manual_parameters=[
Parameter( Parameter(
@ -527,16 +504,6 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
serializer_class = serializers.InterfaceSerializer serializer_class = serializers.InterfaceSerializer
filterset_class = filters.InterfaceFilterSet filterset_class = filters.InterfaceFilterSet
@action(detail=True)
def graphs(self, request, pk):
"""
A convenience method for rendering graphs for a particular interface.
"""
interface = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='interface')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface})
return Response(serializer.data)
class FrontPortViewSet(CableTraceMixin, ModelViewSet): class FrontPortViewSet(CableTraceMixin, ModelViewSet):
queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')

View File

@ -582,7 +582,7 @@ class BaseInterface(models.Model):
abstract = True abstract = True
@extras_features('graphs', 'export_templates', 'webhooks') @extras_features('export_templates', 'webhooks')
class Interface(CableTermination, ComponentModel, BaseInterface): class Interface(CableTermination, ComponentModel, BaseInterface):
""" """
A network interface within a Device. A physical Interface can connect to exactly one other Interface. A network interface within a Device. A physical Interface can connect to exactly one other Interface.

View File

@ -450,7 +450,7 @@ class Platform(ChangeLoggedModel):
) )
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
""" """
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,

View File

@ -91,7 +91,7 @@ class Region(MPTTModel, ChangeLoggedModel):
# Sites # Sites
# #
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Site(ChangeLoggedModel, CustomFieldModel): class Site(ChangeLoggedModel, CustomFieldModel):
""" """
A Site represents a geographic location within a network; typically a building or campus. The optional facility A Site represents a geographic location within a network; typically a building or campus. The optional facility

View File

@ -1,6 +1,4 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
@ -14,7 +12,6 @@ from dcim.models import (
Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
) )
from ipam.models import VLAN from ipam.models import VLAN
from extras.models import Graph
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
from virtualization.models import Cluster, ClusterType from virtualization.models import Cluster, ClusterType
@ -132,26 +129,6 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
}, },
] ]
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_site_graphs(self):
"""
Test retrieval of Graphs assigned to Sites.
"""
ct = ContentType.objects.get_for_model(Site)
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=1'),
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=2'),
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=3'),
)
Graph.objects.bulk_create(graphs)
self.add_permissions('dcim.view_site')
url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.first().pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?site=site-1&foo=1')
class RackGroupTest(APIViewTestCases.APIViewTestCase): class RackGroupTest(APIViewTestCases.APIViewTestCase):
model = RackGroup model = RackGroup
@ -902,26 +879,6 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
}, },
] ]
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_device_graphs(self):
"""
Test retrieval of Graphs assigned to Devices.
"""
ct = ContentType.objects.get_for_model(Device)
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?device={{ obj.name }}&foo=1'),
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?device={{ obj.name }}&foo=2'),
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?device={{ obj.name }}&foo=3'),
)
Graph.objects.bulk_create(graphs)
self.add_permissions('dcim.view_device')
url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.first().pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?device=Device 1&foo=1')
def test_config_context_included_by_default_in_list_view(self): def test_config_context_included_by_default_in_list_view(self):
""" """
Check that config context data is included by default in the devices list. Check that config context data is included by default in the devices list.
@ -1159,26 +1116,6 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
}, },
] ]
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_interface_graphs(self):
"""
Test retrieval of Graphs assigned to Devices.
"""
ct = ContentType.objects.get_for_model(Interface)
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=1'),
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=2'),
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=3'),
)
Graph.objects.bulk_create(graphs)
self.add_permissions('dcim.view_interface')
url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.first().pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1')
class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = FrontPort model = FrontPort

View File

@ -14,7 +14,6 @@ from django.utils.safestring import mark_safe
from django.views.generic import View from django.views.generic import View
from circuits.models import Circuit from circuits.models import Circuit
from extras.models import Graph
from extras.views import ObjectConfigContextView from extras.views import ObjectConfigContextView
from ipam.models import IPAddress, Prefix, Service, VLAN from ipam.models import IPAddress, Prefix, Service, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
@ -172,13 +171,11 @@ class SiteView(ObjectView):
rack_groups = RackGroup.objects.restrict(request.user, 'view').filter(site=site).annotate( rack_groups = RackGroup.objects.restrict(request.user, 'view').filter(site=site).annotate(
rack_count=Count('racks') rack_count=Count('racks')
) )
show_graphs = Graph.objects.filter(type__model='site').exists()
return render(request, 'dcim/site.html', { return render(request, 'dcim/site.html', {
'site': site, 'site': site,
'stats': stats, 'stats': stats,
'rack_groups': rack_groups, 'rack_groups': rack_groups,
'show_graphs': show_graphs,
}) })
@ -1082,8 +1079,6 @@ class DeviceView(ObjectView):
'secrets': secrets, 'secrets': secrets,
'vc_members': vc_members, 'vc_members': vc_members,
'related_devices': related_devices, 'related_devices': related_devices,
'show_graphs': Graph.objects.filter(type__model='device').exists(),
'show_interface_graphs': Graph.objects.filter(type__model='interface').exists(),
}) })

View File

@ -2,7 +2,7 @@ from django import forms
from django.contrib import admin from django.contrib import admin
from utilities.forms import LaxURLField from utilities.forms import LaxURLField
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, JobResult, Webhook from .models import CustomField, CustomFieldChoice, CustomLink, ExportTemplate, JobResult, Webhook
def order_content_types(field): def order_content_types(field):
@ -150,45 +150,6 @@ class CustomLinkAdmin(admin.ModelAdmin):
form = CustomLinkForm form = CustomLinkForm
#
# Graphs
#
class GraphForm(forms.ModelForm):
class Meta:
model = Graph
exclude = ()
help_texts = {
'template_language': "<a href=\"https://jinja.palletsprojects.com\">Jinja2</a> is strongly recommended for "
"new graphs."
}
widgets = {
'source': forms.Textarea,
'link': forms.Textarea,
}
@admin.register(Graph)
class GraphAdmin(admin.ModelAdmin):
fieldsets = (
('Graph', {
'fields': ('type', 'name', 'weight')
}),
('Templates', {
'fields': ('template_language', 'source', 'link'),
'classes': ('monospace',)
})
)
form = GraphForm
list_display = [
'name', 'type', 'weight', 'template_language', 'source',
]
list_filter = [
'type', 'template_language',
]
# #
# Export templates # Export templates
# #

View File

@ -7,7 +7,6 @@ from utilities.api import ChoiceField, WritableNestedSerializer
__all__ = [ __all__ = [
'NestedConfigContextSerializer', 'NestedConfigContextSerializer',
'NestedExportTemplateSerializer', 'NestedExportTemplateSerializer',
'NestedGraphSerializer',
'NestedImageAttachmentSerializer', 'NestedImageAttachmentSerializer',
'NestedJobResultSerializer', 'NestedJobResultSerializer',
'NestedTagSerializer', 'NestedTagSerializer',
@ -30,14 +29,6 @@ class NestedExportTemplateSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'name'] fields = ['id', 'url', 'name']
class NestedGraphSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:graph-detail')
class Meta:
model = models.Graph
fields = ['id', 'url', 'name']
class NestedImageAttachmentSerializer(WritableNestedSerializer): class NestedImageAttachmentSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')

View File

@ -10,7 +10,7 @@ from dcim.api.nested_serializers import (
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
from extras.choices import * from extras.choices import *
from extras.models import ( from extras.models import (
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag, ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag,
) )
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
@ -25,43 +25,6 @@ from virtualization.models import Cluster, ClusterGroup
from .nested_serializers import * from .nested_serializers import *
#
# Graphs
#
class GraphSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:graph-detail')
type = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('graphs').get_query()),
)
class Meta:
model = Graph
fields = ['id', 'url', 'type', 'weight', 'name', 'template_language', 'source', 'link']
class RenderedGraphSerializer(serializers.ModelSerializer):
embed_url = serializers.SerializerMethodField(
read_only=True
)
embed_link = serializers.SerializerMethodField(
read_only=True
)
type = ContentTypeField(
read_only=True
)
class Meta:
model = Graph
fields = ['id', 'type', 'weight', 'name', 'embed_url', 'embed_link']
def get_embed_url(self, obj):
return obj.embed_url(self.context['graphed_object'])
def get_embed_link(self, obj):
return obj.embed_link(self.context['graphed_object'])
# #
# Export templates # Export templates
# #

View File

@ -8,9 +8,6 @@ router.APIRootView = views.ExtrasRootView
# Custom field choices # Custom field choices
router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice') router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
# Graphs
router.register('graphs', views.GraphViewSet)
# Export templates # Export templates
router.register('export-templates', views.ExportTemplateViewSet) router.register('export-templates', views.ExportTemplateViewSet)

View File

@ -15,7 +15,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, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag, ConfigContext, CustomFieldChoice, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag,
) )
from extras.reports import get_report, get_reports, run_report from extras.reports import get_report, get_reports, run_report
from extras.scripts import get_script, get_scripts, run_script from extras.scripts import get_script, get_scripts, run_script
@ -98,17 +98,6 @@ class CustomFieldModelViewSet(ModelViewSet):
return super().get_queryset().prefetch_related('custom_field_values__field') return super().get_queryset().prefetch_related('custom_field_values__field')
#
# Graphs
#
class GraphViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Graph.objects.all()
serializer_class = serializers.GraphSerializer
filterset_class = filters.GraphFilterSet
# #
# Export templates # Export templates
# #

View File

@ -79,21 +79,6 @@ class ObjectChangeActionChoices(ChoiceSet):
) )
#
# ExportTemplates
#
class TemplateLanguageChoices(ChoiceSet):
LANGUAGE_JINJA2 = 'jinja2'
LANGUAGE_DJANGO = 'django'
CHOICES = (
(LANGUAGE_JINJA2, 'Jinja2'),
(LANGUAGE_DJANGO, 'Django (Legacy)'),
)
# #
# Log Levels for Reports and Scripts # Log Levels for Reports and Scripts
# #

View File

@ -6,7 +6,6 @@ EXTRAS_FEATURES = [
'custom_fields', 'custom_fields',
'custom_links', 'custom_links',
'export_templates', 'export_templates',
'graphs',
'job_results', 'job_results',
'webhooks' 'webhooks'
] ]

View File

@ -7,7 +7,7 @@ from tenancy.models import Tenant, TenantGroup
from utilities.filters import BaseFilterSet from utilities.filters import BaseFilterSet
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * from .choices import *
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, JobResult, Tag from .models import ConfigContext, CustomField, ExportTemplate, ObjectChange, JobResult, Tag
__all__ = ( __all__ = (
@ -16,7 +16,6 @@ __all__ = (
'CustomFieldFilter', 'CustomFieldFilter',
'CustomFieldFilterSet', 'CustomFieldFilterSet',
'ExportTemplateFilterSet', 'ExportTemplateFilterSet',
'GraphFilterSet',
'LocalConfigContextFilterSet', 'LocalConfigContextFilterSet',
'ObjectChangeFilterSet', 'ObjectChangeFilterSet',
'TagFilterSet', 'TagFilterSet',
@ -90,13 +89,6 @@ class CustomFieldFilterSet(django_filters.FilterSet):
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf) self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf)
class GraphFilterSet(BaseFilterSet):
class Meta:
model = Graph
fields = ['id', 'type', 'name', 'template_language']
class ExportTemplateFilterSet(BaseFilterSet): class ExportTemplateFilterSet(BaseFilterSet):
class Meta: class Meta:

View File

@ -0,0 +1,16 @@
# Generated by Django 3.1 on 2020-08-21 15:47
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0048_exporttemplate_remove_template_language'),
]
operations = [
migrations.DeleteModel(
name='Graph',
),
]

View File

@ -1,7 +1,7 @@
from .change_logging import ChangeLoggedModel, ObjectChange from .change_logging import ChangeLoggedModel, ObjectChange
from .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue from .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue
from .models import ( from .models import (
ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, Graph, ImageAttachment, JobResult, Report, Script, ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script,
Webhook, Webhook,
) )
from .tags import Tag, TaggedItem from .tags import Tag, TaggedItem
@ -16,7 +16,6 @@ __all__ = (
'CustomFieldValue', 'CustomFieldValue',
'CustomLink', 'CustomLink',
'ExportTemplate', 'ExportTemplate',
'Graph',
'ImageAttachment', 'ImageAttachment',
'JobResult', 'JobResult',
'ObjectChange', 'ObjectChange',

View File

@ -203,69 +203,6 @@ class CustomLink(models.Model):
return self.name return self.name
#
# Graphs
#
class Graph(models.Model):
type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
limit_choices_to=FeatureQuery('graphs')
)
weight = models.PositiveSmallIntegerField(
default=1000
)
name = models.CharField(
max_length=100,
verbose_name='Name'
)
template_language = models.CharField(
max_length=50,
choices=TemplateLanguageChoices,
default=TemplateLanguageChoices.LANGUAGE_JINJA2
)
source = models.CharField(
max_length=500,
verbose_name='Source URL'
)
link = models.URLField(
blank=True,
verbose_name='Link URL'
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('type', 'weight', 'name', 'pk') # (type, weight, name) may be non-unique
def __str__(self):
return self.name
def embed_url(self, obj):
context = {'obj': obj}
if self.template_language == TemplateLanguageChoices.LANGUAGE_DJANGO:
template = Template(self.source)
return template.render(Context(context))
elif self.template_language == TemplateLanguageChoices.LANGUAGE_JINJA2:
return render_jinja2(self.source, context)
def embed_link(self, obj):
if self.link is None:
return ''
context = {'obj': obj}
if self.template_language == TemplateLanguageChoices.LANGUAGE_DJANGO:
template = Template(self.link)
return template.render(Context(context))
elif self.template_language == TemplateLanguageChoices.LANGUAGE_JINJA2:
return render_jinja2(self.link, context)
# #
# Export templates # Export templates
# #

View File

@ -10,7 +10,7 @@ from rq import Worker
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site
from extras.api.views import ReportViewSet, ScriptViewSet from extras.api.views import ReportViewSet, ScriptViewSet
from extras.models import ConfigContext, ExportTemplate, Graph, ImageAttachment, Tag from extras.models import ConfigContext, 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
@ -29,39 +29,6 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
class GraphTest(APIViewTestCases.APIViewTestCase):
model = Graph
brief_fields = ['id', 'name', 'url']
create_data = [
{
'type': 'dcim.site',
'name': 'Graph 4',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
},
{
'type': 'dcim.site',
'name': 'Graph 5',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
},
{
'type': 'dcim.site',
'name': 'Graph 6',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
},
]
@classmethod
def setUpTestData(cls):
ct = ContentType.objects.get_for_model(Site)
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?site={{ obj.name }}&foo=1'),
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?site={{ obj.name }}&foo=2'),
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?site={{ obj.name }}&foo=3'),
)
Graph.objects.bulk_create(graphs)
class ExportTemplateTest(APIViewTestCases.APIViewTestCase): class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
model = ExportTemplate model = ExportTemplate
brief_fields = ['id', 'name', 'url'] brief_fields = ['id', 'name', 'url']

View File

@ -2,49 +2,12 @@ from django.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
from dcim.models import DeviceRole, Platform, Region, Site from dcim.models import DeviceRole, Platform, Region, Site
from extras.choices import *
from extras.filters import * from extras.filters import *
from extras.utils import FeatureQuery from extras.models import ConfigContext, ExportTemplate, Tag
from extras.models import ConfigContext, ExportTemplate, Graph, Tag
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 GraphTestCase(TestCase):
queryset = Graph.objects.all()
filterset = GraphFilterSet
@classmethod
def setUpTestData(cls):
# Get the first three available types
content_types = ContentType.objects.filter(FeatureQuery('graphs').get_query())[:3]
graphs = (
Graph(name='Graph 1', type=content_types[0], template_language=TemplateLanguageChoices.LANGUAGE_DJANGO, source='http://example.com/1'),
Graph(name='Graph 2', type=content_types[1], template_language=TemplateLanguageChoices.LANGUAGE_JINJA2, source='http://example.com/2'),
Graph(name='Graph 3', type=content_types[2], template_language=TemplateLanguageChoices.LANGUAGE_JINJA2, source='http://example.com/3'),
)
Graph.objects.bulk_create(graphs)
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': ['Graph 1', 'Graph 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
content_type = ContentType.objects.filter(FeatureQuery('graphs').get_query()).first()
params = {'type': content_type.pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_template_language(self):
params = {'template_language': TemplateLanguageChoices.LANGUAGE_JINJA2}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ExportTemplateTestCase(TestCase): class ExportTemplateTestCase(TestCase):
queryset = ExportTemplate.objects.all() queryset = ExportTemplate.objects.all()
filterset = ExportTemplateFilterSet filterset = ExportTemplateFilterSet

View File

@ -1,49 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
from dcim.models import Site from extras.models import Tag
from extras.choices import TemplateLanguageChoices
from extras.models import Graph, Tag
class GraphTest(TestCase):
def setUp(self):
self.site = Site(name='Site 1', slug='site-1')
def test_graph_render_django(self):
# Using the pluralize filter as a sanity check (it's only available in Django)
TEMPLATE_TEXT = "{{ obj.name|lower }} thing{{ 2|pluralize }}"
RENDERED_TEXT = "site 1 things"
graph = Graph(
type=ContentType.objects.get(app_label='dcim', model='site'),
name='Graph 1',
template_language=TemplateLanguageChoices.LANGUAGE_DJANGO,
source=TEMPLATE_TEXT,
link=TEMPLATE_TEXT
)
self.assertEqual(graph.embed_url(self.site), RENDERED_TEXT)
self.assertEqual(graph.embed_link(self.site), RENDERED_TEXT)
def test_graph_render_jinja2(self):
TEMPLATE_TEXT = "{{ [obj.name, obj.slug]|join(',') }}"
RENDERED_TEXT = "Site 1,site-1"
graph = Graph(
type=ContentType.objects.get(app_label='dcim', model='site'),
name='Graph 1',
template_language=TemplateLanguageChoices.LANGUAGE_JINJA2,
source=TEMPLATE_TEXT,
link=TEMPLATE_TEXT
)
self.assertEqual(graph.embed_url(self.site), RENDERED_TEXT)
self.assertEqual(graph.embed_link(self.site), RENDERED_TEXT)
class TagTest(TestCase): class TagTest(TestCase):

View File

@ -1,26 +0,0 @@
$('#graphs_modal').on('show.bs.modal', function (event) {
var button = $(event.relatedTarget);
var obj = button.data('obj');
var url = button.data('url');
var modal_title = $(this).find('.modal-title');
var modal_body = $(this).find('.modal-body');
modal_title.text(obj);
modal_body.empty();
$.ajax({
url: url,
dataType: 'json',
success: function(json) {
$.each(json, function(i, graph) {
// Build in a 500ms delay per graph to avoid hammering the server
setTimeout(function() {
modal_body.append('<h4 class="text-center">' + graph.name + '</h4>');
if (graph.embed_link) {
modal_body.append('<a href="' + graph.embed_link + '"><img src="' + graph.embed_url + '" /></a>');
} else {
modal_body.append('<img src="' + graph.embed_url + '" />');
}
}, i*500);
})
}
});
});

View File

@ -30,11 +30,6 @@
</div> </div>
<div class="pull-right noprint"> <div class="pull-right noprint">
{% plugin_buttons provider %} {% plugin_buttons provider %}
{% if show_graphs %}
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ provider.name }}" data-url="{% url 'circuits-api:provider-graphs' pk=provider.pk %}" title="Show graphs">
<i class="fa fa-signal" aria-hidden="true"></i> Graphs
</button>
{% endif %}
{% if perms.circuits.add_provider %} {% if perms.circuits.add_provider %}
{% clone_button provider %} {% clone_button provider %}
{% endif %} {% endif %}
@ -138,14 +133,9 @@
{% plugin_right_page provider %} {% plugin_right_page provider %}
</div> </div>
</div> </div>
{% include 'inc/modal.html' with name='graphs' title='Graphs' %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
{% plugin_full_width_page provider %} {% plugin_full_width_page provider %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block javascript %}
<script src="{% static 'js/graphs.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %}

View File

@ -38,12 +38,6 @@
</div> </div>
<div class="pull-right noprint"> <div class="pull-right noprint">
{% plugin_buttons device %} {% plugin_buttons device %}
{% if show_graphs %}
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }}" data-url="{% url 'dcim-api:device-graphs' pk=device.pk %}" title="Show graphs">
<i class="fa fa-signal" aria-hidden="true"></i>
Graphs
</button>
{% endif %}
{% if perms.dcim.change_device %} {% if perms.dcim.change_device %}
<div class="btn-group"> <div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@ -958,7 +952,6 @@
</div> </div>
</div> </div>
</div> </div>
{% include 'inc/modal.html' with name='graphs' title='Graphs' %}
{% include 'secrets/inc/private_key_modal.html' %} {% include 'secrets/inc/private_key_modal.html' %}
{% endblock %} {% endblock %}
@ -1012,6 +1005,5 @@ $(".cable-toggle").click(function() {
}); });
</script> </script>
<script src="{% static 'js/interface_toggles.js' %}?v{{ settings.VERSION }}"></script> <script src="{% static 'js/interface_toggles.js' %}?v{{ settings.VERSION }}"></script>
<script src="{% static 'js/graphs.js' %}?v{{ settings.VERSION }}"></script>
<script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script> <script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %} {% endblock %}

View File

@ -138,13 +138,6 @@
{# Buttons #} {# Buttons #}
<td class="text-right text-nowrap noprint"> <td class="text-right text-nowrap noprint">
{% if show_interface_graphs %}
{% if iface.connected_endpoint %}
<button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface-graphs' pk=iface.pk %}" title="Show graphs">
<i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
</button>
{% endif %}
{% endif %}
{% if perms.ipam.add_ipaddress %} {% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?interface={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address"> <a href="{% url 'ipam:ipaddress_add' %}?interface={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i> <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>

View File

@ -35,12 +35,6 @@
</div> </div>
<div class="pull-right noprint"> <div class="pull-right noprint">
{% plugin_buttons site %} {% plugin_buttons site %}
{% if show_graphs %}
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ site.name }}" data-url="{% url 'dcim-api:site-graphs' pk=site.pk %}" title="Show graphs">
<i class="fa fa-signal" aria-hidden="true"></i>
Graphs
</button>
{% endif %}
{% if perms.dcim.add_site %} {% if perms.dcim.add_site %}
{% clone_button site %} {% clone_button site %}
{% endif %} {% endif %}
@ -292,14 +286,9 @@
{% plugin_right_page site %} {% plugin_right_page site %}
</div> </div>
</div> </div>
{% include 'inc/modal.html' with name='graphs' title='Graphs' %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
{% plugin_full_width_page site %} {% plugin_full_width_page site %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block javascript %}
<script src="{% static 'js/graphs.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %}

View File

@ -38,11 +38,6 @@
{# Buttons #} {# Buttons #}
<td class="text-right text-nowrap noprint"> <td class="text-right text-nowrap noprint">
{% if show_interface_graphs %}
<button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ virtualmachine.name }} - {{ iface.name }}" data-url="{% url 'virtualization-api:vminterface-graphs' pk=iface.pk %}" title="Show graphs">
<i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
</button>
{% endif %}
{% if perms.ipam.add_ipaddress %} {% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ iface.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address"> <a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ iface.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i> <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>

View File

@ -1,13 +1,8 @@
from django.db.models import Count from django.db.models import Count
from django.shortcuts import get_object_or_404
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from dcim.models import Device from dcim.models import Device
from extras.api.serializers import RenderedGraphSerializer
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
from extras.models import Graph
from utilities.api import ModelViewSet from utilities.api import ModelViewSet
from utilities.utils import get_subquery from utilities.utils import get_subquery
from virtualization import filters from virtualization import filters
@ -91,13 +86,3 @@ class VMInterfaceViewSet(ModelViewSet):
) )
serializer_class = serializers.VMInterfaceSerializer serializer_class = serializers.VMInterfaceSerializer
filterset_class = filters.VMInterfaceFilterSet filterset_class = filters.VMInterfaceFilterSet
@action(detail=True)
def graphs(self, request, pk):
"""
A convenience method for rendering graphs for a particular VM interface.
"""
vminterface = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='vminterface')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': vminterface})
return Response(serializer.data)

View File

@ -381,7 +381,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# Interfaces # Interfaces
# #
@extras_features('graphs', 'export_templates', 'webhooks') @extras_features('export_templates', 'webhooks')
class VMInterface(BaseInterface): class VMInterface(BaseInterface):
virtual_machine = models.ForeignKey( virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine', to='virtualization.VirtualMachine',

View File

@ -1,10 +1,7 @@
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from extras.models import Graph
from ipam.models import VLAN from ipam.models import VLAN
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@ -244,25 +241,3 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
'untagged_vlan': vlans[2].pk, 'untagged_vlan': vlans[2].pk,
}, },
] ]
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_vminterface_graphs(self):
"""
Test retrieval of Graphs assigned to VM interfaces.
"""
ct = ContentType.objects.get_for_model(VMInterface)
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=1'),
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=2'),
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=3'),
)
Graph.objects.bulk_create(graphs)
self.add_permissions('virtualization.view_vminterface')
url = reverse('virtualization-api:vminterface-graphs', kwargs={
'pk': VMInterface.objects.first().pk
})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1')