From 062a5bfe8d3ec3aea653d71dcfa4a715dd9dd86f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 24 Jan 2017 17:12:16 -0500 Subject: [PATCH 001/182] Initial work on API v2.0 --- netbox/circuits/api/urls.py | 12 +- netbox/circuits/api/views.py | 54 ++++----- netbox/dcim/api/urls.py | 52 ++++----- netbox/dcim/api/views.py | 193 ++++++++++--------------------- netbox/dcim/tests/test_apis.py | 16 +-- netbox/extras/api/renderers.py | 2 + netbox/extras/api/serializers.py | 10 +- netbox/extras/api/views.py | 27 +++-- netbox/ipam/api/urls.py | 36 +++--- netbox/ipam/api/views.py | 128 ++++---------------- netbox/secrets/api/urls.py | 8 +- netbox/secrets/api/views.py | 20 ++-- netbox/tenancy/api/urls.py | 8 +- netbox/tenancy/api/views.py | 36 +++--- 14 files changed, 216 insertions(+), 386 deletions(-) diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index afc034141..956b87207 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -9,17 +9,17 @@ from .views import * urlpatterns = [ # Providers - url(r'^providers/$', ProviderListView.as_view(), name='provider_list'), - url(r'^providers/(?P\d+)/$', ProviderDetailView.as_view(), name='provider_detail'), + url(r'^providers/$', ProviderViewSet.as_view({'get': 'list'}), name='provider_list'), + url(r'^providers/(?P\d+)/$', ProviderViewSet.as_view({'get': 'retrieve'}), name='provider_detail'), url(r'^providers/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_PROVIDER}, name='provider_graphs'), # Circuit types - url(r'^circuit-types/$', CircuitTypeListView.as_view(), name='circuittype_list'), - url(r'^circuit-types/(?P\d+)/$', CircuitTypeDetailView.as_view(), name='circuittype_detail'), + url(r'^circuit-types/$', CircuitTypeViewSet.as_view({'get': 'list'}), name='circuittype_list'), + url(r'^circuit-types/(?P\d+)/$', CircuitTypeViewSet.as_view({'get': 'retrieve'}), name='circuittype_detail'), # Circuits - url(r'^circuits/$', CircuitListView.as_view(), name='circuit_list'), - url(r'^circuits/(?P\d+)/$', CircuitDetailView.as_view(), name='circuit_detail'), + url(r'^circuits/$', CircuitViewSet.as_view({'get': 'list'}), name='circuit_list'), + url(r'^circuits/(?P\d+)/$', CircuitViewSet.as_view({'get': 'retrieve'}), name='circuit_detail'), ] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index d89286036..6c64da329 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,58 +1,44 @@ -from rest_framework import generics +from rest_framework.viewsets import ModelViewSet from circuits.models import Provider, CircuitType, Circuit from circuits.filters import CircuitFilter -from extras.api.views import CustomFieldModelAPIView +from extras.api.views import CustomFieldModelViewSet from . import serializers -class ProviderListView(CustomFieldModelAPIView, generics.ListAPIView): +# +# Providers +# + +class ProviderViewSet(CustomFieldModelViewSet): """ - List all providers + List and retrieve circuit providers """ - queryset = Provider.objects.prefetch_related('custom_field_values__field') + queryset = Provider.objects.all() serializer_class = serializers.ProviderSerializer -class ProviderDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single provider - """ - queryset = Provider.objects.prefetch_related('custom_field_values__field') - serializer_class = serializers.ProviderSerializer +# +# Circuit Types +# - -class CircuitTypeListView(generics.ListAPIView): +class CircuitTypeViewSet(ModelViewSet): """ - List all circuit types + List and retrieve circuit types """ queryset = CircuitType.objects.all() serializer_class = serializers.CircuitTypeSerializer -class CircuitTypeDetailView(generics.RetrieveAPIView): - """ - Retrieve a single circuit type - """ - queryset = CircuitType.objects.all() - serializer_class = serializers.CircuitTypeSerializer +# +# Circuits +# - -class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView): +class CircuitViewSet(CustomFieldModelViewSet): """ - List circuits (filterable) + List and retrieve circuits """ - queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\ - .prefetch_related('custom_field_values__field') + queryset = Circuit.objects.select_related('type', 'tenant', 'provider') serializer_class = serializers.CircuitSerializer filter_class = CircuitFilter - - -class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single circuit - """ - queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\ - .prefetch_related('custom_field_values__field') - serializer_class = serializers.CircuitSerializer diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 23787f4b4..a0ea5796b 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -9,52 +9,50 @@ from .views import * urlpatterns = [ # Sites - url(r'^sites/$', SiteListView.as_view(), name='site_list'), - url(r'^sites/(?P\d+)/$', SiteDetailView.as_view(), name='site_detail'), + url(r'^sites/$', SiteViewSet.as_view({'get': 'list'}), name='site_list'), + url(r'^sites/(?P\d+)/$', SiteViewSet.as_view({'get': 'retrieve'}), name='site_detail'), url(r'^sites/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_SITE}, name='site_graphs'), - url(r'^sites/(?P\d+)/racks/$', RackListView.as_view(), name='site_racks'), # Rack groups - url(r'^rack-groups/$', RackGroupListView.as_view(), name='rackgroup_list'), - url(r'^rack-groups/(?P\d+)/$', RackGroupDetailView.as_view(), name='rackgroup_detail'), + url(r'^rack-groups/$', RackGroupViewSet.as_view({'get': 'list'}), name='rackgroup_list'), + url(r'^rack-groups/(?P\d+)/$', RackGroupViewSet.as_view({'get': 'retrieve'}), name='rackgroup_detail'), # Rack roles - url(r'^rack-roles/$', RackRoleListView.as_view(), name='rackrole_list'), - url(r'^rack-roles/(?P\d+)/$', RackRoleDetailView.as_view(), name='rackrole_detail'), + url(r'^rack-roles/$', RackRoleViewSet.as_view({'get': 'list'}), name='rackrole_list'), + url(r'^rack-roles/(?P\d+)/$', RackRoleViewSet.as_view({'get': 'retrieve'}), name='rackrole_detail'), # Racks - url(r'^racks/$', RackListView.as_view(), name='rack_list'), - url(r'^racks/(?P\d+)/$', RackDetailView.as_view(), name='rack_detail'), + url(r'^racks/$', RackViewSet.as_view({'get': 'list'}), name='rack_list'), + url(r'^racks/(?P\d+)/$', RackViewSet.as_view({'get': 'retrieve'}), name='rack_detail'), url(r'^racks/(?P\d+)/rack-units/$', RackUnitListView.as_view(), name='rack_units'), # Manufacturers - url(r'^manufacturers/$', ManufacturerListView.as_view(), name='manufacturer_list'), - url(r'^manufacturers/(?P\d+)/$', ManufacturerDetailView.as_view(), name='manufacturer_detail'), + url(r'^manufacturers/$', ManufacturerViewSet.as_view({'get': 'list'}), name='manufacturer_list'), + url(r'^manufacturers/(?P\d+)/$', ManufacturerViewSet.as_view({'get': 'retrieve'}), name='manufacturer_detail'), # Device types - url(r'^device-types/$', DeviceTypeListView.as_view(), name='devicetype_list'), - url(r'^device-types/(?P\d+)/$', DeviceTypeDetailView.as_view(), name='devicetype_detail'), + url(r'^device-types/$', DeviceTypeViewSet.as_view({'get': 'list'}), name='devicetype_list'), + url(r'^device-types/(?P\d+)/$', DeviceTypeViewSet.as_view({'get': 'retrieve'}), name='devicetype_detail'), # Device roles - url(r'^device-roles/$', DeviceRoleListView.as_view(), name='devicerole_list'), - url(r'^device-roles/(?P\d+)/$', DeviceRoleDetailView.as_view(), name='devicerole_detail'), + url(r'^device-roles/$', DeviceRoleViewSet.as_view({'get': 'list'}), name='devicerole_list'), + url(r'^device-roles/(?P\d+)/$', DeviceRoleViewSet.as_view({'get': 'retrieve'}), name='devicerole_detail'), # Platforms - url(r'^platforms/$', PlatformListView.as_view(), name='platform_list'), - url(r'^platforms/(?P\d+)/$', PlatformDetailView.as_view(), name='platform_detail'), + url(r'^platforms/$', PlatformViewSet.as_view({'get': 'list'}), name='platform_list'), + url(r'^platforms/(?P\d+)/$', PlatformViewSet.as_view({'get': 'retrieve'}), name='platform_detail'), # Devices - url(r'^devices/$', DeviceListView.as_view(), name='device_list'), - url(r'^devices/(?P\d+)/$', DeviceDetailView.as_view(), name='device_detail'), + url(r'^devices/$', DeviceViewSet.as_view({'get': 'list'}), name='device_list'), + url(r'^devices/(?P\d+)/$', DeviceViewSet.as_view({'get': 'retrieve'}), name='device_detail'), url(r'^devices/(?P\d+)/lldp-neighbors/$', LLDPNeighborsView.as_view(), name='device_lldp-neighbors'), - url(r'^devices/(?P\d+)/console-ports/$', ConsolePortListView.as_view(), name='device_consoleports'), - url(r'^devices/(?P\d+)/console-server-ports/$', ConsoleServerPortListView.as_view(), - name='device_consoleserverports'), - url(r'^devices/(?P\d+)/power-ports/$', PowerPortListView.as_view(), name='device_powerports'), - url(r'^devices/(?P\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'), - url(r'^devices/(?P\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'), - url(r'^devices/(?P\d+)/device-bays/$', DeviceBayListView.as_view(), name='device_devicebays'), - url(r'^devices/(?P\d+)/modules/$', ModuleListView.as_view(), name='device_modules'), + url(r'^devices/(?P\d+)/console-ports/$', ConsolePortViewSet.as_view({'get': 'list'}), name='device_consoleports'), + url(r'^devices/(?P\d+)/console-server-ports/$', ConsoleServerPortViewSet.as_view({'get': 'list'}), name='device_consoleserverports'), + url(r'^devices/(?P\d+)/power-ports/$', PowerPortViewSet.as_view({'get': 'list'}), name='device_powerports'), + url(r'^devices/(?P\d+)/power-outlets/$', PowerOutletViewSet.as_view({'get': 'list'}), name='device_poweroutlets'), + url(r'^devices/(?P\d+)/interfaces/$', InterfaceViewSet.as_view({'get': 'list'}), name='device_interfaces'), + url(r'^devices/(?P\d+)/device-bays/$', DeviceBayViewSet.as_view({'get': 'list'}), name='device_devicebays'), + url(r'^devices/(?P\d+)/modules/$', ModuleViewSet.as_view({'get': 'list'}), name='device_modules'), # Console ports url(r'^console-ports/(?P\d+)/$', ConsolePortView.as_view(), name='consoleport'), diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 70ca17bbc..565593e9c 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -3,10 +3,10 @@ from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework.views import APIView +from rest_framework.viewsets import ModelViewSet from django.conf import settings from django.contrib.contenttypes.models import ContentType -from django.db.models import Count from django.http import Http404 from django.shortcuts import get_object_or_404 @@ -15,7 +15,7 @@ from dcim.models import ( InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, ) from dcim import filters -from extras.api.views import CustomFieldModelAPIView +from extras.api.views import CustomFieldModelViewSet from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer from utilities.api import ServiceUnavailable from .exceptions import MissingFilterException @@ -26,19 +26,11 @@ from . import serializers # Sites # -class SiteListView(CustomFieldModelAPIView, generics.ListAPIView): +class SiteViewSet(CustomFieldModelViewSet): """ - List all sites + List and retrieve sites """ - queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field') - serializer_class = serializers.SiteSerializer - - -class SiteDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single site - """ - queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field') + queryset = Site.objects.select_related('tenant') serializer_class = serializers.SiteSerializer @@ -46,38 +38,22 @@ class SiteDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): # Rack groups # -class RackGroupListView(generics.ListAPIView): +class RackGroupViewSet(ModelViewSet): """ - List all rack groups + List and retrieve rack groups """ queryset = RackGroup.objects.select_related('site') serializer_class = serializers.RackGroupSerializer filter_class = filters.RackGroupFilter -class RackGroupDetailView(generics.RetrieveAPIView): - """ - Retrieve a single rack group - """ - queryset = RackGroup.objects.select_related('site') - serializer_class = serializers.RackGroupSerializer - - # # Rack roles # -class RackRoleListView(generics.ListAPIView): +class RackRoleViewSet(ModelViewSet): """ - List all rack roles - """ - queryset = RackRole.objects.all() - serializer_class = serializers.RackRoleSerializer - - -class RackRoleDetailView(generics.RetrieveAPIView): - """ - Retrieve a single rack role + List and retrieve rack roles """ queryset = RackRole.objects.all() serializer_class = serializers.RackRoleSerializer @@ -87,28 +63,18 @@ class RackRoleDetailView(generics.RetrieveAPIView): # Racks # -class RackListView(CustomFieldModelAPIView, generics.ListAPIView): +class RackViewSet(CustomFieldModelViewSet): """ - List racks (filterable) + List and retrieve racks """ - queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\ - .prefetch_related('custom_field_values__field') - serializer_class = serializers.RackSerializer + queryset = Rack.objects.select_related('site', 'group__site', 'tenant') filter_class = filters.RackFilter + def get_serializer_class(self): + if self.action == 'retrieve': + return serializers.RackDetailSerializer + return serializers.RackSerializer -class RackDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single rack - """ - queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\ - .prefetch_related('custom_field_values__field') - serializer_class = serializers.RackDetailSerializer - - -# -# Rack units -# class RackUnitListView(APIView): """ @@ -139,17 +105,9 @@ class RackUnitListView(APIView): # Manufacturers # -class ManufacturerListView(generics.ListAPIView): +class ManufacturerViewSet(ModelViewSet): """ - List all hardware manufacturers - """ - queryset = Manufacturer.objects.all() - serializer_class = serializers.ManufacturerSerializer - - -class ManufacturerDetailView(generics.RetrieveAPIView): - """ - Retrieve a single hardware manufacturers + List and retrieve manufacturers """ queryset = Manufacturer.objects.all() serializer_class = serializers.ManufacturerSerializer @@ -159,38 +117,26 @@ class ManufacturerDetailView(generics.RetrieveAPIView): # Device Types # -class DeviceTypeListView(CustomFieldModelAPIView, generics.ListAPIView): +class DeviceTypeViewSet(CustomFieldModelViewSet): """ - List device types (filterable) + List and retrieve device types """ - queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field') - serializer_class = serializers.DeviceTypeSerializer + queryset = DeviceType.objects.select_related('manufacturer') filter_class = filters.DeviceTypeFilter - -class DeviceTypeDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single device type - """ - queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field') - serializer_class = serializers.DeviceTypeDetailSerializer + def get_serializer_class(self): + if self.action == 'retrieve': + return serializers.DeviceTypeDetailSerializer + return serializers.DeviceTypeSerializer # # Device roles # -class DeviceRoleListView(generics.ListAPIView): +class DeviceRoleViewSet(ModelViewSet): """ - List all device roles - """ - queryset = DeviceRole.objects.all() - serializer_class = serializers.DeviceRoleSerializer - - -class DeviceRoleDetailView(generics.RetrieveAPIView): - """ - Retrieve a single device role + List and retrieve device roles """ queryset = DeviceRole.objects.all() serializer_class = serializers.DeviceRoleSerializer @@ -200,17 +146,9 @@ class DeviceRoleDetailView(generics.RetrieveAPIView): # Platforms # -class PlatformListView(generics.ListAPIView): +class PlatformViewSet(ModelViewSet): """ - List all platforms - """ - queryset = Platform.objects.all() - serializer_class = serializers.PlatformSerializer - - -class PlatformDetailView(generics.RetrieveAPIView): - """ - Retrieve a single platform + List and retrieve platforms """ queryset = Platform.objects.all() serializer_class = serializers.PlatformSerializer @@ -220,40 +158,31 @@ class PlatformDetailView(generics.RetrieveAPIView): # Devices # -class DeviceListView(CustomFieldModelAPIView, generics.ListAPIView): +class DeviceViewSet(CustomFieldModelViewSet): """ - List devices (filterable) + List and retrieve devices """ - queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform', - 'rack__site', 'parent_bay').prefetch_related('primary_ip4__nat_outside', - 'primary_ip6__nat_outside', - 'custom_field_values__field') + queryset = Device.objects.select_related( + 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'rack__site', 'parent_bay', + ).prefetch_related( + 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', + ) serializer_class = serializers.DeviceSerializer filter_class = filters.DeviceFilter renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer] -class DeviceDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single device - """ - queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform', - 'rack__site', 'parent_bay').prefetch_related('custom_field_values__field') - serializer_class = serializers.DeviceSerializer - - # # Console ports # -class ConsolePortListView(generics.ListAPIView): +class ConsolePortViewSet(ModelViewSet): """ - List console ports (by device) + List and retrieve console ports (by device) """ serializer_class = serializers.ConsolePortSerializer def get_queryset(self): - device = get_object_or_404(Device, pk=self.kwargs['pk']) return ConsolePort.objects.filter(device=device).select_related('cs_port') @@ -268,14 +197,13 @@ class ConsolePortView(generics.RetrieveUpdateDestroyAPIView): # Console server ports # -class ConsoleServerPortListView(generics.ListAPIView): +class ConsoleServerPortViewSet(ModelViewSet): """ - List console server ports (by device) + List and retrieve console server ports (by device) """ serializer_class = serializers.ConsoleServerPortSerializer def get_queryset(self): - device = get_object_or_404(Device, pk=self.kwargs['pk']) return ConsoleServerPort.objects.filter(device=device).select_related('connected_console') @@ -284,14 +212,13 @@ class ConsoleServerPortListView(generics.ListAPIView): # Power ports # -class PowerPortListView(generics.ListAPIView): +class PowerPortViewSet(ModelViewSet): """ - List power ports (by device) + List and retrieve power ports (by device) """ serializer_class = serializers.PowerPortSerializer def get_queryset(self): - device = get_object_or_404(Device, pk=self.kwargs['pk']) return PowerPort.objects.filter(device=device).select_related('power_outlet') @@ -306,14 +233,13 @@ class PowerPortView(generics.RetrieveUpdateDestroyAPIView): # Power outlets # -class PowerOutletListView(generics.ListAPIView): +class PowerOutletViewSet(ModelViewSet): """ - List power outlets (by device) + List and retrieve power outlets (by device) """ serializer_class = serializers.PowerOutletSerializer def get_queryset(self): - device = get_object_or_404(Device, pk=self.kwargs['pk']) return PowerOutlet.objects.filter(device=device).select_related('connected_port') @@ -322,9 +248,9 @@ class PowerOutletListView(generics.ListAPIView): # Interfaces # -class InterfaceListView(generics.ListAPIView): +class InterfaceViewSet(ModelViewSet): """ - List interfaces (by device) + List and retrieve interfaces (by device) """ serializer_class = serializers.InterfaceSerializer filter_class = filters.InterfaceFilter @@ -372,14 +298,13 @@ class InterfaceConnectionListView(generics.ListAPIView): # Device bays # -class DeviceBayListView(generics.ListAPIView): +class DeviceBayViewSet(ModelViewSet): """ - List device bays (by device) + List and retrieve device bays (by device) """ serializer_class = serializers.DeviceBayNestedSerializer def get_queryset(self): - device = get_object_or_404(Device, pk=self.kwargs['pk']) return DeviceBay.objects.filter(device=device).select_related('installed_device') @@ -388,14 +313,13 @@ class DeviceBayListView(generics.ListAPIView): # Modules # -class ModuleListView(generics.ListAPIView): +class ModuleViewSet(ModelViewSet): """ - List device modules (by device) + List and retrieve modules (by device) """ serializer_class = serializers.ModuleSerializer def get_queryset(self): - device = get_object_or_404(Device, pk=self.kwargs['pk']) return Module.objects.filter(device=device).select_related('device', 'manufacturer') @@ -442,8 +366,19 @@ class RelatedConnectionsView(APIView): super(RelatedConnectionsView, self).__init__() # Custom fields - self.content_type = ContentType.objects.get_for_model(Device) - self.custom_fields = self.content_type.custom_fields.prefetch_related('choices') + content_type = ContentType.objects.get_for_model(Device) + custom_fields = content_type.custom_fields.prefetch_related('choices') + + # Cache all relevant CustomFieldChoices. This saves us from having to do a lookup per select field per object. + custom_field_choices = {} + for field in custom_fields: + for cfc in field.choices.all(): + custom_field_choices[cfc.id] = cfc.value + + self.context = { + 'custom_fields': custom_fields, + 'custom_field_choices': custom_field_choices, + } def get(self, request): @@ -469,7 +404,7 @@ class RelatedConnectionsView(APIView): # Initialize response skeleton response = { - 'device': serializers.DeviceSerializer(device, context={'view': self}).data, + 'device': serializers.DeviceSerializer(device, context=self.context).data, 'console-ports': [], 'power-ports': [], 'interfaces': [], diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index 0739b86ce..7545a80ba 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/netbox/dcim/tests/test_apis.py @@ -82,21 +82,6 @@ class SiteTest(APITestCase): sorted(self.standard_fields), ) - def test_get_site_list_rack(self, endpoint='/{}api/dcim/sites/1/racks/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for i in json.loads(response.content.decode('utf-8')): - self.assertEqual( - sorted(i.keys()), - sorted(self.rack_fields), - ) - # Check Nested Serializer. - self.assertEqual( - sorted(i.get('site').keys()), - sorted(self.nested_fields), - ) - def test_get_site_list_graphs(self, endpoint='/{}api/dcim/sites/1/graphs/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content.decode('utf-8')) @@ -239,6 +224,7 @@ class DeviceTypeTest(APITestCase): 'subdevice_role', 'comments', 'custom_fields', + 'instance_count', ] nested_fields = [ diff --git a/netbox/extras/api/renderers.py b/netbox/extras/api/renderers.py index 0fd35c762..2e85ed3a8 100644 --- a/netbox/extras/api/renderers.py +++ b/netbox/extras/api/renderers.py @@ -27,6 +27,8 @@ class BINDZoneRenderer(renderers.BaseRenderer): def render(self, data, media_type=None, renderer_context=None): records = [] + if not isinstance(data, (list, tuple)): + data = (data,) for record in data: if record.get('name') and record.get('primary_ip'): try: diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 4e82b4027..01d348b0a 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -12,22 +12,20 @@ class CustomFieldSerializer(serializers.Serializer): def get_custom_fields(self, obj): # Gather all CustomFields applicable to this object - fields = {cf.name: None for cf in self.context['view'].custom_fields} + fields = {cf.name: None for cf in self.context['custom_fields']} + custom_field_choices = self.context['custom_field_choices'] # Attach any defined CustomFieldValues to their respective CustomFields for cfv in obj.custom_field_values.all(): # Attempt to suppress database lookups for CustomFieldChoices by using the cached choice set from the view # context. - if cfv.field.type == CF_TYPE_SELECT and hasattr(self, 'custom_field_choices'): + if cfv.field.type == CF_TYPE_SELECT: cfc = { 'id': int(cfv.serialized_value), - 'value': self.context['view'].custom_field_choices[int(cfv.serialized_value)] + 'value': custom_field_choices[int(cfv.serialized_value)] } fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfc).data - # Fall back to hitting the database in case we're in a view that doesn't inherit CustomFieldModelAPIView. - elif cfv.field.type == CF_TYPE_SELECT: - fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfv.value).data else: fields[cfv.field.name] = cfv.value diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 19d7fab5f..1ee82ace4 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,6 +1,7 @@ import graphviz from rest_framework import generics from rest_framework.views import APIView +from rest_framework.viewsets import ModelViewSet from django.contrib.contenttypes.models import ContentType from django.db.models import Q @@ -14,22 +15,32 @@ from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_P from .serializers import GraphSerializer -class CustomFieldModelAPIView(object): +class CustomFieldModelViewSet(ModelViewSet): """ - Include the applicable set of CustomField in the view context. + Include the applicable set of CustomField in the ModelViewSet context. """ - def __init__(self): - super(CustomFieldModelAPIView, self).__init__() - self.content_type = ContentType.objects.get_for_model(self.queryset.model) - self.custom_fields = self.content_type.custom_fields.prefetch_related('choices') + def get_serializer_context(self): + + # Gather all custom fields for the model + content_type = ContentType.objects.get_for_model(self.queryset.model) + custom_fields = content_type.custom_fields.prefetch_related('choices') # Cache all relevant CustomFieldChoices. This saves us from having to do a lookup per select field per object. custom_field_choices = {} - for field in self.custom_fields: + for field in custom_fields: for cfc in field.choices.all(): custom_field_choices[cfc.id] = cfc.value - self.custom_field_choices = custom_field_choices + custom_field_choices = custom_field_choices + + return { + 'custom_fields': custom_fields, + 'custom_field_choices': custom_field_choices, + } + + def get_queryset(self): + # Prefetch custom field values + return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field') class GraphListView(generics.ListAPIView): diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 598545ddf..19aef2798 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -6,39 +6,39 @@ from .views import * urlpatterns = [ # VRFs - url(r'^vrfs/$', VRFListView.as_view(), name='vrf_list'), - url(r'^vrfs/(?P\d+)/$', VRFDetailView.as_view(), name='vrf_detail'), + url(r'^vrfs/$', VRFViewSet.as_view({'get': 'list'}), name='vrf_list'), + url(r'^vrfs/(?P\d+)/$', VRFViewSet.as_view({'get': 'retrieve'}), name='vrf_detail'), # Roles - url(r'^roles/$', RoleListView.as_view(), name='role_list'), - url(r'^roles/(?P\d+)/$', RoleDetailView.as_view(), name='role_detail'), + url(r'^roles/$', RoleViewSet.as_view({'get': 'list'}), name='role_list'), + url(r'^roles/(?P\d+)/$', RoleViewSet.as_view({'get': 'retrieve'}), name='role_detail'), # RIRs - url(r'^rirs/$', RIRListView.as_view(), name='rir_list'), - url(r'^rirs/(?P\d+)/$', RIRDetailView.as_view(), name='rir_detail'), + url(r'^rirs/$', RIRViewSet.as_view({'get': 'list'}), name='rir_list'), + url(r'^rirs/(?P\d+)/$', RIRViewSet.as_view({'get': 'retrieve'}), name='rir_detail'), # Aggregates - url(r'^aggregates/$', AggregateListView.as_view(), name='aggregate_list'), - url(r'^aggregates/(?P\d+)/$', AggregateDetailView.as_view(), name='aggregate_detail'), + url(r'^aggregates/$', AggregateViewSet.as_view({'get': 'list'}), name='aggregate_list'), + url(r'^aggregates/(?P\d+)/$', AggregateViewSet.as_view({'get': 'retrieve'}), name='aggregate_detail'), # Prefixes - url(r'^prefixes/$', PrefixListView.as_view(), name='prefix_list'), - url(r'^prefixes/(?P\d+)/$', PrefixDetailView.as_view(), name='prefix_detail'), + url(r'^prefixes/$', PrefixViewSet.as_view({'get': 'list'}), name='prefix_list'), + url(r'^prefixes/(?P\d+)/$', PrefixViewSet.as_view({'get': 'retrieve'}), name='prefix_detail'), # IP addresses - url(r'^ip-addresses/$', IPAddressListView.as_view(), name='ipaddress_list'), - url(r'^ip-addresses/(?P\d+)/$', IPAddressDetailView.as_view(), name='ipaddress_detail'), + url(r'^ip-addresses/$', IPAddressViewSet.as_view({'get': 'list'}), name='ipaddress_list'), + url(r'^ip-addresses/(?P\d+)/$', IPAddressViewSet.as_view({'get': 'retrieve'}), name='ipaddress_detail'), # VLAN groups - url(r'^vlan-groups/$', VLANGroupListView.as_view(), name='vlangroup_list'), - url(r'^vlan-groups/(?P\d+)/$', VLANGroupDetailView.as_view(), name='vlangroup_detail'), + url(r'^vlan-groups/$', VLANGroupViewSet.as_view({'get': 'list'}), name='vlangroup_list'), + url(r'^vlan-groups/(?P\d+)/$', VLANGroupViewSet.as_view({'get': 'retrieve'}), name='vlangroup_detail'), # VLANs - url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'), - url(r'^vlans/(?P\d+)/$', VLANDetailView.as_view(), name='vlan_detail'), + url(r'^vlans/$', VLANViewSet.as_view({'get': 'list'}), name='vlan_list'), + url(r'^vlans/(?P\d+)/$', VLANViewSet.as_view({'get': 'retrieve'}), name='vlan_detail'), # Services - url(r'^services/$', ServiceListView.as_view(), name='service_list'), - url(r'^services/(?P\d+)/$', ServiceDetailView.as_view(), name='service_detail'), + url(r'^services/$', ServiceViewSet.as_view({'get': 'list'}), name='service_list'), + url(r'^services/(?P\d+)/$', ServiceViewSet.as_view({'get': 'retrieve'}), name='service_detail'), ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 10b9c46e4..87200fe3a 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,9 +1,9 @@ -from rest_framework import generics +from rest_framework.viewsets import ModelViewSet from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam import filters -from extras.api.views import CustomFieldModelAPIView +from extras.api.views import CustomFieldModelViewSet from . import serializers @@ -11,38 +11,22 @@ from . import serializers # VRFs # -class VRFListView(CustomFieldModelAPIView, generics.ListAPIView): +class VRFViewSet(CustomFieldModelViewSet): """ - List all VRFs + List and retrieve VRFs """ - queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values__field') + queryset = VRF.objects.select_related('tenant') serializer_class = serializers.VRFSerializer filter_class = filters.VRFFilter -class VRFDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single VRF - """ - queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values__field') - serializer_class = serializers.VRFSerializer - - # # Roles # -class RoleListView(generics.ListAPIView): +class RoleViewSet(ModelViewSet): """ - List all roles - """ - queryset = Role.objects.all() - serializer_class = serializers.RoleSerializer - - -class RoleDetailView(generics.RetrieveAPIView): - """ - Retrieve a single role + List and retrieve prefix/VLAN roles """ queryset = Role.objects.all() serializer_class = serializers.RoleSerializer @@ -52,17 +36,9 @@ class RoleDetailView(generics.RetrieveAPIView): # RIRs # -class RIRListView(generics.ListAPIView): +class RIRViewSet(ModelViewSet): """ - List all RIRs - """ - queryset = RIR.objects.all() - serializer_class = serializers.RIRSerializer - - -class RIRDetailView(generics.RetrieveAPIView): - """ - Retrieve a single RIR + List and retrieve RIRs """ queryset = RIR.objects.all() serializer_class = serializers.RIRSerializer @@ -72,129 +48,75 @@ class RIRDetailView(generics.RetrieveAPIView): # Aggregates # -class AggregateListView(CustomFieldModelAPIView, generics.ListAPIView): +class AggregateViewSet(CustomFieldModelViewSet): """ - List aggregates (filterable) + List and retrieve aggregates """ - queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values__field') + queryset = Aggregate.objects.select_related('rir') serializer_class = serializers.AggregateSerializer filter_class = filters.AggregateFilter -class AggregateDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single aggregate - """ - queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values__field') - serializer_class = serializers.AggregateSerializer - - # # Prefixes # -class PrefixListView(CustomFieldModelAPIView, generics.ListAPIView): +class PrefixViewSet(CustomFieldModelViewSet): """ - List prefixes (filterable) + List and retrieve prefixes """ - queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\ - .prefetch_related('custom_field_values__field') + queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') serializer_class = serializers.PrefixSerializer filter_class = filters.PrefixFilter -class PrefixDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single prefix - """ - queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\ - .prefetch_related('custom_field_values__field') - serializer_class = serializers.PrefixSerializer - - # # IP addresses # -class IPAddressListView(CustomFieldModelAPIView, generics.ListAPIView): +class IPAddressViewSet(CustomFieldModelViewSet): """ - List IP addresses (filterable) + List and retrieve IP addresses """ - queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\ - .prefetch_related('nat_outside', 'custom_field_values__field') + queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside') serializer_class = serializers.IPAddressSerializer filter_class = filters.IPAddressFilter -class IPAddressDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single IP address - """ - queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\ - .prefetch_related('nat_outside', 'custom_field_values__field') - serializer_class = serializers.IPAddressSerializer - - # # VLAN groups # -class VLANGroupListView(generics.ListAPIView): +class VLANGroupViewSet(ModelViewSet): """ - List all VLAN groups + List and retrieve VLAN groups """ queryset = VLANGroup.objects.select_related('site') serializer_class = serializers.VLANGroupSerializer filter_class = filters.VLANGroupFilter -class VLANGroupDetailView(generics.RetrieveAPIView): - """ - Retrieve a single VLAN group - """ - queryset = VLANGroup.objects.select_related('site') - serializer_class = serializers.VLANGroupSerializer - - # # VLANs # -class VLANListView(CustomFieldModelAPIView, generics.ListAPIView): +class VLANViewSet(CustomFieldModelViewSet): """ - List VLANs (filterable) + List and retrieve VLANs """ - queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\ - .prefetch_related('custom_field_values__field') + queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') serializer_class = serializers.VLANSerializer filter_class = filters.VLANFilter -class VLANDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single VLAN - """ - queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\ - .prefetch_related('custom_field_values__field') - serializer_class = serializers.VLANSerializer - - # # Services # -class ServiceListView(generics.ListAPIView): +class ServiceViewSet(ModelViewSet): """ - List services (filterable) + List and retrieve services """ queryset = Service.objects.select_related('device').prefetch_related('ipaddresses') serializer_class = serializers.ServiceSerializer filter_class = filters.ServiceFilter - - -class ServiceDetailView(generics.RetrieveAPIView): - """ - Retrieve a single service - """ - queryset = Service.objects.select_related('device').prefetch_related('ipaddresses') - serializer_class = serializers.ServiceSerializer diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index 6acae580d..2ad2abc1e 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -5,14 +5,14 @@ from .views import * urlpatterns = [ + # Secret roles + url(r'^secret-roles/$', SecretRoleViewSet.as_view({'get': 'list'}), name='secretrole_list'), + url(r'^secret-roles/(?P\d+)/$', SecretRoleViewSet.as_view({'get': 'retrieve'}), name='secretrole_detail'), + # Secrets url(r'^secrets/$', SecretListView.as_view(), name='secret_list'), url(r'^secrets/(?P\d+)/$', SecretDetailView.as_view(), name='secret_detail'), - # Secret roles - url(r'^secret-roles/$', SecretRoleListView.as_view(), name='secretrole_list'), - url(r'^secret-roles/(?P\d+)/$', SecretRoleDetailView.as_view(), name='secretrole_detail'), - # Miscellaneous url(r'^generate-keys/$', RSAKeyGeneratorView.as_view(), name='generate_keys'), diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 672165da3..10e95d51b 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -9,6 +9,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from rest_framework.views import APIView +from rest_framework.viewsets import ModelViewSet from extras.api.renderers import FormlessBrowsableAPIRenderer, FreeRADIUSClientsRenderer from secrets.filters import SecretFilter @@ -22,23 +23,22 @@ ERR_USERKEY_INACTIVE = "UserKey has not been activated for decryption." ERR_PRIVKEY_INVALID = "Invalid private key." -class SecretRoleListView(generics.ListAPIView): +# +# Secret Roles +# + +class SecretRoleViewSet(ModelViewSet): """ - List all secret roles + List and retrieve secret roles """ queryset = SecretRole.objects.all() serializer_class = serializers.SecretRoleSerializer permission_classes = [IsAuthenticated] -class SecretRoleDetailView(generics.RetrieveAPIView): - """ - Retrieve a single secret role - """ - queryset = SecretRole.objects.all() - serializer_class = serializers.SecretRoleSerializer - permission_classes = [IsAuthenticated] - +# +# Secrets +# class SecretListView(generics.GenericAPIView): """ diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index af1d1d6aa..457327855 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -6,11 +6,11 @@ from .views import * urlpatterns = [ # Tenant groups - url(r'^tenant-groups/$', TenantGroupListView.as_view(), name='tenantgroup_list'), - url(r'^tenant-groups/(?P\d+)/$', TenantGroupDetailView.as_view(), name='tenantgroup_detail'), + url(r'^tenant-groups/$', TenantGroupViewSet.as_view({'get': 'list'}), name='tenantgroup_list'), + url(r'^tenant-groups/(?P\d+)/$', TenantGroupViewSet.as_view({'get': 'retrieve'}), name='tenantgroup_detail'), # Tenants - url(r'^tenants/$', TenantListView.as_view(), name='tenant_list'), - url(r'^tenants/(?P\d+)/$', TenantDetailView.as_view(), name='tenant_detail'), + url(r'^tenants/$', TenantViewSet.as_view({'get': 'list'}), name='tenant_list'), + url(r'^tenants/(?P\d+)/$', TenantViewSet.as_view({'get': 'retrieve'}), name='tenant_detail'), ] diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index ce08eb058..6cfeed2ef 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -1,40 +1,32 @@ -from rest_framework import generics +from rest_framework.viewsets import ModelViewSet from tenancy.models import Tenant, TenantGroup from tenancy.filters import TenantFilter -from extras.api.views import CustomFieldModelAPIView +from extras.api.views import CustomFieldModelViewSet from . import serializers -class TenantGroupListView(generics.ListAPIView): +# +# Tenant Groups +# + +class TenantGroupViewSet(ModelViewSet): """ - List all tenant groups + List and retrieve tenant groups """ queryset = TenantGroup.objects.all() serializer_class = serializers.TenantGroupSerializer -class TenantGroupDetailView(generics.RetrieveAPIView): - """ - Retrieve a single circuit type - """ - queryset = TenantGroup.objects.all() - serializer_class = serializers.TenantGroupSerializer +# +# Tenants +# - -class TenantListView(CustomFieldModelAPIView, generics.ListAPIView): +class TenantViewSet(CustomFieldModelViewSet): """ - List tenants (filterable) + List and retrieve tenants """ - queryset = Tenant.objects.select_related('group').prefetch_related('custom_field_values__field') + queryset = Tenant.objects.select_related('group') serializer_class = serializers.TenantSerializer filter_class = TenantFilter - - -class TenantDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single tenant - """ - queryset = Tenant.objects.select_related('group').prefetch_related('custom_field_values__field') - serializer_class = serializers.TenantSerializer From 791a641eef20cf129ebd6ac80b04f6dc0b2853e0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 26 Jan 2017 15:33:41 -0500 Subject: [PATCH 002/182] Created CircuitDetailSerializer --- netbox/circuits/api/serializers.py | 18 +++++++++++++++--- netbox/circuits/api/views.py | 6 +++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 947aa9860..ba1ba80a9 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -42,7 +42,7 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer): # -# Circuits +# Circuit Terminations # class CircuitTerminationSerializer(serializers.ModelSerializer): @@ -54,19 +54,31 @@ class CircuitTerminationSerializer(serializers.ModelSerializer): fields = ['id', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info'] +# +# Circuits +# + + class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer): provider = ProviderNestedSerializer() type = CircuitTypeNestedSerializer() tenant = TenantNestedSerializer() - terminations = CircuitTerminationSerializer(many=True) class Meta: model = Circuit fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', - 'terminations', 'custom_fields'] + 'custom_fields'] class CircuitNestedSerializer(CircuitSerializer): class Meta(CircuitSerializer.Meta): fields = ['id', 'cid'] + + +class CircuitDetailSerializer(CircuitSerializer): + terminations = CircuitTerminationSerializer(many=True) + + class Meta(CircuitSerializer.Meta): + fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', + 'terminations', 'custom_fields'] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 6c64da329..ec1f1c8ac 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -40,5 +40,9 @@ class CircuitViewSet(CustomFieldModelViewSet): List and retrieve circuits """ queryset = Circuit.objects.select_related('type', 'tenant', 'provider') - serializer_class = serializers.CircuitSerializer filter_class = CircuitFilter + + def get_serializer_class(self): + if self.action == 'retrieve': + return serializers.CircuitDetailSerializer + return serializers.CircuitSerializer From 0f9fe8648e0b0e73e42ac9ea855793a936f2b732 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 26 Jan 2017 15:34:07 -0500 Subject: [PATCH 003/182] Converted static URL definitions to routers --- netbox/circuits/api/urls.py | 23 +++++++------- netbox/dcim/api/urls.py | 60 +++++++++++++++++-------------------- netbox/ipam/api/urls.py | 56 ++++++++++++---------------------- netbox/secrets/api/urls.py | 21 +++++++++---- netbox/tenancy/api/urls.py | 18 +++++------ 5 files changed, 83 insertions(+), 95 deletions(-) diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 956b87207..58c370449 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,25 +1,24 @@ -from django.conf.urls import url +from django.conf.urls import include, url + +from rest_framework import routers from extras.models import GRAPH_TYPE_PROVIDER from extras.api.views import GraphListView -from .views import * +from .views import CircuitViewSet, CircuitTypeViewSet, ProviderViewSet +router = routers.DefaultRouter() +router.register(r'providers', ProviderViewSet) +router.register(r'circuit-types', CircuitTypeViewSet) +router.register(r'circuits', CircuitViewSet) + urlpatterns = [ + url(r'', include(router.urls)), + # Providers - url(r'^providers/$', ProviderViewSet.as_view({'get': 'list'}), name='provider_list'), - url(r'^providers/(?P\d+)/$', ProviderViewSet.as_view({'get': 'retrieve'}), name='provider_detail'), url(r'^providers/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_PROVIDER}, name='provider_graphs'), - # Circuit types - url(r'^circuit-types/$', CircuitTypeViewSet.as_view({'get': 'list'}), name='circuittype_list'), - url(r'^circuit-types/(?P\d+)/$', CircuitTypeViewSet.as_view({'get': 'retrieve'}), name='circuittype_detail'), - - # Circuits - url(r'^circuits/$', CircuitViewSet.as_view({'get': 'list'}), name='circuit_list'), - url(r'^circuits/(?P\d+)/$', CircuitViewSet.as_view({'get': 'retrieve'}), name='circuit_detail'), - ] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index a0ea5796b..d31d901c6 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -1,50 +1,45 @@ -from django.conf.urls import url +from django.conf.urls import include, url + +from rest_framework import routers from extras.models import GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from extras.api.views import GraphListView, TopologyMapView -from .views import * +from .views import ( + # Viewsets + ConsolePortViewSet, ConsoleServerPortViewSet, DeviceViewSet, DeviceBayViewSet, DeviceRoleViewSet, DeviceTypeViewSet, + InterfaceViewSet, ManufacturerViewSet, ModuleViewSet, PlatformViewSet, PowerPortViewSet, PowerOutletViewSet, + RackViewSet, RackGroupViewSet, RackRoleViewSet, SiteViewSet, + + # Legacy views + ConsolePortView, InterfaceConnectionView, InterfaceConnectionListView, InterfaceDetailView, PowerPortView, + LLDPNeighborsView, RackUnitListView, RelatedConnectionsView, +) + + +router = routers.DefaultRouter() +router.register(r'sites', SiteViewSet) +router.register(r'rack-groups', RackGroupViewSet) +router.register(r'rack-roles', RackRoleViewSet) +router.register(r'racks', RackViewSet) +router.register(r'manufacturers', ManufacturerViewSet) +router.register(r'device-types', DeviceTypeViewSet) +router.register(r'device-roles', DeviceRoleViewSet) +router.register(r'platforms', PlatformViewSet) +router.register(r'devices', DeviceViewSet) urlpatterns = [ + url(r'', include(router.urls)), + # Sites - url(r'^sites/$', SiteViewSet.as_view({'get': 'list'}), name='site_list'), - url(r'^sites/(?P\d+)/$', SiteViewSet.as_view({'get': 'retrieve'}), name='site_detail'), url(r'^sites/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_SITE}, name='site_graphs'), - # Rack groups - url(r'^rack-groups/$', RackGroupViewSet.as_view({'get': 'list'}), name='rackgroup_list'), - url(r'^rack-groups/(?P\d+)/$', RackGroupViewSet.as_view({'get': 'retrieve'}), name='rackgroup_detail'), - - # Rack roles - url(r'^rack-roles/$', RackRoleViewSet.as_view({'get': 'list'}), name='rackrole_list'), - url(r'^rack-roles/(?P\d+)/$', RackRoleViewSet.as_view({'get': 'retrieve'}), name='rackrole_detail'), - # Racks - url(r'^racks/$', RackViewSet.as_view({'get': 'list'}), name='rack_list'), - url(r'^racks/(?P\d+)/$', RackViewSet.as_view({'get': 'retrieve'}), name='rack_detail'), url(r'^racks/(?P\d+)/rack-units/$', RackUnitListView.as_view(), name='rack_units'), - # Manufacturers - url(r'^manufacturers/$', ManufacturerViewSet.as_view({'get': 'list'}), name='manufacturer_list'), - url(r'^manufacturers/(?P\d+)/$', ManufacturerViewSet.as_view({'get': 'retrieve'}), name='manufacturer_detail'), - - # Device types - url(r'^device-types/$', DeviceTypeViewSet.as_view({'get': 'list'}), name='devicetype_list'), - url(r'^device-types/(?P\d+)/$', DeviceTypeViewSet.as_view({'get': 'retrieve'}), name='devicetype_detail'), - - # Device roles - url(r'^device-roles/$', DeviceRoleViewSet.as_view({'get': 'list'}), name='devicerole_list'), - url(r'^device-roles/(?P\d+)/$', DeviceRoleViewSet.as_view({'get': 'retrieve'}), name='devicerole_detail'), - - # Platforms - url(r'^platforms/$', PlatformViewSet.as_view({'get': 'list'}), name='platform_list'), - url(r'^platforms/(?P\d+)/$', PlatformViewSet.as_view({'get': 'retrieve'}), name='platform_detail'), - # Devices - url(r'^devices/$', DeviceViewSet.as_view({'get': 'list'}), name='device_list'), - url(r'^devices/(?P\d+)/$', DeviceViewSet.as_view({'get': 'retrieve'}), name='device_detail'), url(r'^devices/(?P\d+)/lldp-neighbors/$', LLDPNeighborsView.as_view(), name='device_lldp-neighbors'), url(r'^devices/(?P\d+)/console-ports/$', ConsolePortViewSet.as_view({'get': 'list'}), name='device_consoleports'), url(r'^devices/(?P\d+)/console-server-ports/$', ConsoleServerPortViewSet.as_view({'get': 'list'}), name='device_consoleserverports'), @@ -53,6 +48,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/interfaces/$', InterfaceViewSet.as_view({'get': 'list'}), name='device_interfaces'), url(r'^devices/(?P\d+)/device-bays/$', DeviceBayViewSet.as_view({'get': 'list'}), name='device_devicebays'), url(r'^devices/(?P\d+)/modules/$', ModuleViewSet.as_view({'get': 'list'}), name='device_modules'), + # TODO: Services # Console ports url(r'^console-ports/(?P\d+)/$', ConsolePortView.as_view(), name='consoleport'), diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 19aef2798..b74739d0d 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,44 +1,26 @@ -from django.conf.urls import url +from django.conf.urls import include, url -from .views import * +from rest_framework import routers +from .views import ( + AggregateViewSet, IPAddressViewSet, PrefixViewSet, RIRViewSet, RoleViewSet, ServiceViewSet, VLANViewSet, + VLANGroupViewSet, VRFViewSet, +) + + +router = routers.DefaultRouter() +router.register(r'vrfs', VRFViewSet) +router.register(r'rirs', RIRViewSet) +router.register(r'aggregates', AggregateViewSet) +router.register(r'roles', RoleViewSet) +router.register(r'prefixes', PrefixViewSet) +router.register(r'ip-addresses', IPAddressViewSet) +router.register(r'vlan-groups', VLANGroupViewSet) +router.register(r'vlans', VLANViewSet) +router.register(r'services', ServiceViewSet) urlpatterns = [ - # VRFs - url(r'^vrfs/$', VRFViewSet.as_view({'get': 'list'}), name='vrf_list'), - url(r'^vrfs/(?P\d+)/$', VRFViewSet.as_view({'get': 'retrieve'}), name='vrf_detail'), - - # Roles - url(r'^roles/$', RoleViewSet.as_view({'get': 'list'}), name='role_list'), - url(r'^roles/(?P\d+)/$', RoleViewSet.as_view({'get': 'retrieve'}), name='role_detail'), - - # RIRs - url(r'^rirs/$', RIRViewSet.as_view({'get': 'list'}), name='rir_list'), - url(r'^rirs/(?P\d+)/$', RIRViewSet.as_view({'get': 'retrieve'}), name='rir_detail'), - - # Aggregates - url(r'^aggregates/$', AggregateViewSet.as_view({'get': 'list'}), name='aggregate_list'), - url(r'^aggregates/(?P\d+)/$', AggregateViewSet.as_view({'get': 'retrieve'}), name='aggregate_detail'), - - # Prefixes - url(r'^prefixes/$', PrefixViewSet.as_view({'get': 'list'}), name='prefix_list'), - url(r'^prefixes/(?P\d+)/$', PrefixViewSet.as_view({'get': 'retrieve'}), name='prefix_detail'), - - # IP addresses - url(r'^ip-addresses/$', IPAddressViewSet.as_view({'get': 'list'}), name='ipaddress_list'), - url(r'^ip-addresses/(?P\d+)/$', IPAddressViewSet.as_view({'get': 'retrieve'}), name='ipaddress_detail'), - - # VLAN groups - url(r'^vlan-groups/$', VLANGroupViewSet.as_view({'get': 'list'}), name='vlangroup_list'), - url(r'^vlan-groups/(?P\d+)/$', VLANGroupViewSet.as_view({'get': 'retrieve'}), name='vlangroup_detail'), - - # VLANs - url(r'^vlans/$', VLANViewSet.as_view({'get': 'list'}), name='vlan_list'), - url(r'^vlans/(?P\d+)/$', VLANViewSet.as_view({'get': 'retrieve'}), name='vlan_detail'), - - # Services - url(r'^services/$', ServiceViewSet.as_view({'get': 'list'}), name='service_list'), - url(r'^services/(?P\d+)/$', ServiceViewSet.as_view({'get': 'retrieve'}), name='service_detail'), + url(r'', include(router.urls)), ] diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index 2ad2abc1e..14579dd3f 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -1,13 +1,24 @@ -from django.conf.urls import url +from django.conf.urls import include, url -from .views import * +from rest_framework import routers +from .views import ( + + # Viewsets + SecretRoleViewSet, + + # Legacy views + RSAKeyGeneratorView, SecretDetailView, SecretListView, + +) + + +router = routers.DefaultRouter() +router.register(r'secret-roles', SecretRoleViewSet) urlpatterns = [ - # Secret roles - url(r'^secret-roles/$', SecretRoleViewSet.as_view({'get': 'list'}), name='secretrole_list'), - url(r'^secret-roles/(?P\d+)/$', SecretRoleViewSet.as_view({'get': 'retrieve'}), name='secretrole_detail'), + url(r'', include(router.urls)), # Secrets url(r'^secrets/$', SecretListView.as_view(), name='secret_list'), diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 457327855..c21e8ccda 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -1,16 +1,16 @@ -from django.conf.urls import url +from django.conf.urls import include, url -from .views import * +from rest_framework import routers +from views import TenantViewSet, TenantGroupViewSet + + +router = routers.DefaultRouter() +router.register(r'tenant-groups', TenantGroupViewSet) +router.register(r'tenants', TenantViewSet) urlpatterns = [ - # Tenant groups - url(r'^tenant-groups/$', TenantGroupViewSet.as_view({'get': 'list'}), name='tenantgroup_list'), - url(r'^tenant-groups/(?P\d+)/$', TenantGroupViewSet.as_view({'get': 'retrieve'}), name='tenantgroup_detail'), - - # Tenants - url(r'^tenants/$', TenantViewSet.as_view({'get': 'list'}), name='tenant_list'), - url(r'^tenants/(?P\d+)/$', TenantViewSet.as_view({'get': 'retrieve'}), name='tenant_detail'), + url(r'', include(router.urls)), ] From b31c097531bce78d5beacecdbf0e69c701633f35 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 26 Jan 2017 15:36:19 -0500 Subject: [PATCH 004/182] Removed Swagger --- netbox/netbox/settings.py | 6 ------ netbox/netbox/urls.py | 1 - netbox/templates/_base.html | 2 +- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f5d7df4d4..dc87f6655 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -105,7 +105,6 @@ INSTALLED_APPS = ( 'debug_toolbar', 'django_tables2', 'rest_framework', - 'rest_framework_swagger', 'circuits', 'dcim', 'ipam', @@ -189,11 +188,6 @@ REST_FRAMEWORK = { if LOGIN_REQUIRED: REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = ('rest_framework.permissions.IsAuthenticated',) -# Swagger settings (API docs) -SWAGGER_SETTINGS = { - 'base_path': '{}/{}api/docs'.format(ALLOWED_HOSTS[0], BASE_PATH), -} - # Django debug toolbar INTERNAL_IPS = ( '127.0.0.1', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index bbfdee58d..11787adae 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -31,7 +31,6 @@ _patterns = [ url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')), url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')), url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')), - url(r'^api/docs/', include('rest_framework_swagger.urls')), url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), # Error testing diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 4e63cf337..d94081f65 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -289,7 +289,7 @@

Docs · - API · + API · Code

From b8ca530c55aca1de9b50b422c9828ab82c7bc34b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 26 Jan 2017 17:18:41 -0500 Subject: [PATCH 005/182] Added an endpoint for CircuitTerminations --- netbox/circuits/api/urls.py | 12 ++++++++---- netbox/circuits/api/views.py | 35 ++++++++++++++++++++++++----------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 58c370449..0dd2d2a94 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -5,13 +5,14 @@ from rest_framework import routers from extras.models import GRAPH_TYPE_PROVIDER from extras.api.views import GraphListView -from .views import CircuitViewSet, CircuitTypeViewSet, ProviderViewSet +from . import views router = routers.DefaultRouter() -router.register(r'providers', ProviderViewSet) -router.register(r'circuit-types', CircuitTypeViewSet) -router.register(r'circuits', CircuitViewSet) +router.register(r'providers', views.ProviderViewSet) +router.register(r'circuit-types', views.CircuitTypeViewSet) +router.register(r'circuits', views.CircuitViewSet) +router.register(r'circuit-terminations', views.CircuitTerminationViewSet) urlpatterns = [ @@ -21,4 +22,7 @@ urlpatterns = [ url(r'^providers/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_PROVIDER}, name='provider_graphs'), + # Circuits + url(r'^circuits/(?P\d+)/terminations/$', views.NestedCircuitTerminationViewSet.as_view({'get': 'list'})), + ] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index ec1f1c8ac..757cc2e26 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,6 +1,11 @@ -from rest_framework.viewsets import ModelViewSet +from django.shortcuts import get_object_or_404 -from circuits.models import Provider, CircuitType, Circuit +from rest_framework.mixins import ( + CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin, +) +from rest_framework.viewsets import GenericViewSet, ModelViewSet + +from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from circuits.filters import CircuitFilter from extras.api.views import CustomFieldModelViewSet @@ -12,9 +17,6 @@ from . import serializers # class ProviderViewSet(CustomFieldModelViewSet): - """ - List and retrieve circuit providers - """ queryset = Provider.objects.all() serializer_class = serializers.ProviderSerializer @@ -24,9 +26,6 @@ class ProviderViewSet(CustomFieldModelViewSet): # class CircuitTypeViewSet(ModelViewSet): - """ - List and retrieve circuit types - """ queryset = CircuitType.objects.all() serializer_class = serializers.CircuitTypeSerializer @@ -36,9 +35,6 @@ class CircuitTypeViewSet(ModelViewSet): # class CircuitViewSet(CustomFieldModelViewSet): - """ - List and retrieve circuits - """ queryset = Circuit.objects.select_related('type', 'tenant', 'provider') filter_class = CircuitFilter @@ -46,3 +42,20 @@ class CircuitViewSet(CustomFieldModelViewSet): if self.action == 'retrieve': return serializers.CircuitDetailSerializer return serializers.CircuitSerializer + + +class NestedCircuitTerminationViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): + serializer_class = serializers.CircuitTerminationSerializer + + def get_queryset(self): + circuit = get_object_or_404(Circuit, pk=self.kwargs['pk']) + return CircuitTermination.objects.filter(circuit=circuit).select_related('site', 'interface__device') + + +# +# Circuit Terminations +# + +class CircuitTerminationViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): + queryset = CircuitTermination.objects.select_related('site', 'interface__device') + serializer_class = serializers.CircuitTerminationSerializer From acfba410ddb64c8fc5dd8a2d983eee0c34cd2cd4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 26 Jan 2017 17:58:36 -0500 Subject: [PATCH 006/182] Standardized implementation of nested ViewSets --- netbox/circuits/api/views.py | 16 ++-- netbox/dcim/api/urls.py | 74 ++++++++------- netbox/dcim/api/views.py | 177 +++++++++++++---------------------- netbox/ipam/api/urls.py | 23 ++--- netbox/ipam/api/views.py | 27 ------ netbox/secrets/api/urls.py | 18 +--- netbox/secrets/api/views.py | 3 - netbox/tenancy/api/urls.py | 6 +- netbox/tenancy/api/views.py | 6 -- 9 files changed, 129 insertions(+), 221 deletions(-) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 757cc2e26..731602975 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -44,14 +44,6 @@ class CircuitViewSet(CustomFieldModelViewSet): return serializers.CircuitSerializer -class NestedCircuitTerminationViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.CircuitTerminationSerializer - - def get_queryset(self): - circuit = get_object_or_404(Circuit, pk=self.kwargs['pk']) - return CircuitTermination.objects.filter(circuit=circuit).select_related('site', 'interface__device') - - # # Circuit Terminations # @@ -59,3 +51,11 @@ class NestedCircuitTerminationViewSet(CreateModelMixin, ListModelMixin, GenericV class CircuitTerminationViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): queryset = CircuitTermination.objects.select_related('site', 'interface__device') serializer_class = serializers.CircuitTerminationSerializer + + +class NestedCircuitTerminationViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): + serializer_class = serializers.CircuitTerminationSerializer + + def get_queryset(self): + circuit = get_object_or_404(Circuit, pk=self.kwargs['pk']) + return CircuitTermination.objects.filter(circuit=circuit).select_related('site', 'interface__device') diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index d31d901c6..d75ecb083 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -5,29 +5,20 @@ from rest_framework import routers from extras.models import GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from extras.api.views import GraphListView, TopologyMapView -from .views import ( - - # Viewsets - ConsolePortViewSet, ConsoleServerPortViewSet, DeviceViewSet, DeviceBayViewSet, DeviceRoleViewSet, DeviceTypeViewSet, - InterfaceViewSet, ManufacturerViewSet, ModuleViewSet, PlatformViewSet, PowerPortViewSet, PowerOutletViewSet, - RackViewSet, RackGroupViewSet, RackRoleViewSet, SiteViewSet, - - # Legacy views - ConsolePortView, InterfaceConnectionView, InterfaceConnectionListView, InterfaceDetailView, PowerPortView, - LLDPNeighborsView, RackUnitListView, RelatedConnectionsView, -) +from . import views router = routers.DefaultRouter() -router.register(r'sites', SiteViewSet) -router.register(r'rack-groups', RackGroupViewSet) -router.register(r'rack-roles', RackRoleViewSet) -router.register(r'racks', RackViewSet) -router.register(r'manufacturers', ManufacturerViewSet) -router.register(r'device-types', DeviceTypeViewSet) -router.register(r'device-roles', DeviceRoleViewSet) -router.register(r'platforms', PlatformViewSet) -router.register(r'devices', DeviceViewSet) +router.register(r'sites', views.SiteViewSet) +router.register(r'rack-groups', views.RackGroupViewSet) +router.register(r'rack-roles', views.RackRoleViewSet) +router.register(r'racks', views.RackViewSet) +router.register(r'manufacturers', views.ManufacturerViewSet) +router.register(r'device-types', views.DeviceTypeViewSet) +router.register(r'device-roles', views.DeviceRoleViewSet) +router.register(r'platforms', views.PlatformViewSet) +router.register(r'devices', views.DeviceViewSet) +router.register(r'interface-connections', views.InterfaceConnectionViewSet) urlpatterns = [ @@ -37,34 +28,47 @@ urlpatterns = [ url(r'^sites/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_SITE}, name='site_graphs'), # Racks - url(r'^racks/(?P\d+)/rack-units/$', RackUnitListView.as_view(), name='rack_units'), + url(r'^racks/(?P\d+)/rack-units/$', views.RackUnitListView.as_view(), name='rack_units'), + + # Device types + # TODO: Nested DeviceType components # Devices - url(r'^devices/(?P\d+)/lldp-neighbors/$', LLDPNeighborsView.as_view(), name='device_lldp-neighbors'), - url(r'^devices/(?P\d+)/console-ports/$', ConsolePortViewSet.as_view({'get': 'list'}), name='device_consoleports'), - url(r'^devices/(?P\d+)/console-server-ports/$', ConsoleServerPortViewSet.as_view({'get': 'list'}), name='device_consoleserverports'), - url(r'^devices/(?P\d+)/power-ports/$', PowerPortViewSet.as_view({'get': 'list'}), name='device_powerports'), - url(r'^devices/(?P\d+)/power-outlets/$', PowerOutletViewSet.as_view({'get': 'list'}), name='device_poweroutlets'), - url(r'^devices/(?P\d+)/interfaces/$', InterfaceViewSet.as_view({'get': 'list'}), name='device_interfaces'), - url(r'^devices/(?P\d+)/device-bays/$', DeviceBayViewSet.as_view({'get': 'list'}), name='device_devicebays'), - url(r'^devices/(?P\d+)/modules/$', ModuleViewSet.as_view({'get': 'list'}), name='device_modules'), + url(r'^devices/(?P\d+)/lldp-neighbors/$', views.LLDPNeighborsView.as_view(), name='device_lldp-neighbors'), + url(r'^devices/(?P\d+)/console-ports/$', views.NestedConsolePortViewSet.as_view({'get': 'list'}), name='device_consoleports'), + url(r'^devices/(?P\d+)/console-server-ports/$', views.NestedConsoleServerPortViewSet.as_view({'get': 'list'}), name='device_consoleserverports'), + url(r'^devices/(?P\d+)/power-ports/$', views.NestedPowerPortViewSet.as_view({'get': 'list'}), name='device_powerports'), + url(r'^devices/(?P\d+)/power-outlets/$', views.NestedPowerOutletViewSet.as_view({'get': 'list'}), name='device_poweroutlets'), + url(r'^devices/(?P\d+)/interfaces/$', views.NestedInterfaceViewSet.as_view({'get': 'list'}), name='device_interfaces'), + url(r'^devices/(?P\d+)/device-bays/$', views.NestedDeviceBayViewSet.as_view({'get': 'list'}), name='device_devicebays'), + url(r'^devices/(?P\d+)/modules/$', views.NestedModuleViewSet.as_view({'get': 'list'}), name='device_modules'), # TODO: Services # Console ports - url(r'^console-ports/(?P\d+)/$', ConsolePortView.as_view(), name='consoleport'), + url(r'^console-ports/(?P\d+)/$', views.ConsolePortViewSet.as_view({'get': 'retrieve'}), name='consoleport'), + + # Console server ports + url(r'^console-server-ports/(?P\d+)/$', views.ConsoleServerPortViewSet.as_view({'get': 'retrieve'}), name='consoleserverport'), # Power ports - url(r'^power-ports/(?P\d+)/$', PowerPortView.as_view(), name='powerport'), + url(r'^power-ports/(?P\d+)/$', views.PowerPortViewSet.as_view({'get': 'retrieve'}), name='powerport'), + + # Power outlets + url(r'^power-outlets/(?P\d+)/$', views.PowerOutletViewSet.as_view({'get': 'retrieve'}), name='poweroutlet'), # Interfaces - url(r'^interfaces/(?P\d+)/$', InterfaceDetailView.as_view(), name='interface_detail'), + url(r'^interfaces/(?P\d+)/$', views.InterfaceViewSet.as_view({'get': 'retrieve'}), name='interface'), url(r'^interfaces/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_INTERFACE}, name='interface_graphs'), - url(r'^interface-connections/$', InterfaceConnectionListView.as_view(), name='interfaceconnection_list'), - url(r'^interface-connections/(?P\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection_detail'), + + # Device bays + url(r'^device-bays/(?P\d+)/$', views.DeviceBayViewSet.as_view({'get': 'retrieve'}), name='devicebay'), + + # Modules + url(r'^modules/(?P\d+)/$', views.ModuleViewSet.as_view({'get': 'retrieve'}), name='module'), # Miscellaneous - url(r'^related-connections/$', RelatedConnectionsView.as_view(), name='related_connections'), + url(r'^related-connections/$', views.RelatedConnectionsView.as_view(), name='related_connections'), url(r'^topology-maps/(?P[\w-]+)/$', TopologyMapView.as_view(), name='topology_map'), ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 01eb58dab..2e8a4d1d0 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,9 +1,10 @@ -from rest_framework import generics -from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly +from rest_framework.mixins import ( + CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin, +) from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework.views import APIView -from rest_framework.viewsets import ModelViewSet +from rest_framework.viewsets import GenericViewSet, ModelViewSet from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -11,8 +12,8 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 from dcim.models import ( - ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface, - InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, + ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, Interface, InterfaceConnection, + Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, ) from dcim import filters from extras.api.views import CustomFieldModelViewSet @@ -27,9 +28,6 @@ from . import serializers # class SiteViewSet(CustomFieldModelViewSet): - """ - List and retrieve sites - """ queryset = Site.objects.select_related('tenant') serializer_class = serializers.SiteSerializer @@ -39,9 +37,6 @@ class SiteViewSet(CustomFieldModelViewSet): # class RackGroupViewSet(ModelViewSet): - """ - List and retrieve rack groups - """ queryset = RackGroup.objects.select_related('site') serializer_class = serializers.RackGroupSerializer filter_class = filters.RackGroupFilter @@ -52,9 +47,6 @@ class RackGroupViewSet(ModelViewSet): # class RackRoleViewSet(ModelViewSet): - """ - List and retrieve rack roles - """ queryset = RackRole.objects.all() serializer_class = serializers.RackRoleSerializer @@ -64,9 +56,6 @@ class RackRoleViewSet(ModelViewSet): # class RackViewSet(CustomFieldModelViewSet): - """ - List and retrieve racks - """ queryset = Rack.objects.select_related('site', 'group__site', 'tenant') filter_class = filters.RackFilter @@ -106,9 +95,6 @@ class RackUnitListView(APIView): # class ManufacturerViewSet(ModelViewSet): - """ - List and retrieve manufacturers - """ queryset = Manufacturer.objects.all() serializer_class = serializers.ManufacturerSerializer @@ -118,9 +104,6 @@ class ManufacturerViewSet(ModelViewSet): # class DeviceTypeViewSet(CustomFieldModelViewSet): - """ - List and retrieve device types - """ queryset = DeviceType.objects.select_related('manufacturer') filter_class = filters.DeviceTypeFilter @@ -131,13 +114,10 @@ class DeviceTypeViewSet(CustomFieldModelViewSet): # -# Device roles +# Device Roles # class DeviceRoleViewSet(ModelViewSet): - """ - List and retrieve device roles - """ queryset = DeviceRole.objects.all() serializer_class = serializers.DeviceRoleSerializer @@ -147,9 +127,6 @@ class DeviceRoleViewSet(ModelViewSet): # class PlatformViewSet(ModelViewSet): - """ - List and retrieve platforms - """ queryset = Platform.objects.all() serializer_class = serializers.PlatformSerializer @@ -159,9 +136,6 @@ class PlatformViewSet(ModelViewSet): # class DeviceViewSet(CustomFieldModelViewSet): - """ - List and retrieve devices - """ queryset = Device.objects.select_related( 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'rack__site', 'parent_bay', ).prefetch_related( @@ -173,13 +147,15 @@ class DeviceViewSet(CustomFieldModelViewSet): # -# Console ports +# Console Ports # -class ConsolePortViewSet(ModelViewSet): - """ - List and retrieve console ports (by device) - """ +class ConsolePortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): + queryset = ConsolePort.objects.select_related('cs_port') + serializer_class = serializers.ConsolePortSerializer + + +class NestedConsolePortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): serializer_class = serializers.ConsolePortSerializer def get_queryset(self): @@ -187,20 +163,16 @@ class ConsolePortViewSet(ModelViewSet): return ConsolePort.objects.filter(device=device).select_related('cs_port') -class ConsolePortView(generics.RetrieveUpdateDestroyAPIView): - permission_classes = [DjangoModelPermissionsOrAnonReadOnly] - serializer_class = serializers.ConsolePortSerializer - queryset = ConsolePort.objects.all() - - # -# Console server ports +# Console Server Ports # -class ConsoleServerPortViewSet(ModelViewSet): - """ - List and retrieve console server ports (by device) - """ +class ConsoleServerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): + queryset = ConsoleServerPort.objects.select_related('connected_console') + serializer_class = serializers.ConsoleServerPortSerializer + + +class NestedConsoleServerPortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): serializer_class = serializers.ConsoleServerPortSerializer def get_queryset(self): @@ -209,13 +181,15 @@ class ConsoleServerPortViewSet(ModelViewSet): # -# Power ports +# Power Ports # -class PowerPortViewSet(ModelViewSet): - """ - List and retrieve power ports (by device) - """ +class PowerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): + queryset = PowerPort.objects.select_related('power_outlet') + serializer_class = serializers.PowerPortSerializer + + +class NestedPowerPortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): serializer_class = serializers.PowerPortSerializer def get_queryset(self): @@ -223,20 +197,16 @@ class PowerPortViewSet(ModelViewSet): return PowerPort.objects.filter(device=device).select_related('power_outlet') -class PowerPortView(generics.RetrieveUpdateDestroyAPIView): - permission_classes = [DjangoModelPermissionsOrAnonReadOnly] - serializer_class = serializers.PowerPortSerializer - queryset = PowerPort.objects.all() - - # -# Power outlets +# Power Outlets # -class PowerOutletViewSet(ModelViewSet): - """ - List and retrieve power outlets (by device) - """ +class PowerOutletViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): + queryset = PowerOutlet.objects.select_related('connected_port') + serializer_class = serializers.PowerOutletSerializer + + +class NestedPowerOutletViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): serializer_class = serializers.PowerOutletSerializer def get_queryset(self): @@ -248,61 +218,31 @@ class PowerOutletViewSet(ModelViewSet): # Interfaces # -class InterfaceViewSet(ModelViewSet): - """ - List and retrieve interfaces (by device) - """ - serializer_class = serializers.InterfaceSerializer - filter_class = filters.InterfaceFilter - - def get_queryset(self): - - device = get_object_or_404(Device, pk=self.kwargs['pk']) - queryset = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\ - .select_related('connected_as_a', 'connected_as_b', 'circuit_termination') - - # Filter by type (physical or virtual) - iface_type = self.request.query_params.get('type') - if iface_type == 'physical': - queryset = queryset.exclude(form_factor=IFACE_FF_VIRTUAL) - elif iface_type == 'virtual': - queryset = queryset.filter(form_factor=IFACE_FF_VIRTUAL) - elif iface_type is not None: - queryset = queryset.empty() - - return queryset - - -class InterfaceDetailView(generics.RetrieveAPIView): - """ - Retrieve a single interface - """ +class InterfaceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): queryset = Interface.objects.select_related('device') serializer_class = serializers.InterfaceDetailSerializer -class InterfaceConnectionView(generics.RetrieveUpdateDestroyAPIView): - permission_classes = [DjangoModelPermissionsOrAnonReadOnly] - serializer_class = serializers.InterfaceConnectionSerializer - queryset = InterfaceConnection.objects.all() +class NestedInterfaceViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): + serializer_class = serializers.InterfaceSerializer + filter_class = filters.InterfaceFilter - -class InterfaceConnectionListView(generics.ListAPIView): - """ - Retrieve a list of all interface connections - """ - serializer_class = serializers.InterfaceConnectionSerializer - queryset = InterfaceConnection.objects.all() + def get_queryset(self): + device = get_object_or_404(Device, pk=self.kwargs['pk']) + return Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\ + .select_related('connected_as_a', 'connected_as_b', 'circuit_termination') # # Device bays # -class DeviceBayViewSet(ModelViewSet): - """ - List and retrieve device bays (by device) - """ +class DeviceBayViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): + queryset = DeviceBay.objects.select_related('installed_device') + serializer_class = serializers.DeviceBaySerializer + + +class NestedDeviceBayViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): serializer_class = serializers.DeviceBayNestedSerializer def get_queryset(self): @@ -314,10 +254,12 @@ class DeviceBayViewSet(ModelViewSet): # Modules # -class ModuleViewSet(ModelViewSet): - """ - List and retrieve modules (by device) - """ +class ModuleViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): + queryset = Module.objects.select_related('device', 'manufacturer') + serializer_class = serializers.ModuleSerializer + + +class NestedModuleViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): serializer_class = serializers.ModuleSerializer def get_queryset(self): @@ -325,6 +267,15 @@ class ModuleViewSet(ModelViewSet): return Module.objects.filter(device=device).select_related('device', 'manufacturer') +# +# Interface connections +# + +class InterfaceConnectionViewSet(ModelViewSet): + queryset = InterfaceConnection.objects.all() + serializer_class = serializers.InterfaceConnectionSerializer + + # # Live queries # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index b74739d0d..24c97b341 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -2,22 +2,19 @@ from django.conf.urls import include, url from rest_framework import routers -from .views import ( - AggregateViewSet, IPAddressViewSet, PrefixViewSet, RIRViewSet, RoleViewSet, ServiceViewSet, VLANViewSet, - VLANGroupViewSet, VRFViewSet, -) +from . import views router = routers.DefaultRouter() -router.register(r'vrfs', VRFViewSet) -router.register(r'rirs', RIRViewSet) -router.register(r'aggregates', AggregateViewSet) -router.register(r'roles', RoleViewSet) -router.register(r'prefixes', PrefixViewSet) -router.register(r'ip-addresses', IPAddressViewSet) -router.register(r'vlan-groups', VLANGroupViewSet) -router.register(r'vlans', VLANViewSet) -router.register(r'services', ServiceViewSet) +router.register(r'vrfs', views.VRFViewSet) +router.register(r'rirs', views.RIRViewSet) +router.register(r'aggregates', views.AggregateViewSet) +router.register(r'roles', views.RoleViewSet) +router.register(r'prefixes', views.PrefixViewSet) +router.register(r'ip-addresses', views.IPAddressViewSet) +router.register(r'vlan-groups', views.VLANGroupViewSet) +router.register(r'vlans', views.VLANViewSet) +router.register(r'services', views.ServiceViewSet) urlpatterns = [ diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 87200fe3a..80ff10c6f 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -12,9 +12,6 @@ from . import serializers # class VRFViewSet(CustomFieldModelViewSet): - """ - List and retrieve VRFs - """ queryset = VRF.objects.select_related('tenant') serializer_class = serializers.VRFSerializer filter_class = filters.VRFFilter @@ -25,9 +22,6 @@ class VRFViewSet(CustomFieldModelViewSet): # class RoleViewSet(ModelViewSet): - """ - List and retrieve prefix/VLAN roles - """ queryset = Role.objects.all() serializer_class = serializers.RoleSerializer @@ -37,9 +31,6 @@ class RoleViewSet(ModelViewSet): # class RIRViewSet(ModelViewSet): - """ - List and retrieve RIRs - """ queryset = RIR.objects.all() serializer_class = serializers.RIRSerializer @@ -49,9 +40,6 @@ class RIRViewSet(ModelViewSet): # class AggregateViewSet(CustomFieldModelViewSet): - """ - List and retrieve aggregates - """ queryset = Aggregate.objects.select_related('rir') serializer_class = serializers.AggregateSerializer filter_class = filters.AggregateFilter @@ -62,9 +50,6 @@ class AggregateViewSet(CustomFieldModelViewSet): # class PrefixViewSet(CustomFieldModelViewSet): - """ - List and retrieve prefixes - """ queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') serializer_class = serializers.PrefixSerializer filter_class = filters.PrefixFilter @@ -75,9 +60,6 @@ class PrefixViewSet(CustomFieldModelViewSet): # class IPAddressViewSet(CustomFieldModelViewSet): - """ - List and retrieve IP addresses - """ queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside') serializer_class = serializers.IPAddressSerializer filter_class = filters.IPAddressFilter @@ -88,9 +70,6 @@ class IPAddressViewSet(CustomFieldModelViewSet): # class VLANGroupViewSet(ModelViewSet): - """ - List and retrieve VLAN groups - """ queryset = VLANGroup.objects.select_related('site') serializer_class = serializers.VLANGroupSerializer filter_class = filters.VLANGroupFilter @@ -101,9 +80,6 @@ class VLANGroupViewSet(ModelViewSet): # class VLANViewSet(CustomFieldModelViewSet): - """ - List and retrieve VLANs - """ queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') serializer_class = serializers.VLANSerializer filter_class = filters.VLANFilter @@ -114,9 +90,6 @@ class VLANViewSet(CustomFieldModelViewSet): # class ServiceViewSet(ModelViewSet): - """ - List and retrieve services - """ queryset = Service.objects.select_related('device').prefetch_related('ipaddresses') serializer_class = serializers.ServiceSerializer filter_class = filters.ServiceFilter diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index 14579dd3f..a030b481e 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -2,29 +2,21 @@ from django.conf.urls import include, url from rest_framework import routers -from .views import ( - - # Viewsets - SecretRoleViewSet, - - # Legacy views - RSAKeyGeneratorView, SecretDetailView, SecretListView, - -) +from . import views router = routers.DefaultRouter() -router.register(r'secret-roles', SecretRoleViewSet) +router.register(r'secret-roles', views.SecretRoleViewSet) urlpatterns = [ url(r'', include(router.urls)), # Secrets - url(r'^secrets/$', SecretListView.as_view(), name='secret_list'), - url(r'^secrets/(?P\d+)/$', SecretDetailView.as_view(), name='secret_detail'), + url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'), + url(r'^secrets/(?P\d+)/$', views.SecretDetailView.as_view(), name='secret_detail'), # Miscellaneous - url(r'^generate-keys/$', RSAKeyGeneratorView.as_view(), name='generate_keys'), + url(r'^generate-keys/$', views.RSAKeyGeneratorView.as_view(), name='generate_keys'), ] diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 10e95d51b..e2ccbdb07 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -28,9 +28,6 @@ ERR_PRIVKEY_INVALID = "Invalid private key." # class SecretRoleViewSet(ModelViewSet): - """ - List and retrieve secret roles - """ queryset = SecretRole.objects.all() serializer_class = serializers.SecretRoleSerializer permission_classes = [IsAuthenticated] diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index c21e8ccda..35e11cd6b 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -2,12 +2,12 @@ from django.conf.urls import include, url from rest_framework import routers -from views import TenantViewSet, TenantGroupViewSet +from . import views router = routers.DefaultRouter() -router.register(r'tenant-groups', TenantGroupViewSet) -router.register(r'tenants', TenantViewSet) +router.register(r'tenant-groups', views.TenantGroupViewSet) +router.register(r'tenants', views.TenantViewSet) urlpatterns = [ diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 6cfeed2ef..17d0e79ef 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -12,9 +12,6 @@ from . import serializers # class TenantGroupViewSet(ModelViewSet): - """ - List and retrieve tenant groups - """ queryset = TenantGroup.objects.all() serializer_class = serializers.TenantGroupSerializer @@ -24,9 +21,6 @@ class TenantGroupViewSet(ModelViewSet): # class TenantViewSet(CustomFieldModelViewSet): - """ - List and retrieve tenants - """ queryset = Tenant.objects.select_related('group') serializer_class = serializers.TenantSerializer filter_class = TenantFilter From ddc2c8d1105f991c0d4a0b20366e2bbde5eb47e8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 26 Jan 2017 22:37:17 -0500 Subject: [PATCH 007/182] Cleaned up device component nested serializers --- netbox/circuits/api/serializers.py | 4 +- netbox/dcim/api/serializers.py | 84 ++++++++++++------------------ netbox/dcim/api/views.py | 19 +++---- netbox/ipam/api/serializers.py | 4 +- 4 files changed, 45 insertions(+), 66 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index ba1ba80a9..4cdfcf91e 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from circuits.models import Provider, Circuit, CircuitTermination, CircuitType -from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer +from dcim.api.serializers import SiteNestedSerializer, NestedInterfaceSerializer from extras.api.serializers import CustomFieldSerializer from tenancy.api.serializers import TenantNestedSerializer @@ -47,7 +47,7 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer): class CircuitTerminationSerializer(serializers.ModelSerializer): site = SiteNestedSerializer() - interface = InterfaceNestedSerializer() + interface = NestedInterfaceSerializer() class Meta: model = CircuitTermination diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f81f299af..64b16999a 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -191,20 +191,6 @@ class InterfaceTemplateNestedSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'form_factor', 'mgmt_only'] -class DeviceTypeDetailSerializer(DeviceTypeSerializer): - console_port_templates = ConsolePortTemplateNestedSerializer(many=True, read_only=True) - cs_port_templates = ConsoleServerPortTemplateNestedSerializer(many=True, read_only=True) - power_port_templates = PowerPortTemplateNestedSerializer(many=True, read_only=True) - power_outlet_templates = PowerPortTemplateNestedSerializer(many=True, read_only=True) - interface_templates = InterfaceTemplateNestedSerializer(many=True, read_only=True) - - class Meta(DeviceTypeSerializer.Meta): - fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', - 'comments', 'custom_fields', 'console_port_templates', 'cs_port_templates', 'power_port_templates', - 'power_outlet_templates', 'interface_templates'] - - # # Device roles # @@ -302,10 +288,11 @@ class ConsoleServerPortSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'connected_console'] -class ConsoleServerPortNestedSerializer(ConsoleServerPortSerializer): +class NestedConsoleServerPortSerializer(ConsoleServerPortSerializer): - class Meta(ConsoleServerPortSerializer.Meta): - fields = ['id', 'device', 'name'] + class Meta: + model = ConsoleServerPort + fields = ['id', 'name', 'connected_console'] # @@ -314,17 +301,18 @@ class ConsoleServerPortNestedSerializer(ConsoleServerPortSerializer): class ConsolePortSerializer(serializers.ModelSerializer): device = DeviceNestedSerializer() - cs_port = ConsoleServerPortNestedSerializer() + cs_port = ConsoleServerPortSerializer() class Meta: model = ConsolePort fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] -class ConsolePortNestedSerializer(ConsolePortSerializer): +class NestedConsolePortSerializer(ConsolePortSerializer): - class Meta(ConsolePortSerializer.Meta): - fields = ['id', 'device', 'name'] + class Meta: + model = ConsolePort + fields = ['id', 'name', 'cs_port', 'connection_status'] # @@ -339,10 +327,11 @@ class PowerOutletSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'connected_port'] -class PowerOutletNestedSerializer(PowerOutletSerializer): +class NestedPowerOutletSerializer(PowerOutletSerializer): - class Meta(PowerOutletSerializer.Meta): - fields = ['id', 'device', 'name'] + class Meta: + model = PowerOutlet + fields = ['id', 'name', 'connected_port'] # @@ -351,17 +340,18 @@ class PowerOutletNestedSerializer(PowerOutletSerializer): class PowerPortSerializer(serializers.ModelSerializer): device = DeviceNestedSerializer() - power_outlet = PowerOutletNestedSerializer() + power_outlet = PowerOutletSerializer() class Meta: model = PowerPort fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] -class PowerPortNestedSerializer(PowerPortSerializer): +class NestedPowerPortSerializer(PowerPortSerializer): - class Meta(PowerPortSerializer.Meta): - fields = ['id', 'device', 'name'] + class Meta: + model = PowerPort + fields = ['id', 'name', 'power_outlet', 'connection_status'] # @@ -377,15 +367,14 @@ class InterfaceSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected'] -class InterfaceNestedSerializer(InterfaceSerializer): - form_factor = serializers.ReadOnlyField(source='get_form_factor_display') +class NestedInterfaceSerializer(InterfaceSerializer): - class Meta(InterfaceSerializer.Meta): - fields = ['id', 'device', 'name'] + class Meta: + model = Interface + fields = ['id', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected'] class InterfaceDetailSerializer(InterfaceSerializer): - connected_interface = InterfaceSerializer() class Meta(InterfaceSerializer.Meta): fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected', @@ -398,26 +387,20 @@ class InterfaceDetailSerializer(InterfaceSerializer): class DeviceBaySerializer(serializers.ModelSerializer): device = DeviceNestedSerializer() + installed_device = DeviceNestedSerializer() class Meta: model = DeviceBay - fields = ['id', 'device', 'name'] - - -class DeviceBayNestedSerializer(DeviceBaySerializer): - installed_device = DeviceNestedSerializer() - - class Meta(DeviceBaySerializer.Meta): - fields = ['id', 'name', 'installed_device'] - - -class DeviceBayDetailSerializer(DeviceBaySerializer): - installed_device = DeviceNestedSerializer() - - class Meta(DeviceBaySerializer.Meta): fields = ['id', 'device', 'name', 'installed_device'] +class NestedDeviceBaySerializer(DeviceBaySerializer): + + class Meta: + model = DeviceBay + fields = ['id', 'name', 'installed_device'] + + # # Modules # @@ -431,10 +414,11 @@ class ModuleSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] -class ModuleNestedSerializer(ModuleSerializer): +class NestedModuleSerializer(ModuleSerializer): - class Meta(ModuleSerializer.Meta): - fields = ['id', 'device', 'parent', 'name'] + class Meta: + model = Module + fields = ['id', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] # diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 2e8a4d1d0..8d4eb13c3 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -107,11 +107,6 @@ class DeviceTypeViewSet(CustomFieldModelViewSet): queryset = DeviceType.objects.select_related('manufacturer') filter_class = filters.DeviceTypeFilter - def get_serializer_class(self): - if self.action == 'retrieve': - return serializers.DeviceTypeDetailSerializer - return serializers.DeviceTypeSerializer - # # Device Roles @@ -156,7 +151,7 @@ class ConsolePortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin class NestedConsolePortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.ConsolePortSerializer + serializer_class = serializers.NestedConsolePortSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -173,7 +168,7 @@ class ConsoleServerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyMode class NestedConsoleServerPortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.ConsoleServerPortSerializer + serializer_class = serializers.NestedConsoleServerPortSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -190,7 +185,7 @@ class PowerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, class NestedPowerPortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.PowerPortSerializer + serializer_class = serializers.NestedPowerPortSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -207,7 +202,7 @@ class PowerOutletViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin class NestedPowerOutletViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.PowerOutletSerializer + serializer_class = serializers.NestedPowerOutletSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -224,7 +219,7 @@ class InterfaceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, class NestedInterfaceViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.InterfaceSerializer + serializer_class = serializers.NestedInterfaceSerializer filter_class = filters.InterfaceFilter def get_queryset(self): @@ -243,7 +238,7 @@ class DeviceBayViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, class NestedDeviceBayViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.DeviceBayNestedSerializer + serializer_class = serializers.NestedDeviceBaySerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -260,7 +255,7 @@ class ModuleViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, Gen class NestedModuleViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.ModuleSerializer + serializer_class = serializers.NestedModuleSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e3f902605..4b69711cf 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from dcim.api.serializers import DeviceNestedSerializer, InterfaceNestedSerializer, SiteNestedSerializer +from dcim.api.serializers import DeviceNestedSerializer, NestedInterfaceSerializer, SiteNestedSerializer from extras.api.serializers import CustomFieldSerializer from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.serializers import TenantNestedSerializer @@ -155,7 +155,7 @@ class PrefixNestedSerializer(PrefixSerializer): class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer): vrf = VRFTenantSerializer() tenant = TenantNestedSerializer() - interface = InterfaceNestedSerializer() + interface = NestedInterfaceSerializer() class Meta: model = IPAddress From fa900d5dbb01215a891a0c65f29dc46d29cdee71 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jan 2017 12:22:29 -0500 Subject: [PATCH 008/182] Converted nested serializers to HyperlinkedModelSerializer --- netbox/circuits/api/serializers.py | 55 ++++---- netbox/dcim/api/serializers.py | 208 ++++++++++++++++------------- netbox/dcim/api/urls.py | 28 ++-- netbox/dcim/api/views.py | 31 +++-- netbox/extras/api/views.py | 11 +- netbox/ipam/api/serializers.py | 129 +++++++++--------- netbox/netbox/urls.py | 12 +- netbox/secrets/api/serializers.py | 28 +--- netbox/tenancy/api/serializers.py | 16 ++- 9 files changed, 273 insertions(+), 245 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 4cdfcf91e..d5f229a82 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,9 +1,9 @@ from rest_framework import serializers from circuits.models import Provider, Circuit, CircuitTermination, CircuitType -from dcim.api.serializers import SiteNestedSerializer, NestedInterfaceSerializer +from dcim.api.serializers import NestedSiteSerializer, ChildInterfaceSerializer from extras.api.serializers import CustomFieldSerializer -from tenancy.api.serializers import TenantNestedSerializer +from tenancy.api.serializers import NestedTenantSerializer # @@ -14,14 +14,17 @@ class ProviderSerializer(CustomFieldSerializer, serializers.ModelSerializer): class Meta: model = Provider - fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - 'custom_fields'] + fields = [ + 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + 'custom_fields', + ] -class ProviderNestedSerializer(ProviderSerializer): +class NestedProviderSerializer(serializers.HyperlinkedModelSerializer): - class Meta(ProviderSerializer.Meta): - fields = ['id', 'name', 'slug'] + class Meta: + model = Provider + fields = ['id', 'url', 'name', 'slug'] # @@ -35,10 +38,11 @@ class CircuitTypeSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug'] -class CircuitTypeNestedSerializer(CircuitTypeSerializer): +class NestedCircuitTypeSerializer(serializers.HyperlinkedModelSerializer): - class Meta(CircuitTypeSerializer.Meta): - pass + class Meta: + model = CircuitType + fields = ['id', 'url', 'name', 'slug'] # @@ -46,8 +50,8 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer): # class CircuitTerminationSerializer(serializers.ModelSerializer): - site = SiteNestedSerializer() - interface = NestedInterfaceSerializer() + site = NestedSiteSerializer() + interface = ChildInterfaceSerializer() class Meta: model = CircuitTermination @@ -58,27 +62,32 @@ class CircuitTerminationSerializer(serializers.ModelSerializer): # Circuits # - class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer): - provider = ProviderNestedSerializer() - type = CircuitTypeNestedSerializer() - tenant = TenantNestedSerializer() + provider = NestedProviderSerializer() + type = NestedCircuitTypeSerializer() + tenant = NestedTenantSerializer() class Meta: model = Circuit - fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', - 'custom_fields'] + fields = [ + 'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', + 'custom_fields', + ] -class CircuitNestedSerializer(CircuitSerializer): +class NestedCircuitSerializer(serializers.HyperlinkedModelSerializer): - class Meta(CircuitSerializer.Meta): - fields = ['id', 'cid'] + class Meta: + model = Circuit + fields = ['id', 'url', 'cid'] +# TODO: Delete this class CircuitDetailSerializer(CircuitSerializer): terminations = CircuitTerminationSerializer(many=True) class Meta(CircuitSerializer.Meta): - fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', - 'terminations', 'custom_fields'] + fields = [ + 'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', + 'terminations', 'custom_fields', + ] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 64b16999a..c6e68b9cb 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -8,7 +8,7 @@ from dcim.models import ( SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, ) from extras.api.serializers import CustomFieldSerializer -from tenancy.api.serializers import TenantNestedSerializer +from tenancy.api.serializers import NestedTenantSerializer # @@ -16,19 +16,22 @@ from tenancy.api.serializers import TenantNestedSerializer # class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer): - tenant = TenantNestedSerializer() + tenant = NestedTenantSerializer() class Meta: model = Site - fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', - 'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes', - 'count_vlans', 'count_racks', 'count_devices', 'count_circuits'] + fields = [ + 'id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name', + 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes', 'count_vlans', + 'count_racks', 'count_devices', 'count_circuits', + ] -class SiteNestedSerializer(SiteSerializer): +class NestedSiteSerializer(serializers.HyperlinkedModelSerializer): - class Meta(SiteSerializer.Meta): - fields = ['id', 'name', 'slug'] + class Meta: + model = Site + fields = ['id', 'url', 'name', 'slug'] # @@ -36,17 +39,18 @@ class SiteNestedSerializer(SiteSerializer): # class RackGroupSerializer(serializers.ModelSerializer): - site = SiteNestedSerializer() + site = NestedSiteSerializer() class Meta: model = RackGroup fields = ['id', 'name', 'slug', 'site'] -class RackGroupNestedSerializer(RackGroupSerializer): +class NestedRackGroupSerializer(serializers.HyperlinkedModelSerializer): - class Meta(SiteSerializer.Meta): - fields = ['id', 'name', 'slug'] + class Meta: + model = RackGroup + fields = ['id', 'url', 'name', 'slug'] # @@ -60,10 +64,11 @@ class RackRoleSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'color'] -class RackRoleNestedSerializer(RackRoleSerializer): +class NestedRackRoleSerializer(serializers.HyperlinkedModelSerializer): - class Meta(RackRoleSerializer.Meta): - fields = ['id', 'name', 'slug'] + class Meta: + model = RackRole + fields = ['id', 'url', 'name', 'slug'] # @@ -72,21 +77,24 @@ class RackRoleNestedSerializer(RackRoleSerializer): class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer): - site = SiteNestedSerializer() - group = RackGroupNestedSerializer() - tenant = TenantNestedSerializer() - role = RackRoleNestedSerializer() + site = NestedSiteSerializer() + group = NestedRackGroupSerializer() + tenant = NestedTenantSerializer() + role = NestedRackRoleSerializer() class Meta: model = Rack - fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', - 'u_height', 'desc_units', 'comments', 'custom_fields'] + fields = [ + 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', + 'desc_units', 'comments', 'custom_fields', + ] -class RackNestedSerializer(RackSerializer): +class NestedRackSerializer(serializers.HyperlinkedModelSerializer): - class Meta(RackSerializer.Meta): - fields = ['id', 'name', 'facility_id', 'display_name'] + class Meta: + model = Rack + fields = ['id', 'url', 'name', 'display_name'] class RackDetailSerializer(RackSerializer): @@ -94,19 +102,21 @@ class RackDetailSerializer(RackSerializer): rear_units = serializers.SerializerMethodField() class Meta(RackSerializer.Meta): - fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', - 'u_height', 'desc_units', 'comments', 'custom_fields', 'front_units', 'rear_units'] + fields = [ + 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', + 'desc_units', 'comments', 'custom_fields', 'front_units', 'rear_units', + ] def get_front_units(self, obj): units = obj.get_rack_units(face=RACK_FACE_FRONT) for u in units: - u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None + u['device'] = NestedDeviceSerializer(u['device']).data if u['device'] else None return units def get_rear_units(self, obj): units = obj.get_rack_units(face=RACK_FACE_REAR) for u in units: - u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None + u['device'] = NestedDeviceSerializer(u['device']).data if u['device'] else None return units @@ -121,10 +131,11 @@ class ManufacturerSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug'] -class ManufacturerNestedSerializer(ManufacturerSerializer): +class NestedManufacturerSerializer(serializers.HyperlinkedModelSerializer): - class Meta(ManufacturerSerializer.Meta): - pass + class Meta: + model = Manufacturer + fields = ['id', 'url', 'name', 'slug'] # @@ -132,15 +143,17 @@ class ManufacturerNestedSerializer(ManufacturerSerializer): # class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer): - manufacturer = ManufacturerNestedSerializer() + manufacturer = NestedManufacturerSerializer() subdevice_role = serializers.SerializerMethodField() instance_count = serializers.IntegerField(source='instances.count', read_only=True) class Meta: model = DeviceType - fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', - 'comments', 'custom_fields', 'instance_count'] + fields = [ + 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', + 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields', + 'instance_count', + ] def get_subdevice_role(self, obj): return { @@ -150,47 +163,55 @@ class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer): }[obj.subdevice_role] -class DeviceTypeNestedSerializer(DeviceTypeSerializer): +class NestedDeviceTypeSerializer(serializers.HyperlinkedModelSerializer): - class Meta(DeviceTypeSerializer.Meta): - fields = ['id', 'manufacturer', 'model', 'slug'] + class Meta: + model = DeviceType + fields = ['id', 'url', 'manufacturer', 'model', 'slug'] -class ConsolePortTemplateNestedSerializer(serializers.ModelSerializer): +class ConsolePortTemplateSerializer(serializers.ModelSerializer): class Meta: model = ConsolePortTemplate fields = ['id', 'name'] -class ConsoleServerPortTemplateNestedSerializer(serializers.ModelSerializer): +class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer): class Meta: model = ConsoleServerPortTemplate fields = ['id', 'name'] -class PowerPortTemplateNestedSerializer(serializers.ModelSerializer): +class PowerPortTemplateSerializer(serializers.ModelSerializer): class Meta: model = PowerPortTemplate fields = ['id', 'name'] -class PowerOutletTemplateNestedSerializer(serializers.ModelSerializer): +class PowerOutletTemplateSerializer(serializers.ModelSerializer): class Meta: model = PowerOutletTemplate fields = ['id', 'name'] -class InterfaceTemplateNestedSerializer(serializers.ModelSerializer): +class InterfaceTemplateSerializer(serializers.ModelSerializer): class Meta: model = InterfaceTemplate fields = ['id', 'name', 'form_factor', 'mgmt_only'] +class DeviceBayTemplateSerializer(serializers.ModelSerializer): + + class Meta: + model = DeviceBay + fields = ['id', 'name',] + + # # Device roles # @@ -202,10 +223,11 @@ class DeviceRoleSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'color'] -class DeviceRoleNestedSerializer(DeviceRoleSerializer): +class NestedDeviceRoleSerializer(serializers.HyperlinkedModelSerializer): - class Meta(DeviceRoleSerializer.Meta): - fields = ['id', 'name', 'slug'] + class Meta: + model = DeviceRole + fields = ['id', 'url', 'name', 'slug'] # @@ -219,40 +241,43 @@ class PlatformSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'rpc_client'] -class PlatformNestedSerializer(PlatformSerializer): +class NestedPlatformSerializer(serializers.HyperlinkedModelSerializer): - class Meta(PlatformSerializer.Meta): - fields = ['id', 'name', 'slug'] + class Meta: + model = Platform + fields = ['id', 'url', 'name', 'slug'] # # Devices # -# Cannot import ipam.api.IPAddressNestedSerializer due to circular dependency -class DeviceIPAddressNestedSerializer(serializers.ModelSerializer): +# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency +class DeviceIPAddressSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = IPAddress - fields = ['id', 'family', 'address'] + fields = ['id', 'url', 'family', 'address'] class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): - device_type = DeviceTypeNestedSerializer() - device_role = DeviceRoleNestedSerializer() - tenant = TenantNestedSerializer() - platform = PlatformNestedSerializer() - rack = RackNestedSerializer() - primary_ip = DeviceIPAddressNestedSerializer() - primary_ip4 = DeviceIPAddressNestedSerializer() - primary_ip6 = DeviceIPAddressNestedSerializer() + device_type = NestedDeviceTypeSerializer() + device_role = NestedDeviceRoleSerializer() + tenant = NestedTenantSerializer() + platform = NestedPlatformSerializer() + rack = NestedRackSerializer() + primary_ip = DeviceIPAddressSerializer() + primary_ip4 = DeviceIPAddressSerializer() + primary_ip6 = DeviceIPAddressSerializer() parent_device = serializers.SerializerMethodField() class Meta: model = Device - fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', - 'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'comments', 'custom_fields'] + fields = [ + 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', + 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', + 'comments', 'custom_fields', + ] def get_parent_device(self, obj): try: @@ -269,11 +294,11 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): } -class DeviceNestedSerializer(serializers.ModelSerializer): +class NestedDeviceSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Device - fields = ['id', 'name', 'display_name'] + fields = ['id', 'url', 'name', 'display_name'] # @@ -281,18 +306,18 @@ class DeviceNestedSerializer(serializers.ModelSerializer): # class ConsoleServerPortSerializer(serializers.ModelSerializer): - device = DeviceNestedSerializer() + device = NestedDeviceSerializer() class Meta: model = ConsoleServerPort fields = ['id', 'device', 'name', 'connected_console'] -class NestedConsoleServerPortSerializer(ConsoleServerPortSerializer): +class ChildConsoleServerPortSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = ConsoleServerPort - fields = ['id', 'name', 'connected_console'] + fields = ['id', 'url', 'name', 'connected_console'] # @@ -300,7 +325,7 @@ class NestedConsoleServerPortSerializer(ConsoleServerPortSerializer): # class ConsolePortSerializer(serializers.ModelSerializer): - device = DeviceNestedSerializer() + device = NestedDeviceSerializer() cs_port = ConsoleServerPortSerializer() class Meta: @@ -308,11 +333,11 @@ class ConsolePortSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] -class NestedConsolePortSerializer(ConsolePortSerializer): +class ChildConsolePortSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = ConsolePort - fields = ['id', 'name', 'cs_port', 'connection_status'] + fields = ['id', 'url', 'name', 'cs_port', 'connection_status'] # @@ -320,18 +345,18 @@ class NestedConsolePortSerializer(ConsolePortSerializer): # class PowerOutletSerializer(serializers.ModelSerializer): - device = DeviceNestedSerializer() + device = NestedDeviceSerializer() class Meta: model = PowerOutlet fields = ['id', 'device', 'name', 'connected_port'] -class NestedPowerOutletSerializer(PowerOutletSerializer): +class ChildPowerOutletSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = PowerOutlet - fields = ['id', 'name', 'connected_port'] + fields = ['id', 'url', 'name', 'connected_port'] # @@ -339,7 +364,7 @@ class NestedPowerOutletSerializer(PowerOutletSerializer): # class PowerPortSerializer(serializers.ModelSerializer): - device = DeviceNestedSerializer() + device = NestedDeviceSerializer() power_outlet = PowerOutletSerializer() class Meta: @@ -347,11 +372,11 @@ class PowerPortSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] -class NestedPowerPortSerializer(PowerPortSerializer): +class ChildPowerPortSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = PowerPort - fields = ['id', 'name', 'power_outlet', 'connection_status'] + fields = ['id', 'url', 'name', 'power_outlet', 'connection_status'] # @@ -359,7 +384,7 @@ class NestedPowerPortSerializer(PowerPortSerializer): # class InterfaceSerializer(serializers.ModelSerializer): - device = DeviceNestedSerializer() + device = NestedDeviceSerializer() form_factor = serializers.ReadOnlyField(source='get_form_factor_display') class Meta: @@ -367,18 +392,21 @@ class InterfaceSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected'] -class NestedInterfaceSerializer(InterfaceSerializer): +class ChildInterfaceSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Interface - fields = ['id', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected'] + fields = ['id', 'url', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected'] +# TODO: Remove this class InterfaceDetailSerializer(InterfaceSerializer): class Meta(InterfaceSerializer.Meta): - fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected', - 'connected_interface'] + fields = [ + 'id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected', + 'connected_interface', + ] # @@ -386,19 +414,19 @@ class InterfaceDetailSerializer(InterfaceSerializer): # class DeviceBaySerializer(serializers.ModelSerializer): - device = DeviceNestedSerializer() - installed_device = DeviceNestedSerializer() + device = NestedDeviceSerializer() + installed_device = NestedDeviceSerializer() class Meta: model = DeviceBay fields = ['id', 'device', 'name', 'installed_device'] -class NestedDeviceBaySerializer(DeviceBaySerializer): +class ChildDeviceBaySerializer(serializers.HyperlinkedModelSerializer): class Meta: model = DeviceBay - fields = ['id', 'name', 'installed_device'] + fields = ['id', 'url', 'name', 'installed_device'] # @@ -406,19 +434,19 @@ class NestedDeviceBaySerializer(DeviceBaySerializer): # class ModuleSerializer(serializers.ModelSerializer): - device = DeviceNestedSerializer() - manufacturer = ManufacturerNestedSerializer() + device = NestedDeviceSerializer() + manufacturer = NestedManufacturerSerializer() class Meta: model = Module fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] -class NestedModuleSerializer(ModuleSerializer): +class ChildModuleSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Module - fields = ['id', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] + fields = ['id', 'url', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] # diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index d75ecb083..7ac824bac 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -35,37 +35,37 @@ urlpatterns = [ # Devices url(r'^devices/(?P\d+)/lldp-neighbors/$', views.LLDPNeighborsView.as_view(), name='device_lldp-neighbors'), - url(r'^devices/(?P\d+)/console-ports/$', views.NestedConsolePortViewSet.as_view({'get': 'list'}), name='device_consoleports'), - url(r'^devices/(?P\d+)/console-server-ports/$', views.NestedConsoleServerPortViewSet.as_view({'get': 'list'}), name='device_consoleserverports'), - url(r'^devices/(?P\d+)/power-ports/$', views.NestedPowerPortViewSet.as_view({'get': 'list'}), name='device_powerports'), - url(r'^devices/(?P\d+)/power-outlets/$', views.NestedPowerOutletViewSet.as_view({'get': 'list'}), name='device_poweroutlets'), - url(r'^devices/(?P\d+)/interfaces/$', views.NestedInterfaceViewSet.as_view({'get': 'list'}), name='device_interfaces'), - url(r'^devices/(?P\d+)/device-bays/$', views.NestedDeviceBayViewSet.as_view({'get': 'list'}), name='device_devicebays'), - url(r'^devices/(?P\d+)/modules/$', views.NestedModuleViewSet.as_view({'get': 'list'}), name='device_modules'), + url(r'^devices/(?P\d+)/console-ports/$', views.ChildConsolePortViewSet.as_view({'get': 'list'}), name='consoleport-list'), + url(r'^devices/(?P\d+)/console-server-ports/$', views.ChildConsoleServerPortViewSet.as_view({'get': 'list'}), name='consoleserverport-list'), + url(r'^devices/(?P\d+)/power-ports/$', views.NestedPowerPortViewSet.as_view({'get': 'list'}), name='powerport-list'), + url(r'^devices/(?P\d+)/power-outlets/$', views.NestedPowerOutletViewSet.as_view({'get': 'list'}), name='poweroutlet-list'), + url(r'^devices/(?P\d+)/interfaces/$', views.NestedInterfaceViewSet.as_view({'get': 'list'}), name='interface-list'), + url(r'^devices/(?P\d+)/device-bays/$', views.NestedDeviceBayViewSet.as_view({'get': 'list'}), name='devicebay-list'), + url(r'^devices/(?P\d+)/modules/$', views.NestedModuleViewSet.as_view({'get': 'list'}), name='module-list'), # TODO: Services # Console ports - url(r'^console-ports/(?P\d+)/$', views.ConsolePortViewSet.as_view({'get': 'retrieve'}), name='consoleport'), + url(r'^console-ports/(?P\d+)/$', views.ConsolePortViewSet.as_view({'get': 'retrieve'}), name='consoleport-detail'), # Console server ports - url(r'^console-server-ports/(?P\d+)/$', views.ConsoleServerPortViewSet.as_view({'get': 'retrieve'}), name='consoleserverport'), + url(r'^console-server-ports/(?P\d+)/$', views.ConsoleServerPortViewSet.as_view({'get': 'retrieve'}), name='consoleserverport-detail'), # Power ports - url(r'^power-ports/(?P\d+)/$', views.PowerPortViewSet.as_view({'get': 'retrieve'}), name='powerport'), + url(r'^power-ports/(?P\d+)/$', views.PowerPortViewSet.as_view({'get': 'retrieve'}), name='powerport-detail'), # Power outlets - url(r'^power-outlets/(?P\d+)/$', views.PowerOutletViewSet.as_view({'get': 'retrieve'}), name='poweroutlet'), + url(r'^power-outlets/(?P\d+)/$', views.PowerOutletViewSet.as_view({'get': 'retrieve'}), name='poweroutlet-detail'), # Interfaces - url(r'^interfaces/(?P\d+)/$', views.InterfaceViewSet.as_view({'get': 'retrieve'}), name='interface'), + url(r'^interfaces/(?P\d+)/$', views.InterfaceViewSet.as_view({'get': 'retrieve'}), name='interface-detail'), url(r'^interfaces/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_INTERFACE}, name='interface_graphs'), # Device bays - url(r'^device-bays/(?P\d+)/$', views.DeviceBayViewSet.as_view({'get': 'retrieve'}), name='devicebay'), + url(r'^device-bays/(?P\d+)/$', views.DeviceBayViewSet.as_view({'get': 'retrieve'}), name='devicebay-detail'), # Modules - url(r'^modules/(?P\d+)/$', views.ModuleViewSet.as_view({'get': 'retrieve'}), name='module'), + url(r'^modules/(?P\d+)/$', views.ModuleViewSet.as_view({'get': 'retrieve'}), name='module-detail'), # Miscellaneous url(r'^related-connections/$', views.RelatedConnectionsView.as_view(), name='related_connections'), diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 8d4eb13c3..31103b786 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -57,13 +57,9 @@ class RackRoleViewSet(ModelViewSet): class RackViewSet(CustomFieldModelViewSet): queryset = Rack.objects.select_related('site', 'group__site', 'tenant') + serializer_class = serializers.RackSerializer filter_class = filters.RackFilter - def get_serializer_class(self): - if self.action == 'retrieve': - return serializers.RackDetailSerializer - return serializers.RackSerializer - class RackUnitListView(APIView): """ @@ -85,7 +81,10 @@ class RackUnitListView(APIView): # Serialize Devices within the rack elevation for u in elevation: if u['device']: - u['device'] = serializers.DeviceNestedSerializer(instance=u['device']).data + u['device'] = serializers.NestedDeviceSerializer( + instance=u['device'], + context={'request': request}, + ).data return Response(elevation) @@ -105,7 +104,7 @@ class ManufacturerViewSet(ModelViewSet): class DeviceTypeViewSet(CustomFieldModelViewSet): queryset = DeviceType.objects.select_related('manufacturer') - filter_class = filters.DeviceTypeFilter + serializer_class = serializers.DeviceTypeSerializer # @@ -150,8 +149,8 @@ class ConsolePortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin serializer_class = serializers.ConsolePortSerializer -class NestedConsolePortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.NestedConsolePortSerializer +class ChildConsolePortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): + serializer_class = serializers.ChildConsoleServerPortSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -167,8 +166,8 @@ class ConsoleServerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyMode serializer_class = serializers.ConsoleServerPortSerializer -class NestedConsoleServerPortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.NestedConsoleServerPortSerializer +class ChildConsoleServerPortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): + serializer_class = serializers.ChildConsoleServerPortSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -185,7 +184,7 @@ class PowerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, class NestedPowerPortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.NestedPowerPortSerializer + serializer_class = serializers.ChildPowerPortSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -202,7 +201,7 @@ class PowerOutletViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin class NestedPowerOutletViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.NestedPowerOutletSerializer + serializer_class = serializers.ChildPowerOutletSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -219,7 +218,7 @@ class InterfaceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, class NestedInterfaceViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.NestedInterfaceSerializer + serializer_class = serializers.ChildInterfaceSerializer filter_class = filters.InterfaceFilter def get_queryset(self): @@ -238,7 +237,7 @@ class DeviceBayViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, class NestedDeviceBayViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.NestedDeviceBaySerializer + serializer_class = serializers.ChildDeviceBaySerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -255,7 +254,7 @@ class ModuleViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, Gen class NestedModuleViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.NestedModuleSerializer + serializer_class = serializers.ChildModuleSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 1ee82ace4..74ebf073c 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -33,10 +33,12 @@ class CustomFieldModelViewSet(ModelViewSet): custom_field_choices[cfc.id] = cfc.value custom_field_choices = custom_field_choices - return { + context = super(CustomFieldModelViewSet, self).get_serializer_context() + context.update({ 'custom_fields': custom_fields, 'custom_field_choices': custom_field_choices, - } + }) + return context def get_queryset(self): # Prefetch custom field values @@ -55,8 +57,11 @@ class GraphListView(generics.ListAPIView): GRAPH_TYPE_PROVIDER: Provider, GRAPH_TYPE_SITE: Site, } + obj = get_object_or_404(cls[self.kwargs.get('type')], pk=self.kwargs['pk']) context = super(GraphListView, self).get_serializer_context() - context.update({'graphed_object': get_object_or_404(cls[self.kwargs.get('type')], pk=self.kwargs['pk'])}) + context.update({ + 'graphed_object': obj, + }) return context def get_queryset(self): diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 4b69711cf..bab257cc5 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,9 +1,9 @@ from rest_framework import serializers -from dcim.api.serializers import DeviceNestedSerializer, NestedInterfaceSerializer, SiteNestedSerializer +from dcim.api.serializers import NestedDeviceSerializer, ChildInterfaceSerializer, NestedSiteSerializer from extras.api.serializers import CustomFieldSerializer from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from tenancy.api.serializers import TenantNestedSerializer +from tenancy.api.serializers import NestedTenantSerializer # @@ -11,26 +11,18 @@ from tenancy.api.serializers import TenantNestedSerializer # class VRFSerializer(CustomFieldSerializer, serializers.ModelSerializer): - tenant = TenantNestedSerializer() + tenant = NestedTenantSerializer() class Meta: model = VRF fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields'] -class VRFNestedSerializer(VRFSerializer): +class NestedVRFSerializer(serializers.HyperlinkedModelSerializer): - class Meta(VRFSerializer.Meta): - fields = ['id', 'name', 'rd'] - - -class VRFTenantSerializer(VRFSerializer): - """ - Include tenant serializer. Useful for determining tenant inheritance for Prefixes and IPAddresses. - """ - - class Meta(VRFSerializer.Meta): - fields = ['id', 'name', 'rd', 'tenant'] + class Meta: + model = VRF + fields = ['id', 'url', 'name', 'rd'] # @@ -44,10 +36,11 @@ class RoleSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'weight'] -class RoleNestedSerializer(RoleSerializer): +class NestedRoleSerializer(serializers.HyperlinkedModelSerializer): - class Meta(RoleSerializer.Meta): - fields = ['id', 'name', 'slug'] + class Meta: + model = Role + fields = ['id', 'url', 'name', 'slug'] # @@ -61,10 +54,11 @@ class RIRSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'is_private'] -class RIRNestedSerializer(RIRSerializer): +class NestedRIRSerializer(serializers.HyperlinkedModelSerializer): - class Meta(RIRSerializer.Meta): - fields = ['id', 'name', 'slug'] + class Meta: + model = RIR + fields = ['id', 'url', 'name', 'slug'] # @@ -72,17 +66,18 @@ class RIRNestedSerializer(RIRSerializer): # class AggregateSerializer(CustomFieldSerializer, serializers.ModelSerializer): - rir = RIRNestedSerializer() + rir = NestedRIRSerializer() class Meta: model = Aggregate fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields'] -class AggregateNestedSerializer(AggregateSerializer): +class NestedAggregateSerializer(serializers.HyperlinkedModelSerializer): class Meta(AggregateSerializer.Meta): - fields = ['id', 'family', 'prefix'] + model = Aggregate + fields = ['id', 'url', 'family', 'prefix'] # @@ -90,17 +85,18 @@ class AggregateNestedSerializer(AggregateSerializer): # class VLANGroupSerializer(serializers.ModelSerializer): - site = SiteNestedSerializer() + site = NestedSiteSerializer() class Meta: model = VLANGroup fields = ['id', 'name', 'slug', 'site'] -class VLANGroupNestedSerializer(VLANGroupSerializer): +class NestedVLANGroupSerializer(serializers.HyperlinkedModelSerializer): - class Meta(VLANGroupSerializer.Meta): - fields = ['id', 'name', 'slug'] + class Meta: + model = VLANGroup + fields = ['id', 'url', 'name', 'slug'] # @@ -108,21 +104,24 @@ class VLANGroupNestedSerializer(VLANGroupSerializer): # class VLANSerializer(CustomFieldSerializer, serializers.ModelSerializer): - site = SiteNestedSerializer() - group = VLANGroupNestedSerializer() - tenant = TenantNestedSerializer() - role = RoleNestedSerializer() + site = NestedSiteSerializer() + group = NestedVLANGroupSerializer() + tenant = NestedTenantSerializer() + role = NestedRoleSerializer() class Meta: model = VLAN - fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name', - 'custom_fields'] + fields = [ + 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name', + 'custom_fields', + ] -class VLANNestedSerializer(VLANSerializer): +class NestedVLANSerializer(serializers.HyperlinkedModelSerializer): - class Meta(VLANSerializer.Meta): - fields = ['id', 'vid', 'name', 'display_name'] + class Meta: + model = VLAN + fields = ['id', 'url', 'vid', 'name', 'display_name'] # @@ -130,22 +129,25 @@ class VLANNestedSerializer(VLANSerializer): # class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer): - site = SiteNestedSerializer() - vrf = VRFTenantSerializer() - tenant = TenantNestedSerializer() - vlan = VLANNestedSerializer() - role = RoleNestedSerializer() + site = NestedSiteSerializer() + vrf = NestedVRFSerializer() + tenant = NestedTenantSerializer() + vlan = NestedVLANSerializer() + role = NestedRoleSerializer() class Meta: model = Prefix - fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', - 'custom_fields'] + fields = [ + 'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', + 'custom_fields', + ] -class PrefixNestedSerializer(PrefixSerializer): +class NestedPrefixSerializer(serializers.HyperlinkedModelSerializer): - class Meta(PrefixSerializer.Meta): - fields = ['id', 'family', 'prefix'] + class Meta: + model = Prefix + fields = ['id', 'url', 'family', 'prefix'] # @@ -153,23 +155,26 @@ class PrefixNestedSerializer(PrefixSerializer): # class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer): - vrf = VRFTenantSerializer() - tenant = TenantNestedSerializer() - interface = NestedInterfaceSerializer() + vrf = NestedVRFSerializer() + tenant = NestedTenantSerializer() + interface = ChildInterfaceSerializer() class Meta: model = IPAddress - fields = ['id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside', - 'nat_outside', 'custom_fields'] + fields = [ + 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside', + 'nat_outside', 'custom_fields', + ] -class IPAddressNestedSerializer(IPAddressSerializer): +class NestedIPAddressSerializer(serializers.HyperlinkedModelSerializer): - class Meta(IPAddressSerializer.Meta): - fields = ['id', 'family', 'address'] + class Meta: + model = IPAddress + fields = ['id', 'url', 'family', 'address'] -IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer() -IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer() +IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer() +IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer() # @@ -177,15 +182,9 @@ IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer( # class ServiceSerializer(serializers.ModelSerializer): - device = DeviceNestedSerializer() - ipaddresses = IPAddressNestedSerializer(many=True) + device = NestedDeviceSerializer() + ipaddresses = NestedIPAddressSerializer(many=True) class Meta: model = Service fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] - - -class ServiceNestedSerializer(ServiceSerializer): - - class Meta(ServiceSerializer.Meta): - fields = ['id', 'name', 'port', 'protocol'] diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 11787adae..fe6fff333 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -26,12 +26,12 @@ _patterns = [ url(r'^profile/', include('users.urls', namespace='users')), # API - url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')), - url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')), - url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')), - url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')), - url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')), - url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), + url(r'^api/circuits/', include('circuits.api.urls')), + url(r'^api/dcim/', include('dcim.api.urls')), + url(r'^api/ipam/', include('ipam.api.urls')), + url(r'^api/secrets/', include('secrets.api.urls')), + url(r'^api/tenancy/', include('tenancy.api.urls')), + url(r'^api-auth/', include('rest_framework.urls')), # Error testing url(r'^500/$', trigger_500), diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index f16af39c3..40c024e1c 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -1,7 +1,6 @@ from rest_framework import serializers -from dcim.models import Device -from ipam.api.serializers import IPAddressNestedSerializer +from dcim.api.serializers import NestedDeviceSerializer from secrets.models import Secret, SecretRole @@ -16,34 +15,21 @@ class SecretRoleSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug'] -class SecretRoleNestedSerializer(SecretRoleSerializer): +class NestedSecretRoleSerializer(serializers.HyperlinkedModelSerializer): - class Meta(SecretRoleSerializer.Meta): - pass + class Meta: + model = SecretRole + fields = ['id', 'url', 'name', 'slug'] # # Secrets # -class SecretDeviceSerializer(serializers.ModelSerializer): - primary_ip = IPAddressNestedSerializer() - - class Meta: - model = Device - fields = ['id', 'name', 'primary_ip'] - - class SecretSerializer(serializers.ModelSerializer): - device = SecretDeviceSerializer() - role = SecretRoleNestedSerializer() + device = NestedDeviceSerializer() + role = NestedSecretRoleSerializer() class Meta: model = Secret fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated'] - - -class SecretNestedSerializer(SecretSerializer): - - class Meta(SecretSerializer.Meta): - fields = ['id', 'name'] diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 6d22561ef..afd634129 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -15,10 +15,11 @@ class TenantGroupSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug'] -class TenantGroupNestedSerializer(TenantGroupSerializer): +class NestedTenantGroupSerializer(serializers.HyperlinkedModelSerializer): - class Meta(TenantGroupSerializer.Meta): - pass + class Meta: + model = TenantGroup + fields = ['id', 'url', 'name', 'slug'] # @@ -26,14 +27,15 @@ class TenantGroupNestedSerializer(TenantGroupSerializer): # class TenantSerializer(CustomFieldSerializer, serializers.ModelSerializer): - group = TenantGroupNestedSerializer() + group = NestedTenantGroupSerializer() class Meta: model = Tenant fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields'] -class TenantNestedSerializer(TenantSerializer): +class NestedTenantSerializer(serializers.HyperlinkedModelSerializer): - class Meta(TenantSerializer.Meta): - fields = ['id', 'name', 'slug'] + class Meta: + model = Tenant + fields = ['id', 'url', 'name', 'slug'] From 12d263999bab1e19ad92b5236cbd2e803edcd019 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jan 2017 14:36:13 -0500 Subject: [PATCH 009/182] Introduced WritableSerializerMixin --- netbox/circuits/api/views.py | 14 +++++------ netbox/dcim/api/views.py | 46 +++++++++++++++++++--------------- netbox/ipam/api/serializers.py | 3 ++- netbox/ipam/api/views.py | 15 +++++------ netbox/tenancy/api/views.py | 3 ++- netbox/utilities/api.py | 24 ++++++++++++++++++ 6 files changed, 68 insertions(+), 37 deletions(-) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 731602975..ccfe57a4c 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -9,6 +9,7 @@ from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from circuits.filters import CircuitFilter from extras.api.views import CustomFieldModelViewSet +from utilities.api import WritableSerializerMixin from . import serializers @@ -34,26 +35,23 @@ class CircuitTypeViewSet(ModelViewSet): # Circuits # -class CircuitViewSet(CustomFieldModelViewSet): +class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Circuit.objects.select_related('type', 'tenant', 'provider') + serializer_class = serializers.CircuitSerializer filter_class = CircuitFilter - def get_serializer_class(self): - if self.action == 'retrieve': - return serializers.CircuitDetailSerializer - return serializers.CircuitSerializer - # # Circuit Terminations # -class CircuitTerminationViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): +class CircuitTerminationViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, + GenericViewSet): queryset = CircuitTermination.objects.select_related('site', 'interface__device') serializer_class = serializers.CircuitTerminationSerializer -class NestedCircuitTerminationViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): +class NestedCircuitTerminationViewSet(CreateModelMixin, ListModelMixin ,WritableSerializerMixin, GenericViewSet): serializer_class = serializers.CircuitTerminationSerializer def get_queryset(self): diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 31103b786..44ebd4677 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -18,7 +18,7 @@ from dcim.models import ( from dcim import filters from extras.api.views import CustomFieldModelViewSet from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer -from utilities.api import ServiceUnavailable +from utilities.api import ServiceUnavailable, WritableSerializerMixin from .exceptions import MissingFilterException from . import serializers @@ -27,7 +27,7 @@ from . import serializers # Sites # -class SiteViewSet(CustomFieldModelViewSet): +class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Site.objects.select_related('tenant') serializer_class = serializers.SiteSerializer @@ -36,7 +36,7 @@ class SiteViewSet(CustomFieldModelViewSet): # Rack groups # -class RackGroupViewSet(ModelViewSet): +class RackGroupViewSet(WritableSerializerMixin, ModelViewSet): queryset = RackGroup.objects.select_related('site') serializer_class = serializers.RackGroupSerializer filter_class = filters.RackGroupFilter @@ -55,7 +55,7 @@ class RackRoleViewSet(ModelViewSet): # Racks # -class RackViewSet(CustomFieldModelViewSet): +class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Rack.objects.select_related('site', 'group__site', 'tenant') serializer_class = serializers.RackSerializer filter_class = filters.RackFilter @@ -102,7 +102,7 @@ class ManufacturerViewSet(ModelViewSet): # Device Types # -class DeviceTypeViewSet(CustomFieldModelViewSet): +class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = DeviceType.objects.select_related('manufacturer') serializer_class = serializers.DeviceTypeSerializer @@ -129,7 +129,7 @@ class PlatformViewSet(ModelViewSet): # Devices # -class DeviceViewSet(CustomFieldModelViewSet): +class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Device.objects.select_related( 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'rack__site', 'parent_bay', ).prefetch_related( @@ -144,12 +144,13 @@ class DeviceViewSet(CustomFieldModelViewSet): # Console Ports # -class ConsolePortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): +class ConsolePortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, + GenericViewSet): queryset = ConsolePort.objects.select_related('cs_port') serializer_class = serializers.ConsolePortSerializer -class ChildConsolePortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): +class ChildConsolePortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildConsoleServerPortSerializer def get_queryset(self): @@ -161,12 +162,13 @@ class ChildConsolePortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): # Console Server Ports # -class ConsoleServerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): +class ConsoleServerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, + GenericViewSet): queryset = ConsoleServerPort.objects.select_related('connected_console') serializer_class = serializers.ConsoleServerPortSerializer -class ChildConsoleServerPortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): +class ChildConsoleServerPortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildConsoleServerPortSerializer def get_queryset(self): @@ -178,12 +180,13 @@ class ChildConsoleServerPortViewSet(CreateModelMixin, ListModelMixin, GenericVie # Power Ports # -class PowerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): +class PowerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, + GenericViewSet): queryset = PowerPort.objects.select_related('power_outlet') serializer_class = serializers.PowerPortSerializer -class NestedPowerPortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): +class NestedPowerPortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildPowerPortSerializer def get_queryset(self): @@ -195,12 +198,13 @@ class NestedPowerPortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): # Power Outlets # -class PowerOutletViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): +class PowerOutletViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, + GenericViewSet): queryset = PowerOutlet.objects.select_related('connected_port') serializer_class = serializers.PowerOutletSerializer -class NestedPowerOutletViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): +class NestedPowerOutletViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildPowerOutletSerializer def get_queryset(self): @@ -212,12 +216,13 @@ class NestedPowerOutletViewSet(CreateModelMixin, ListModelMixin, GenericViewSet) # Interfaces # -class InterfaceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): +class InterfaceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, + GenericViewSet): queryset = Interface.objects.select_related('device') serializer_class = serializers.InterfaceDetailSerializer -class NestedInterfaceViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): +class NestedInterfaceViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildInterfaceSerializer filter_class = filters.InterfaceFilter @@ -231,12 +236,13 @@ class NestedInterfaceViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): # Device bays # -class DeviceBayViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): +class DeviceBayViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, + GenericViewSet): queryset = DeviceBay.objects.select_related('installed_device') serializer_class = serializers.DeviceBaySerializer -class NestedDeviceBayViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): +class NestedDeviceBayViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildDeviceBaySerializer def get_queryset(self): @@ -248,12 +254,12 @@ class NestedDeviceBayViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): # Modules # -class ModuleViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): +class ModuleViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, GenericViewSet): queryset = Module.objects.select_related('device', 'manufacturer') serializer_class = serializers.ModuleSerializer -class NestedModuleViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): +class NestedModuleViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildModuleSerializer def get_queryset(self): diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index bab257cc5..4394cc1a8 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -4,6 +4,7 @@ from dcim.api.serializers import NestedDeviceSerializer, ChildInterfaceSerialize from extras.api.serializers import CustomFieldSerializer from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.serializers import NestedTenantSerializer +from utilities.api import WritableSerializerMixin # @@ -84,7 +85,7 @@ class NestedAggregateSerializer(serializers.HyperlinkedModelSerializer): # VLAN groups # -class VLANGroupSerializer(serializers.ModelSerializer): +class VLANGroupSerializer(WritableSerializerMixin, serializers.ModelSerializer): site = NestedSiteSerializer() class Meta: diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 80ff10c6f..94d1e6814 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -4,6 +4,7 @@ from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, from ipam import filters from extras.api.views import CustomFieldModelViewSet +from utilities.api import WritableSerializerMixin from . import serializers @@ -11,7 +12,7 @@ from . import serializers # VRFs # -class VRFViewSet(CustomFieldModelViewSet): +class VRFViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = VRF.objects.select_related('tenant') serializer_class = serializers.VRFSerializer filter_class = filters.VRFFilter @@ -39,7 +40,7 @@ class RIRViewSet(ModelViewSet): # Aggregates # -class AggregateViewSet(CustomFieldModelViewSet): +class AggregateViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Aggregate.objects.select_related('rir') serializer_class = serializers.AggregateSerializer filter_class = filters.AggregateFilter @@ -49,7 +50,7 @@ class AggregateViewSet(CustomFieldModelViewSet): # Prefixes # -class PrefixViewSet(CustomFieldModelViewSet): +class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') serializer_class = serializers.PrefixSerializer filter_class = filters.PrefixFilter @@ -59,7 +60,7 @@ class PrefixViewSet(CustomFieldModelViewSet): # IP addresses # -class IPAddressViewSet(CustomFieldModelViewSet): +class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside') serializer_class = serializers.IPAddressSerializer filter_class = filters.IPAddressFilter @@ -69,7 +70,7 @@ class IPAddressViewSet(CustomFieldModelViewSet): # VLAN groups # -class VLANGroupViewSet(ModelViewSet): +class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet): queryset = VLANGroup.objects.select_related('site') serializer_class = serializers.VLANGroupSerializer filter_class = filters.VLANGroupFilter @@ -79,7 +80,7 @@ class VLANGroupViewSet(ModelViewSet): # VLANs # -class VLANViewSet(CustomFieldModelViewSet): +class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') serializer_class = serializers.VLANSerializer filter_class = filters.VLANFilter @@ -89,7 +90,7 @@ class VLANViewSet(CustomFieldModelViewSet): # Services # -class ServiceViewSet(ModelViewSet): +class ServiceViewSet(WritableSerializerMixin, ModelViewSet): queryset = Service.objects.select_related('device').prefetch_related('ipaddresses') serializer_class = serializers.ServiceSerializer filter_class = filters.ServiceFilter diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 17d0e79ef..d288dd9f2 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -4,6 +4,7 @@ from tenancy.models import Tenant, TenantGroup from tenancy.filters import TenantFilter from extras.api.views import CustomFieldModelViewSet +from utilities.api import WritableSerializerMixin from . import serializers @@ -20,7 +21,7 @@ class TenantGroupViewSet(ModelViewSet): # Tenants # -class TenantViewSet(CustomFieldModelViewSet): +class TenantViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Tenant.objects.select_related('group') serializer_class = serializers.TenantSerializer filter_class = TenantFilter diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index ff35fd293..c25f7e842 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,6 +1,30 @@ from rest_framework.exceptions import APIException +from rest_framework.serializers import ModelSerializer + + +WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] class ServiceUnavailable(APIException): status_code = 503 default_detail = "Service temporarily unavailable, please try again later." + + +class WritableSerializerMixin(object): + """ + Returns a flat Serializer from the given model suitable for write operations (POST, PUT, PATCH). This is necessary + to allow write operations on objects which utilize nested serializers. + """ + + def get_serializer_class(self): + + class WritableSerializer(ModelSerializer): + + class Meta: + model = self.queryset.model + fields = '__all__' + + if self.action in WRITE_OPERATIONS: + return WritableSerializer + + return self.serializer_class From a3d0d4a5bff0a88028eec42e4b8caad32f5ddf7f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jan 2017 14:54:12 -0500 Subject: [PATCH 010/182] Enabled pagination --- netbox/netbox/settings.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index dc87f6655..74bf6ae10 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -181,9 +181,11 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH) # Secrets SECRETS_MIN_PUBKEY_SIZE = 2048 -# Django REST framework +# Django REST framework (API) REST_FRAMEWORK = { - 'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',) + 'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',), + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': PAGINATE_COUNT, } if LOGIN_REQUIRED: REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = ('rest_framework.permissions.IsAuthenticated',) From c0dac1383d8ea4847f08a06f997bbf80f1cafca1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jan 2017 15:12:46 -0500 Subject: [PATCH 011/182] Fix retrieval of model under viewsets without a statically defined queryset --- netbox/utilities/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index c25f7e842..a69e4f369 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -21,7 +21,7 @@ class WritableSerializerMixin(object): class WritableSerializer(ModelSerializer): class Meta: - model = self.queryset.model + model = self.get_queryset().model fields = '__all__' if self.action in WRITE_OPERATIONS: From 0cf029edd478e6109a54d61f69064247a449885b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jan 2017 16:19:38 -0500 Subject: [PATCH 012/182] Added Service serializers --- netbox/dcim/api/urls.py | 13 +++++++------ netbox/dcim/api/views.py | 12 ++++++------ netbox/ipam/api/serializers.py | 8 ++++++++ netbox/ipam/api/views.py | 22 +++++++++++++++++----- 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 7ac824bac..c649d1ab6 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -4,6 +4,7 @@ from rest_framework import routers from extras.models import GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from extras.api.views import GraphListView, TopologyMapView +from ipam.api.views import ChildServiceViewSet from . import views @@ -37,12 +38,12 @@ urlpatterns = [ url(r'^devices/(?P\d+)/lldp-neighbors/$', views.LLDPNeighborsView.as_view(), name='device_lldp-neighbors'), url(r'^devices/(?P\d+)/console-ports/$', views.ChildConsolePortViewSet.as_view({'get': 'list'}), name='consoleport-list'), url(r'^devices/(?P\d+)/console-server-ports/$', views.ChildConsoleServerPortViewSet.as_view({'get': 'list'}), name='consoleserverport-list'), - url(r'^devices/(?P\d+)/power-ports/$', views.NestedPowerPortViewSet.as_view({'get': 'list'}), name='powerport-list'), - url(r'^devices/(?P\d+)/power-outlets/$', views.NestedPowerOutletViewSet.as_view({'get': 'list'}), name='poweroutlet-list'), - url(r'^devices/(?P\d+)/interfaces/$', views.NestedInterfaceViewSet.as_view({'get': 'list'}), name='interface-list'), - url(r'^devices/(?P\d+)/device-bays/$', views.NestedDeviceBayViewSet.as_view({'get': 'list'}), name='devicebay-list'), - url(r'^devices/(?P\d+)/modules/$', views.NestedModuleViewSet.as_view({'get': 'list'}), name='module-list'), - # TODO: Services + url(r'^devices/(?P\d+)/power-ports/$', views.ChildPowerPortViewSet.as_view({'get': 'list'}), name='powerport-list'), + url(r'^devices/(?P\d+)/power-outlets/$', views.ChildPowerOutletViewSet.as_view({'get': 'list'}), name='poweroutlet-list'), + url(r'^devices/(?P\d+)/interfaces/$', views.ChildInterfaceViewSet.as_view({'get': 'list'}), name='interface-list'), + url(r'^devices/(?P\d+)/device-bays/$', views.ChildDeviceBayViewSet.as_view({'get': 'list'}), name='devicebay-list'), + url(r'^devices/(?P\d+)/modules/$', views.ChildModuleViewSet.as_view({'get': 'list'}), name='module-list'), + url(r'^devices/(?P\d+)/services/$', ChildServiceViewSet.as_view({'get': 'list'}), name='service-list'), # Console ports url(r'^console-ports/(?P\d+)/$', views.ConsolePortViewSet.as_view({'get': 'retrieve'}), name='consoleport-detail'), diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 44ebd4677..b5946ff38 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -151,7 +151,7 @@ class ConsolePortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin class ChildConsolePortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): - serializer_class = serializers.ChildConsoleServerPortSerializer + serializer_class = serializers.ChildConsolePortSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -186,7 +186,7 @@ class PowerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, serializer_class = serializers.PowerPortSerializer -class NestedPowerPortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class ChildPowerPortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildPowerPortSerializer def get_queryset(self): @@ -204,7 +204,7 @@ class PowerOutletViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin serializer_class = serializers.PowerOutletSerializer -class NestedPowerOutletViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class ChildPowerOutletViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildPowerOutletSerializer def get_queryset(self): @@ -222,7 +222,7 @@ class InterfaceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, serializer_class = serializers.InterfaceDetailSerializer -class NestedInterfaceViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class ChildInterfaceViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildInterfaceSerializer filter_class = filters.InterfaceFilter @@ -242,7 +242,7 @@ class DeviceBayViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, serializer_class = serializers.DeviceBaySerializer -class NestedDeviceBayViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class ChildDeviceBayViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildDeviceBaySerializer def get_queryset(self): @@ -259,7 +259,7 @@ class ModuleViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, Wri serializer_class = serializers.ModuleSerializer -class NestedModuleViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class ChildModuleViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildModuleSerializer def get_queryset(self): diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 4394cc1a8..8c7fb2fa7 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -189,3 +189,11 @@ class ServiceSerializer(serializers.ModelSerializer): class Meta: model = Service fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] + + +class ChildServiceSerializer(serializers.HyperlinkedModelSerializer): + ipaddresses = NestedIPAddressSerializer(many=True) + + class Meta: + model = Service + fields = ['id', 'url', 'name', 'port', 'protocol', 'ipaddresses', 'description'] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 94d1e6814..feedac9a7 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,8 +1,13 @@ -from rest_framework.viewsets import ModelViewSet +from django.shortcuts import get_object_or_404 +from rest_framework.mixins import ( + CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin, +) +from rest_framework.viewsets import GenericViewSet, ModelViewSet + +from dcim.models import Device from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam import filters - from extras.api.views import CustomFieldModelViewSet from utilities.api import WritableSerializerMixin from . import serializers @@ -90,7 +95,14 @@ class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # Services # -class ServiceViewSet(WritableSerializerMixin, ModelViewSet): - queryset = Service.objects.select_related('device').prefetch_related('ipaddresses') +class ServiceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, GenericViewSet): + queryset = Service.objects.select_related('device') serializer_class = serializers.ServiceSerializer - filter_class = filters.ServiceFilter + + +class ChildServiceViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): + serializer_class = serializers.ChildServiceSerializer + + def get_queryset(self): + device = get_object_or_404(Device, pk=self.kwargs['pk']) + return Service.objects.filter(device=device).select_related('device') From f0fef94a4fd461ff670ecd408c8027cf6f83a744 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jan 2017 15:35:01 -0500 Subject: [PATCH 013/182] Re-implemented interface/connection serializers --- netbox/circuits/api/serializers.py | 4 +- netbox/dcim/api/serializers.py | 90 ++++++++++++++++++++---------- netbox/dcim/api/views.py | 14 ++--- netbox/ipam/api/serializers.py | 4 +- 4 files changed, 70 insertions(+), 42 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index d5f229a82..68b26cff7 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from circuits.models import Provider, Circuit, CircuitTermination, CircuitType -from dcim.api.serializers import NestedSiteSerializer, ChildInterfaceSerializer +from dcim.api.serializers import NestedSiteSerializer, DeviceInterfaceSerializer from extras.api.serializers import CustomFieldSerializer from tenancy.api.serializers import NestedTenantSerializer @@ -51,7 +51,7 @@ class NestedCircuitTypeSerializer(serializers.HyperlinkedModelSerializer): class CircuitTerminationSerializer(serializers.ModelSerializer): site = NestedSiteSerializer() - interface = ChildInterfaceSerializer() + interface = DeviceInterfaceSerializer() class Meta: model = CircuitTermination diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index c6e68b9cb..e7c9ab80d 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -313,7 +313,7 @@ class ConsoleServerPortSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'connected_console'] -class ChildConsoleServerPortSerializer(serializers.HyperlinkedModelSerializer): +class DeviceConsoleServerPortSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = ConsoleServerPort @@ -333,7 +333,7 @@ class ConsolePortSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] -class ChildConsolePortSerializer(serializers.HyperlinkedModelSerializer): +class DeviceConsolePortSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = ConsolePort @@ -352,7 +352,7 @@ class PowerOutletSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'connected_port'] -class ChildPowerOutletSerializer(serializers.HyperlinkedModelSerializer): +class DevicePowerOutletSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = PowerOutlet @@ -372,7 +372,7 @@ class PowerPortSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] -class ChildPowerPortSerializer(serializers.HyperlinkedModelSerializer): +class DevicePowerPortSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = PowerPort @@ -383,31 +383,70 @@ class ChildPowerPortSerializer(serializers.HyperlinkedModelSerializer): # Interfaces # + class InterfaceSerializer(serializers.ModelSerializer): device = NestedDeviceSerializer() - form_factor = serializers.ReadOnlyField(source='get_form_factor_display') + connection = serializers.SerializerMethodField(read_only=True) + connected_interface = serializers.SerializerMethodField(read_only=True) class Meta: model = Interface - fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected'] - - -class ChildInterfaceSerializer(serializers.HyperlinkedModelSerializer): - - class Meta: - model = Interface - fields = ['id', 'url', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected'] - - -# TODO: Remove this -class InterfaceDetailSerializer(InterfaceSerializer): - - class Meta(InterfaceSerializer.Meta): fields = [ - 'id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected', + 'id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'connection', 'connected_interface', ] + def get_connection(self, obj): + if obj.connection: + return NestedInterfaceConnectionSerializer(obj.connection, context=self.context).data + return None + + def get_connected_interface(self, obj): + if obj.connected_interface: + return PeerInterfaceSerializer(obj.connected_interface, context=self.context).data + return None + + +class PeerInterfaceSerializer(serializers.HyperlinkedModelSerializer): + device = NestedDeviceSerializer() + + class Meta: + model = Interface + fields = ['id', 'url', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description'] + + +class DeviceInterfaceSerializer(serializers.HyperlinkedModelSerializer): + connection = serializers.SerializerMethodField() + + class Meta: + model = Interface + fields = ['id', 'url', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'connection'] + + def get_connection(self, obj): + if obj.connection: + return NestedInterfaceConnectionSerializer(obj.connection, context=self.context).data + return None + + +# +# Interface connections +# + +class InterfaceConnectionSerializer(serializers.ModelSerializer): + interface_a = PeerInterfaceSerializer() + interface_b = PeerInterfaceSerializer() + + class Meta: + model = InterfaceConnection + fields = ['id', 'interface_a', 'interface_b', 'connection_status'] + + +class NestedInterfaceConnectionSerializer(serializers.HyperlinkedModelSerializer): + + class Meta: + model = InterfaceConnection + fields = ['id', 'url', 'connection_status'] + # # Device bays @@ -447,14 +486,3 @@ class ChildModuleSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Module fields = ['id', 'url', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] - - -# -# Interface connections -# - -class InterfaceConnectionSerializer(serializers.ModelSerializer): - - class Meta: - model = InterfaceConnection - fields = ['id', 'interface_a', 'interface_b', 'connection_status'] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index b5946ff38..9d3f5f681 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -151,7 +151,7 @@ class ConsolePortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin class ChildConsolePortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): - serializer_class = serializers.ChildConsolePortSerializer + serializer_class = serializers.DeviceConsolePortSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -169,7 +169,7 @@ class ConsoleServerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyMode class ChildConsoleServerPortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): - serializer_class = serializers.ChildConsoleServerPortSerializer + serializer_class = serializers.DeviceConsoleServerPortSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -187,7 +187,7 @@ class PowerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, class ChildPowerPortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): - serializer_class = serializers.ChildPowerPortSerializer + serializer_class = serializers.DevicePowerPortSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -205,7 +205,7 @@ class PowerOutletViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin class ChildPowerOutletViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): - serializer_class = serializers.ChildPowerOutletSerializer + serializer_class = serializers.DevicePowerOutletSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -219,11 +219,11 @@ class ChildPowerOutletViewSet(CreateModelMixin, ListModelMixin, WritableSerializ class InterfaceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, GenericViewSet): queryset = Interface.objects.select_related('device') - serializer_class = serializers.InterfaceDetailSerializer + serializer_class = serializers.InterfaceSerializer class ChildInterfaceViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): - serializer_class = serializers.ChildInterfaceSerializer + serializer_class = serializers.DeviceInterfaceSerializer filter_class = filters.InterfaceFilter def get_queryset(self): @@ -380,7 +380,7 @@ class RelatedConnectionsView(APIView): interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\ .select_related('connected_as_a', 'connected_as_b', 'circuit_termination') for iface in interfaces: - data = serializers.InterfaceDetailSerializer(instance=iface).data + data = serializers.InterfaceSerializer(instance=iface).data del(data['device']) response['interfaces'].append(data) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 8c7fb2fa7..37b42af6c 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from dcim.api.serializers import NestedDeviceSerializer, ChildInterfaceSerializer, NestedSiteSerializer +from dcim.api.serializers import NestedDeviceSerializer, DeviceInterfaceSerializer, NestedSiteSerializer from extras.api.serializers import CustomFieldSerializer from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.serializers import NestedTenantSerializer @@ -158,7 +158,7 @@ class NestedPrefixSerializer(serializers.HyperlinkedModelSerializer): class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer): vrf = NestedVRFSerializer() tenant = NestedTenantSerializer() - interface = ChildInterfaceSerializer() + interface = DeviceInterfaceSerializer() class Meta: model = IPAddress From 7beac0b105ba71d0d3b71ba62d13d7d670f6ebb5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jan 2017 16:15:12 -0500 Subject: [PATCH 014/182] Converted device component views to a router --- netbox/dcim/api/urls.py | 57 ++++++++++++++++++++-------------------- netbox/dcim/api/views.py | 14 +++++----- netbox/ipam/api/views.py | 2 +- 3 files changed, 37 insertions(+), 36 deletions(-) diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index c649d1ab6..a7c17b368 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -4,26 +4,54 @@ from rest_framework import routers from extras.models import GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from extras.api.views import GraphListView, TopologyMapView -from ipam.api.views import ChildServiceViewSet +from ipam.api.views import ServiceViewSet, DeviceServiceViewSet from . import views router = routers.DefaultRouter() + +# Sites router.register(r'sites', views.SiteViewSet) + +# Racks router.register(r'rack-groups', views.RackGroupViewSet) router.register(r'rack-roles', views.RackRoleViewSet) router.register(r'racks', views.RackViewSet) + +# Device types router.register(r'manufacturers', views.ManufacturerViewSet) router.register(r'device-types', views.DeviceTypeViewSet) + +# Devices router.register(r'device-roles', views.DeviceRoleViewSet) router.register(r'platforms', views.PlatformViewSet) router.register(r'devices', views.DeviceViewSet) +router.register(r'console-ports', views.ConsolePortViewSet) +router.register(r'console-server-ports', views.ConsoleServerPortViewSet) +router.register(r'power-ports', views.PowerPortViewSet) +router.register(r'power-outlets', views.PowerOutletViewSet) +router.register(r'interfaces', views.InterfaceViewSet) router.register(r'interface-connections', views.InterfaceConnectionViewSet) +router.register(r'device-bays', views.DeviceBayViewSet) +router.register(r'modules', views.ModuleViewSet) +router.register(r'services', ServiceViewSet) + +# Device components +device_router = routers.DefaultRouter() +device_router.register(r'console-ports', views.DeviceConsolePortViewSet, base_name='consoleport') +device_router.register(r'console-server-ports', views.DeviceConsoleServerPortViewSet, base_name='consoleserverport') +device_router.register(r'power-ports', views.DevicePowerPortViewSet, base_name='powerport') +device_router.register(r'power-outlets', views.DevicePowerOutletViewSet, base_name='poweroutlet') +device_router.register(r'interfaces', views.DeviceInterfaceViewSet, base_name='interface') +device_router.register(r'device-bays', views.DeviceDeviceBayViewSet, base_name='devicebay') +device_router.register(r'modules', views.DeviceModuleViewSet, base_name='module') +device_router.register(r'services', DeviceServiceViewSet, base_name='service') urlpatterns = [ url(r'', include(router.urls)), + url(r'^devices/(?P\d+)/', include(device_router.urls)), # Sites url(r'^sites/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_SITE}, name='site_graphs'), @@ -36,38 +64,11 @@ urlpatterns = [ # Devices url(r'^devices/(?P\d+)/lldp-neighbors/$', views.LLDPNeighborsView.as_view(), name='device_lldp-neighbors'), - url(r'^devices/(?P\d+)/console-ports/$', views.ChildConsolePortViewSet.as_view({'get': 'list'}), name='consoleport-list'), - url(r'^devices/(?P\d+)/console-server-ports/$', views.ChildConsoleServerPortViewSet.as_view({'get': 'list'}), name='consoleserverport-list'), - url(r'^devices/(?P\d+)/power-ports/$', views.ChildPowerPortViewSet.as_view({'get': 'list'}), name='powerport-list'), - url(r'^devices/(?P\d+)/power-outlets/$', views.ChildPowerOutletViewSet.as_view({'get': 'list'}), name='poweroutlet-list'), - url(r'^devices/(?P\d+)/interfaces/$', views.ChildInterfaceViewSet.as_view({'get': 'list'}), name='interface-list'), - url(r'^devices/(?P\d+)/device-bays/$', views.ChildDeviceBayViewSet.as_view({'get': 'list'}), name='devicebay-list'), - url(r'^devices/(?P\d+)/modules/$', views.ChildModuleViewSet.as_view({'get': 'list'}), name='module-list'), - url(r'^devices/(?P\d+)/services/$', ChildServiceViewSet.as_view({'get': 'list'}), name='service-list'), - - # Console ports - url(r'^console-ports/(?P\d+)/$', views.ConsolePortViewSet.as_view({'get': 'retrieve'}), name='consoleport-detail'), - - # Console server ports - url(r'^console-server-ports/(?P\d+)/$', views.ConsoleServerPortViewSet.as_view({'get': 'retrieve'}), name='consoleserverport-detail'), - - # Power ports - url(r'^power-ports/(?P\d+)/$', views.PowerPortViewSet.as_view({'get': 'retrieve'}), name='powerport-detail'), - - # Power outlets - url(r'^power-outlets/(?P\d+)/$', views.PowerOutletViewSet.as_view({'get': 'retrieve'}), name='poweroutlet-detail'), # Interfaces - url(r'^interfaces/(?P\d+)/$', views.InterfaceViewSet.as_view({'get': 'retrieve'}), name='interface-detail'), url(r'^interfaces/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_INTERFACE}, name='interface_graphs'), - # Device bays - url(r'^device-bays/(?P\d+)/$', views.DeviceBayViewSet.as_view({'get': 'retrieve'}), name='devicebay-detail'), - - # Modules - url(r'^modules/(?P\d+)/$', views.ModuleViewSet.as_view({'get': 'retrieve'}), name='module-detail'), - # Miscellaneous url(r'^related-connections/$', views.RelatedConnectionsView.as_view(), name='related_connections'), url(r'^topology-maps/(?P[\w-]+)/$', TopologyMapView.as_view(), name='topology_map'), diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 9d3f5f681..472fc6931 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -150,7 +150,7 @@ class ConsolePortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin serializer_class = serializers.ConsolePortSerializer -class ChildConsolePortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DeviceConsolePortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.DeviceConsolePortSerializer def get_queryset(self): @@ -168,7 +168,7 @@ class ConsoleServerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyMode serializer_class = serializers.ConsoleServerPortSerializer -class ChildConsoleServerPortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DeviceConsoleServerPortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.DeviceConsoleServerPortSerializer def get_queryset(self): @@ -186,7 +186,7 @@ class PowerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, serializer_class = serializers.PowerPortSerializer -class ChildPowerPortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DevicePowerPortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.DevicePowerPortSerializer def get_queryset(self): @@ -204,7 +204,7 @@ class PowerOutletViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin serializer_class = serializers.PowerOutletSerializer -class ChildPowerOutletViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DevicePowerOutletViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.DevicePowerOutletSerializer def get_queryset(self): @@ -222,7 +222,7 @@ class InterfaceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, serializer_class = serializers.InterfaceSerializer -class ChildInterfaceViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DeviceInterfaceViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.DeviceInterfaceSerializer filter_class = filters.InterfaceFilter @@ -242,7 +242,7 @@ class DeviceBayViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, serializer_class = serializers.DeviceBaySerializer -class ChildDeviceBayViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DeviceDeviceBayViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildDeviceBaySerializer def get_queryset(self): @@ -259,7 +259,7 @@ class ModuleViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, Wri serializer_class = serializers.ModuleSerializer -class ChildModuleViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DeviceModuleViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildModuleSerializer def get_queryset(self): diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index feedac9a7..aba741291 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -100,7 +100,7 @@ class ServiceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, Wr serializer_class = serializers.ServiceSerializer -class ChildServiceViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DeviceServiceViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.ChildServiceSerializer def get_queryset(self): From d9e4017677e4447589e2698f1a51aa470a1d59b2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jan 2017 17:00:58 -0500 Subject: [PATCH 015/182] Moved graph views into model viewsets --- netbox/circuits/api/urls.py | 4 ---- netbox/circuits/api/views.py | 11 +++++++++++ netbox/dcim/api/urls.py | 12 ++---------- netbox/dcim/api/views.py | 21 +++++++++++++++++++-- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 0dd2d2a94..f06b14165 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -18,10 +18,6 @@ urlpatterns = [ url(r'', include(router.urls)), - # Providers - url(r'^providers/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_PROVIDER}, - name='provider_graphs'), - # Circuits url(r'^circuits/(?P\d+)/terminations/$', views.NestedCircuitTerminationViewSet.as_view({'get': 'list'})), diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index ccfe57a4c..1fdafa97b 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,13 +1,17 @@ from django.shortcuts import get_object_or_404 +from rest_framework.decorators import detail_route from rest_framework.mixins import ( CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin, ) +from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet, ModelViewSet from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from circuits.filters import CircuitFilter +from extras.models import Graph, GRAPH_TYPE_PROVIDER +from extras.api.serializers import GraphSerializer from extras.api.views import CustomFieldModelViewSet from utilities.api import WritableSerializerMixin from . import serializers @@ -21,6 +25,13 @@ class ProviderViewSet(CustomFieldModelViewSet): queryset = Provider.objects.all() serializer_class = serializers.ProviderSerializer + @detail_route() + def graphs(self, request, pk=None): + provider = get_object_or_404(Provider, pk=pk) + queryset = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER) + serializer = GraphSerializer(queryset, many=True, context={'graphed_object': provider}) + return Response(serializer.data) + # # Circuit Types diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index a7c17b368..27f3813f2 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -37,6 +37,8 @@ router.register(r'device-bays', views.DeviceBayViewSet) router.register(r'modules', views.ModuleViewSet) router.register(r'services', ServiceViewSet) +# TODO: Device type components + # Device components device_router = routers.DefaultRouter() device_router.register(r'console-ports', views.DeviceConsolePortViewSet, base_name='consoleport') @@ -53,22 +55,12 @@ urlpatterns = [ url(r'', include(router.urls)), url(r'^devices/(?P\d+)/', include(device_router.urls)), - # Sites - url(r'^sites/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_SITE}, name='site_graphs'), - # Racks url(r'^racks/(?P\d+)/rack-units/$', views.RackUnitListView.as_view(), name='rack_units'), - # Device types - # TODO: Nested DeviceType components - # Devices url(r'^devices/(?P\d+)/lldp-neighbors/$', views.LLDPNeighborsView.as_view(), name='device_lldp-neighbors'), - # Interfaces - url(r'^interfaces/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_INTERFACE}, - name='interface_graphs'), - # Miscellaneous url(r'^related-connections/$', views.RelatedConnectionsView.as_view(), name='related_connections'), url(r'^topology-maps/(?P[\w-]+)/$', TopologyMapView.as_view(), name='topology_map'), diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 472fc6931..696b306aa 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,3 +1,4 @@ +from rest_framework.decorators import detail_route from rest_framework.mixins import ( CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin, ) @@ -16,8 +17,10 @@ from dcim.models import ( Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, ) from dcim import filters -from extras.api.views import CustomFieldModelViewSet from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer +from extras.api.serializers import GraphSerializer +from extras.api.views import CustomFieldModelViewSet +from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from utilities.api import ServiceUnavailable, WritableSerializerMixin from .exceptions import MissingFilterException from . import serializers @@ -31,6 +34,13 @@ class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Site.objects.select_related('tenant') serializer_class = serializers.SiteSerializer + @detail_route() + def graphs(self, request, pk=None): + site = get_object_or_404(Site, pk=pk) + queryset = Graph.objects.filter(type=GRAPH_TYPE_SITE) + serializer = GraphSerializer(queryset, many=True, context={'graphed_object': site}) + return Response(serializer.data) + # # Rack groups @@ -221,6 +231,13 @@ class InterfaceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, queryset = Interface.objects.select_related('device') serializer_class = serializers.InterfaceSerializer + @detail_route() + def graphs(self, request, pk=None): + interface = get_object_or_404(Interface, pk=pk) + queryset = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE) + serializer = GraphSerializer(queryset, many=True, context={'graphed_object': interface}) + return Response(serializer.data) + class DeviceInterfaceViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): serializer_class = serializers.DeviceInterfaceSerializer @@ -272,7 +289,7 @@ class DeviceModuleViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMi # class InterfaceConnectionViewSet(ModelViewSet): - queryset = InterfaceConnection.objects.all() + queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device') serializer_class = serializers.InterfaceConnectionSerializer From 173a6eee036a9a9b7a79dadd679e24a35e16776b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jan 2017 17:24:04 -0500 Subject: [PATCH 016/182] Moved rack units and device LLDP neighbors views into model viewsets --- netbox/dcim/api/urls.py | 6 ------ netbox/dcim/api/views.py | 35 +++++++++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 27f3813f2..b10e857e8 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -55,12 +55,6 @@ urlpatterns = [ url(r'', include(router.urls)), url(r'^devices/(?P\d+)/', include(device_router.urls)), - # Racks - url(r'^racks/(?P\d+)/rack-units/$', views.RackUnitListView.as_view(), name='rack_units'), - - # Devices - url(r'^devices/(?P\d+)/lldp-neighbors/$', views.LLDPNeighborsView.as_view(), name='device_lldp-neighbors'), - # Miscellaneous url(r'^related-connections/$', views.RelatedConnectionsView.as_view(), name='related_connections'), url(r'^topology-maps/(?P[\w-]+)/$', TopologyMapView.as_view(), name='topology_map'), diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 696b306aa..0ddc04b99 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -70,14 +70,11 @@ class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet): serializer_class = serializers.RackSerializer filter_class = filters.RackFilter - -class RackUnitListView(APIView): - """ - List rack units (by rack) - """ - - def get(self, request, pk): - + @detail_route(url_path='rack-units') + def rack_units(self, request, pk=None): + """ + List rack units (by rack) + """ rack = get_object_or_404(Rack, pk=pk) face = request.GET.get('face', 0) exclude_pk = request.GET.get('exclude', None) @@ -149,6 +146,28 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): filter_class = filters.DeviceFilter renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer] + @detail_route(url_path='lldp-neighbors') + def lldp_neighbors(self, request, pk): + """ + Retrieve live LLDP neighbors of a device + """ + device = get_object_or_404(Device, pk=pk) + if not device.primary_ip: + raise ServiceUnavailable("No IP configured for this device.") + + RPC = device.get_rpc_client() + if not RPC: + raise ServiceUnavailable("No RPC client available for this platform ({}).".format(device.platform)) + + # Connect to device and retrieve inventory info + try: + with RPC(device, username=settings.NETBOX_USERNAME, password=settings.NETBOX_PASSWORD) as rpc_client: + lldp_neighbors = rpc_client.get_lldp_neighbors() + except: + raise ServiceUnavailable("Error connecting to the remote device.") + + return Response(lldp_neighbors) + # # Console Ports From 1fcc2b002956ca42763060601870da4d154022b3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 31 Jan 2017 10:40:53 -0500 Subject: [PATCH 017/182] Namespaced all API URLs --- netbox/circuits/api/serializers.py | 3 +++ netbox/dcim/api/serializers.py | 24 ++++++++++++++++++++++-- netbox/dcim/api/views.py | 4 ++-- netbox/ipam/api/serializers.py | 11 ++++++++++- netbox/ipam/api/views.py | 2 +- netbox/netbox/urls.py | 10 +++++----- netbox/secrets/api/serializers.py | 1 + netbox/tenancy/api/serializers.py | 2 ++ 8 files changed, 46 insertions(+), 11 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 68b26cff7..a670b2e01 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -21,6 +21,7 @@ class ProviderSerializer(CustomFieldSerializer, serializers.ModelSerializer): class NestedProviderSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') class Meta: model = Provider @@ -39,6 +40,7 @@ class CircuitTypeSerializer(serializers.ModelSerializer): class NestedCircuitTypeSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') class Meta: model = CircuitType @@ -76,6 +78,7 @@ class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer): class NestedCircuitSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') class Meta: model = Circuit diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index e7c9ab80d..0c256c1ad 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -28,6 +28,7 @@ class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer): class NestedSiteSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') class Meta: model = Site @@ -47,6 +48,7 @@ class RackGroupSerializer(serializers.ModelSerializer): class NestedRackGroupSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') class Meta: model = RackGroup @@ -65,6 +67,7 @@ class RackRoleSerializer(serializers.ModelSerializer): class NestedRackRoleSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') class Meta: model = RackRole @@ -91,6 +94,7 @@ class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer): class NestedRackSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') class Meta: model = Rack @@ -132,6 +136,7 @@ class ManufacturerSerializer(serializers.ModelSerializer): class NestedManufacturerSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') class Meta: model = Manufacturer @@ -164,6 +169,8 @@ class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer): class NestedDeviceTypeSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') + manufacturer = NestedManufacturerSerializer() class Meta: model = DeviceType @@ -224,6 +231,7 @@ class DeviceRoleSerializer(serializers.ModelSerializer): class NestedDeviceRoleSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') class Meta: model = DeviceRole @@ -242,6 +250,7 @@ class PlatformSerializer(serializers.ModelSerializer): class NestedPlatformSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') class Meta: model = Platform @@ -254,6 +263,7 @@ class NestedPlatformSerializer(serializers.HyperlinkedModelSerializer): # Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency class DeviceIPAddressSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') class Meta: model = IPAddress @@ -295,6 +305,7 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): class NestedDeviceSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') class Meta: model = Device @@ -314,6 +325,7 @@ class ConsoleServerPortSerializer(serializers.ModelSerializer): class DeviceConsoleServerPortSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') class Meta: model = ConsoleServerPort @@ -334,6 +346,7 @@ class ConsolePortSerializer(serializers.ModelSerializer): class DeviceConsolePortSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') class Meta: model = ConsolePort @@ -353,6 +366,7 @@ class PowerOutletSerializer(serializers.ModelSerializer): class DevicePowerOutletSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') class Meta: model = PowerOutlet @@ -373,6 +387,7 @@ class PowerPortSerializer(serializers.ModelSerializer): class DevicePowerPortSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') class Meta: model = PowerPort @@ -408,6 +423,7 @@ class InterfaceSerializer(serializers.ModelSerializer): class PeerInterfaceSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() class Meta: @@ -416,6 +432,7 @@ class PeerInterfaceSerializer(serializers.HyperlinkedModelSerializer): class DeviceInterfaceSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') connection = serializers.SerializerMethodField() class Meta: @@ -442,6 +459,7 @@ class InterfaceConnectionSerializer(serializers.ModelSerializer): class NestedInterfaceConnectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfaceconnection-detail') class Meta: model = InterfaceConnection @@ -461,7 +479,8 @@ class DeviceBaySerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'installed_device'] -class ChildDeviceBaySerializer(serializers.HyperlinkedModelSerializer): +class DeviceDeviceBaySerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') class Meta: model = DeviceBay @@ -481,7 +500,8 @@ class ModuleSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] -class ChildModuleSerializer(serializers.HyperlinkedModelSerializer): +class DeviceModuleSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') class Meta: model = Module diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 0ddc04b99..1dd26b73d 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -279,7 +279,7 @@ class DeviceBayViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, class DeviceDeviceBayViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): - serializer_class = serializers.ChildDeviceBaySerializer + serializer_class = serializers.DeviceDeviceBaySerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) @@ -296,7 +296,7 @@ class ModuleViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, Wri class DeviceModuleViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): - serializer_class = serializers.ChildModuleSerializer + serializer_class = serializers.DeviceModuleSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 37b42af6c..d4f60340b 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -20,6 +20,7 @@ class VRFSerializer(CustomFieldSerializer, serializers.ModelSerializer): class NestedVRFSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') class Meta: model = VRF @@ -38,6 +39,7 @@ class RoleSerializer(serializers.ModelSerializer): class NestedRoleSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') class Meta: model = Role @@ -56,6 +58,7 @@ class RIRSerializer(serializers.ModelSerializer): class NestedRIRSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') class Meta: model = RIR @@ -75,6 +78,7 @@ class AggregateSerializer(CustomFieldSerializer, serializers.ModelSerializer): class NestedAggregateSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') class Meta(AggregateSerializer.Meta): model = Aggregate @@ -94,6 +98,7 @@ class VLANGroupSerializer(WritableSerializerMixin, serializers.ModelSerializer): class NestedVLANGroupSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') class Meta: model = VLANGroup @@ -119,6 +124,7 @@ class VLANSerializer(CustomFieldSerializer, serializers.ModelSerializer): class NestedVLANSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') class Meta: model = VLAN @@ -145,6 +151,7 @@ class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer): class NestedPrefixSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') class Meta: model = Prefix @@ -169,6 +176,7 @@ class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer): class NestedIPAddressSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') class Meta: model = IPAddress @@ -191,7 +199,8 @@ class ServiceSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] -class ChildServiceSerializer(serializers.HyperlinkedModelSerializer): +class DeviceServiceSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') ipaddresses = NestedIPAddressSerializer(many=True) class Meta: diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index aba741291..766acd97a 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -101,7 +101,7 @@ class ServiceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, Wr class DeviceServiceViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): - serializer_class = serializers.ChildServiceSerializer + serializer_class = serializers.DeviceServiceSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index fe6fff333..9042e0a62 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -26,11 +26,11 @@ _patterns = [ url(r'^profile/', include('users.urls', namespace='users')), # API - url(r'^api/circuits/', include('circuits.api.urls')), - url(r'^api/dcim/', include('dcim.api.urls')), - url(r'^api/ipam/', include('ipam.api.urls')), - url(r'^api/secrets/', include('secrets.api.urls')), - url(r'^api/tenancy/', include('tenancy.api.urls')), + url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')), + url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')), + url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')), + url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')), + url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')), url(r'^api-auth/', include('rest_framework.urls')), # Error testing diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 40c024e1c..a6ed6ed8c 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -16,6 +16,7 @@ class SecretRoleSerializer(serializers.ModelSerializer): class NestedSecretRoleSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') class Meta: model = SecretRole diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index afd634129..4091e5261 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -16,6 +16,7 @@ class TenantGroupSerializer(serializers.ModelSerializer): class NestedTenantGroupSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') class Meta: model = TenantGroup @@ -35,6 +36,7 @@ class TenantSerializer(CustomFieldSerializer, serializers.ModelSerializer): class NestedTenantSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') class Meta: model = Tenant From e1cd846c9a06ff9eca1f6285bf3a54871ee20f51 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 31 Jan 2017 12:19:41 -0500 Subject: [PATCH 018/182] Enabled creation of device components --- netbox/dcim/api/serializers.py | 8 ++++++- netbox/dcim/api/views.py | 42 ++++++++++++++++++++++++++++------ netbox/utilities/api.py | 5 ++-- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 0c256c1ad..244846672 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -330,6 +330,7 @@ class DeviceConsoleServerPortSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = ConsoleServerPort fields = ['id', 'url', 'name', 'connected_console'] + read_only_fields = ['connected_console'] # @@ -351,6 +352,7 @@ class DeviceConsolePortSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = ConsolePort fields = ['id', 'url', 'name', 'cs_port', 'connection_status'] + read_only_fields = ['cs_port', 'connection_status'] # @@ -371,6 +373,7 @@ class DevicePowerOutletSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = PowerOutlet fields = ['id', 'url', 'name', 'connected_port'] + read_only_fields = ['connected_port'] # @@ -392,6 +395,7 @@ class DevicePowerPortSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = PowerPort fields = ['id', 'url', 'name', 'power_outlet', 'connection_status'] + read_only_fields = ['power_outlet', 'connection_status'] # @@ -485,6 +489,7 @@ class DeviceDeviceBaySerializer(serializers.HyperlinkedModelSerializer): class Meta: model = DeviceBay fields = ['id', 'url', 'name', 'installed_device'] + read_only_fields = ['installed_device'] # @@ -502,7 +507,8 @@ class ModuleSerializer(serializers.ModelSerializer): class DeviceModuleSerializer(serializers.HyperlinkedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + manufacturer = NestedManufacturerSerializer() class Meta: model = Module - fields = ['id', 'url', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] + fields = ['id', 'url', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 1dd26b73d..0f8f5d742 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -179,13 +179,17 @@ class ConsolePortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin serializer_class = serializers.ConsolePortSerializer -class DeviceConsolePortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DeviceConsolePortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): serializer_class = serializers.DeviceConsolePortSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) return ConsolePort.objects.filter(device=device).select_related('cs_port') + def perform_create(self, serializer): + device = get_object_or_404(Device, pk=self.kwargs['pk']) + serializer.save(device=device) + # # Console Server Ports @@ -197,13 +201,17 @@ class ConsoleServerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyMode serializer_class = serializers.ConsoleServerPortSerializer -class DeviceConsoleServerPortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DeviceConsoleServerPortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): serializer_class = serializers.DeviceConsoleServerPortSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) return ConsoleServerPort.objects.filter(device=device).select_related('connected_console') + def perform_create(self, serializer): + device = get_object_or_404(Device, pk=self.kwargs['pk']) + serializer.save(device=device) + # # Power Ports @@ -215,13 +223,17 @@ class PowerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, serializer_class = serializers.PowerPortSerializer -class DevicePowerPortViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DevicePowerPortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): serializer_class = serializers.DevicePowerPortSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) return PowerPort.objects.filter(device=device).select_related('power_outlet') + def perform_create(self, serializer): + device = get_object_or_404(Device, pk=self.kwargs['pk']) + serializer.save(device=device) + # # Power Outlets @@ -233,13 +245,17 @@ class PowerOutletViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin serializer_class = serializers.PowerOutletSerializer -class DevicePowerOutletViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DevicePowerOutletViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): serializer_class = serializers.DevicePowerOutletSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) return PowerOutlet.objects.filter(device=device).select_related('connected_port') + def perform_create(self, serializer): + device = get_object_or_404(Device, pk=self.kwargs['pk']) + serializer.save(device=device) + # # Interfaces @@ -258,7 +274,7 @@ class InterfaceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, return Response(serializer.data) -class DeviceInterfaceViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DeviceInterfaceViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): serializer_class = serializers.DeviceInterfaceSerializer filter_class = filters.InterfaceFilter @@ -267,6 +283,10 @@ class DeviceInterfaceViewSet(CreateModelMixin, ListModelMixin, WritableSerialize return Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\ .select_related('connected_as_a', 'connected_as_b', 'circuit_termination') + def perform_create(self, serializer): + device = get_object_or_404(Device, pk=self.kwargs['pk']) + serializer.save(device=device) + # # Device bays @@ -278,13 +298,17 @@ class DeviceBayViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, serializer_class = serializers.DeviceBaySerializer -class DeviceDeviceBayViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DeviceDeviceBayViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): serializer_class = serializers.DeviceDeviceBaySerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) return DeviceBay.objects.filter(device=device).select_related('installed_device') + def perform_create(self, serializer): + device = get_object_or_404(Device, pk=self.kwargs['pk']) + serializer.save(device=device) + # # Modules @@ -295,13 +319,17 @@ class ModuleViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, Wri serializer_class = serializers.ModuleSerializer -class DeviceModuleViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): +class DeviceModuleViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): serializer_class = serializers.DeviceModuleSerializer def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) return Module.objects.filter(device=device).select_related('device', 'manufacturer') + def perform_create(self, serializer): + device = get_object_or_404(Device, pk=self.kwargs['pk']) + serializer.save(device=device) + # # Interface connections diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index a69e4f369..939ca3c05 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -20,9 +20,8 @@ class WritableSerializerMixin(object): class WritableSerializer(ModelSerializer): - class Meta: - model = self.get_queryset().model - fields = '__all__' + class Meta(self.serializer_class.Meta): + pass if self.action in WRITE_OPERATIONS: return WritableSerializer From bb1f97abc285a12496459953db3a82435e8185e1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 31 Jan 2017 15:35:09 -0500 Subject: [PATCH 019/182] Implemented static writable ModelSerializers for all models --- netbox/circuits/api/serializers.py | 35 ++++--- netbox/circuits/api/urls.py | 3 - netbox/circuits/api/views.py | 4 +- netbox/dcim/api/serializers.py | 145 ++++++++++++++++++----------- netbox/dcim/api/urls.py | 4 +- netbox/dcim/api/views.py | 8 +- netbox/extras/api/serializers.py | 57 +++++++----- netbox/ipam/api/serializers.py | 94 ++++++++++++++----- netbox/ipam/api/views.py | 6 ++ netbox/secrets/api/serializers.py | 2 +- netbox/tenancy/api/serializers.py | 18 +++- netbox/tenancy/api/views.py | 1 + netbox/utilities/api.py | 14 +-- 13 files changed, 251 insertions(+), 140 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index a670b2e01..0cc2a277b 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -2,7 +2,7 @@ from rest_framework import serializers from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.serializers import NestedSiteSerializer, DeviceInterfaceSerializer -from extras.api.serializers import CustomFieldSerializer +from extras.api.serializers import CustomFieldValueSerializer from tenancy.api.serializers import NestedTenantSerializer @@ -10,17 +10,18 @@ from tenancy.api.serializers import NestedTenantSerializer # Providers # -class ProviderSerializer(CustomFieldSerializer, serializers.ModelSerializer): +class ProviderSerializer(serializers.ModelSerializer): + custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Provider fields = [ 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - 'custom_fields', + 'custom_field_values', ] -class NestedProviderSerializer(serializers.HyperlinkedModelSerializer): +class NestedProviderSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') class Meta: @@ -28,6 +29,15 @@ class NestedProviderSerializer(serializers.HyperlinkedModelSerializer): fields = ['id', 'url', 'name', 'slug'] +class WritableProviderSerializer(serializers.ModelSerializer): + + class Meta: + model = Provider + fields = [ + 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + ] + + # # Circuit types # @@ -39,7 +49,7 @@ class CircuitTypeSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug'] -class NestedCircuitTypeSerializer(serializers.HyperlinkedModelSerializer): +class NestedCircuitTypeSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') class Meta: @@ -64,20 +74,21 @@ class CircuitTerminationSerializer(serializers.ModelSerializer): # Circuits # -class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer): +class CircuitSerializer(serializers.ModelSerializer): provider = NestedProviderSerializer() type = NestedCircuitTypeSerializer() tenant = NestedTenantSerializer() + custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Circuit fields = [ 'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', - 'custom_fields', + 'custom_field_values', ] -class NestedCircuitSerializer(serializers.HyperlinkedModelSerializer): +class NestedCircuitSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') class Meta: @@ -85,12 +96,10 @@ class NestedCircuitSerializer(serializers.HyperlinkedModelSerializer): fields = ['id', 'url', 'cid'] -# TODO: Delete this -class CircuitDetailSerializer(CircuitSerializer): - terminations = CircuitTerminationSerializer(many=True) +class WritableCircuitSerializer(serializers.ModelSerializer): - class Meta(CircuitSerializer.Meta): + class Meta: + model = Circuit fields = [ 'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', - 'terminations', 'custom_fields', ] diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index f06b14165..59739e510 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -2,9 +2,6 @@ from django.conf.urls import include, url from rest_framework import routers -from extras.models import GRAPH_TYPE_PROVIDER -from extras.api.views import GraphListView - from . import views diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 1fdafa97b..97d619fd2 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -21,9 +21,10 @@ from . import serializers # Providers # -class ProviderViewSet(CustomFieldModelViewSet): +class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Provider.objects.all() serializer_class = serializers.ProviderSerializer + write_serializer_class = serializers.WritableProviderSerializer @detail_route() def graphs(self, request, pk=None): @@ -49,6 +50,7 @@ class CircuitTypeViewSet(ModelViewSet): class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Circuit.objects.select_related('type', 'tenant', 'provider') serializer_class = serializers.CircuitSerializer + write_serializer_class = serializers.WritableCircuitSerializer filter_class = CircuitFilter diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 244846672..1585109f1 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -4,10 +4,10 @@ from ipam.models import IPAddress from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, - PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site, - SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, + PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site, SUBDEVICE_ROLE_CHILD, + SUBDEVICE_ROLE_PARENT, ) -from extras.api.serializers import CustomFieldSerializer +from extras.api.serializers import CustomFieldValueSerializer from tenancy.api.serializers import NestedTenantSerializer @@ -15,19 +15,20 @@ from tenancy.api.serializers import NestedTenantSerializer # Sites # -class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer): +class SiteSerializer(serializers.ModelSerializer): tenant = NestedTenantSerializer() + custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Site fields = [ 'id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name', - 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes', 'count_vlans', + 'contact_phone', 'contact_email', 'comments', 'custom_field_values', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits', ] -class NestedSiteSerializer(serializers.HyperlinkedModelSerializer): +class NestedSiteSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') class Meta: @@ -35,6 +36,16 @@ class NestedSiteSerializer(serializers.HyperlinkedModelSerializer): fields = ['id', 'url', 'name', 'slug'] +class WritableSiteSerializer(serializers.ModelSerializer): + + class Meta: + model = Site + fields = [ + 'id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name', + 'contact_phone', 'contact_email', 'comments', + ] + + # # Rack groups # @@ -47,7 +58,7 @@ class RackGroupSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'site'] -class NestedRackGroupSerializer(serializers.HyperlinkedModelSerializer): +class NestedRackGroupSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') class Meta: @@ -55,6 +66,13 @@ class NestedRackGroupSerializer(serializers.HyperlinkedModelSerializer): fields = ['id', 'url', 'name', 'slug'] +class WritableRackGroupSerializer(serializers.ModelSerializer): + + class Meta: + model = RackGroup + fields = ['id', 'name', 'slug', 'site'] + + # # Rack roles # @@ -66,7 +84,7 @@ class RackRoleSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'color'] -class NestedRackRoleSerializer(serializers.HyperlinkedModelSerializer): +class NestedRackRoleSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') class Meta: @@ -79,21 +97,22 @@ class NestedRackRoleSerializer(serializers.HyperlinkedModelSerializer): # -class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer): +class RackSerializer(serializers.ModelSerializer): site = NestedSiteSerializer() group = NestedRackGroupSerializer() tenant = NestedTenantSerializer() role = NestedRackRoleSerializer() + custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Rack fields = [ 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', - 'desc_units', 'comments', 'custom_fields', + 'desc_units', 'comments', 'custom_field_values', ] -class NestedRackSerializer(serializers.HyperlinkedModelSerializer): +class NestedRackSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') class Meta: @@ -101,28 +120,15 @@ class NestedRackSerializer(serializers.HyperlinkedModelSerializer): fields = ['id', 'url', 'name', 'display_name'] -class RackDetailSerializer(RackSerializer): - front_units = serializers.SerializerMethodField() - rear_units = serializers.SerializerMethodField() +class WritableRackSerializer(serializers.ModelSerializer): - class Meta(RackSerializer.Meta): + class Meta: + model = Rack fields = [ - 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', - 'desc_units', 'comments', 'custom_fields', 'front_units', 'rear_units', + 'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + 'comments', ] - def get_front_units(self, obj): - units = obj.get_rack_units(face=RACK_FACE_FRONT) - for u in units: - u['device'] = NestedDeviceSerializer(u['device']).data if u['device'] else None - return units - - def get_rear_units(self, obj): - units = obj.get_rack_units(face=RACK_FACE_REAR) - for u in units: - u['device'] = NestedDeviceSerializer(u['device']).data if u['device'] else None - return units - # # Manufacturers @@ -135,7 +141,7 @@ class ManufacturerSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug'] -class NestedManufacturerSerializer(serializers.HyperlinkedModelSerializer): +class NestedManufacturerSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') class Meta: @@ -147,16 +153,17 @@ class NestedManufacturerSerializer(serializers.HyperlinkedModelSerializer): # Device types # -class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer): +class DeviceTypeSerializer(serializers.ModelSerializer): manufacturer = NestedManufacturerSerializer() subdevice_role = serializers.SerializerMethodField() instance_count = serializers.IntegerField(source='instances.count', read_only=True) + custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = DeviceType fields = [ 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', - 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields', + 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_field_values', 'instance_count', ] @@ -168,7 +175,7 @@ class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer): }[obj.subdevice_role] -class NestedDeviceTypeSerializer(serializers.HyperlinkedModelSerializer): +class NestedDeviceTypeSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() @@ -177,6 +184,16 @@ class NestedDeviceTypeSerializer(serializers.HyperlinkedModelSerializer): fields = ['id', 'url', 'manufacturer', 'model', 'slug'] +class WritableDeviceTypeSerializer(serializers.ModelSerializer): + + class Meta: + model = DeviceType + fields = [ + 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', + 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', + ] + + class ConsolePortTemplateSerializer(serializers.ModelSerializer): class Meta: @@ -230,7 +247,7 @@ class DeviceRoleSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'color'] -class NestedDeviceRoleSerializer(serializers.HyperlinkedModelSerializer): +class NestedDeviceRoleSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') class Meta: @@ -249,7 +266,7 @@ class PlatformSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'rpc_client'] -class NestedPlatformSerializer(serializers.HyperlinkedModelSerializer): +class NestedPlatformSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') class Meta: @@ -262,7 +279,7 @@ class NestedPlatformSerializer(serializers.HyperlinkedModelSerializer): # # Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency -class DeviceIPAddressSerializer(serializers.HyperlinkedModelSerializer): +class DeviceIPAddressSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') class Meta: @@ -270,7 +287,7 @@ class DeviceIPAddressSerializer(serializers.HyperlinkedModelSerializer): fields = ['id', 'url', 'family', 'address'] -class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): +class DeviceSerializer(serializers.ModelSerializer): device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() tenant = NestedTenantSerializer() @@ -280,13 +297,14 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): primary_ip4 = DeviceIPAddressSerializer() primary_ip6 = DeviceIPAddressSerializer() parent_device = serializers.SerializerMethodField() + custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Device fields = [ 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'comments', 'custom_fields', + 'comments', 'custom_field_values', ] def get_parent_device(self, obj): @@ -304,7 +322,7 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): } -class NestedDeviceSerializer(serializers.HyperlinkedModelSerializer): +class NestedDeviceSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') class Meta: @@ -312,19 +330,29 @@ class NestedDeviceSerializer(serializers.HyperlinkedModelSerializer): fields = ['id', 'url', 'name', 'display_name'] +class WritableDeviceSerializer(serializers.ModelSerializer): + + class Meta: + model = Device + fields = [ + 'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'rack', + 'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', + ] + + # # Console server ports # class ConsoleServerPortSerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer() + device = NestedDeviceSerializer(read_only=True) class Meta: model = ConsoleServerPort fields = ['id', 'device', 'name', 'connected_console'] -class DeviceConsoleServerPortSerializer(serializers.HyperlinkedModelSerializer): +class DeviceConsoleServerPortSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') class Meta: @@ -338,7 +366,7 @@ class DeviceConsoleServerPortSerializer(serializers.HyperlinkedModelSerializer): # class ConsolePortSerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer() + device = NestedDeviceSerializer(read_only=True) cs_port = ConsoleServerPortSerializer() class Meta: @@ -346,7 +374,7 @@ class ConsolePortSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] -class DeviceConsolePortSerializer(serializers.HyperlinkedModelSerializer): +class DeviceConsolePortSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') class Meta: @@ -360,14 +388,14 @@ class DeviceConsolePortSerializer(serializers.HyperlinkedModelSerializer): # class PowerOutletSerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer() + device = NestedDeviceSerializer(read_only=True) class Meta: model = PowerOutlet fields = ['id', 'device', 'name', 'connected_port'] -class DevicePowerOutletSerializer(serializers.HyperlinkedModelSerializer): +class DevicePowerOutletSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') class Meta: @@ -381,7 +409,7 @@ class DevicePowerOutletSerializer(serializers.HyperlinkedModelSerializer): # class PowerPortSerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer() + device = NestedDeviceSerializer(read_only=True) power_outlet = PowerOutletSerializer() class Meta: @@ -389,7 +417,7 @@ class PowerPortSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] -class DevicePowerPortSerializer(serializers.HyperlinkedModelSerializer): +class DevicePowerPortSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') class Meta: @@ -404,7 +432,7 @@ class DevicePowerPortSerializer(serializers.HyperlinkedModelSerializer): class InterfaceSerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer() + device = NestedDeviceSerializer(read_only=True) connection = serializers.SerializerMethodField(read_only=True) connected_interface = serializers.SerializerMethodField(read_only=True) @@ -426,7 +454,7 @@ class InterfaceSerializer(serializers.ModelSerializer): return None -class PeerInterfaceSerializer(serializers.HyperlinkedModelSerializer): +class PeerInterfaceSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() @@ -435,7 +463,7 @@ class PeerInterfaceSerializer(serializers.HyperlinkedModelSerializer): fields = ['id', 'url', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description'] -class DeviceInterfaceSerializer(serializers.HyperlinkedModelSerializer): +class DeviceInterfaceSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') connection = serializers.SerializerMethodField() @@ -462,7 +490,7 @@ class InterfaceConnectionSerializer(serializers.ModelSerializer): fields = ['id', 'interface_a', 'interface_b', 'connection_status'] -class NestedInterfaceConnectionSerializer(serializers.HyperlinkedModelSerializer): +class NestedInterfaceConnectionSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfaceconnection-detail') class Meta: @@ -470,12 +498,19 @@ class NestedInterfaceConnectionSerializer(serializers.HyperlinkedModelSerializer fields = ['id', 'url', 'connection_status'] +class WritableInterfaceConnectionSerializer(serializers.ModelSerializer): + + class Meta: + model = InterfaceConnection + fields = ['id', 'interface_a', 'interface_b', 'connection_status'] + + # # Device bays # class DeviceBaySerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer() + device = NestedDeviceSerializer(read_only=True) installed_device = NestedDeviceSerializer() class Meta: @@ -483,7 +518,7 @@ class DeviceBaySerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'installed_device'] -class DeviceDeviceBaySerializer(serializers.HyperlinkedModelSerializer): +class DeviceDeviceBaySerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') class Meta: @@ -497,7 +532,7 @@ class DeviceDeviceBaySerializer(serializers.HyperlinkedModelSerializer): # class ModuleSerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer() + device = NestedDeviceSerializer(read_only=True) manufacturer = NestedManufacturerSerializer() class Meta: @@ -505,7 +540,7 @@ class ModuleSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] -class DeviceModuleSerializer(serializers.HyperlinkedModelSerializer): +class DeviceModuleSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') manufacturer = NestedManufacturerSerializer() diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index b10e857e8..0bf692dbb 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -2,10 +2,8 @@ from django.conf.urls import include, url from rest_framework import routers -from extras.models import GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE -from extras.api.views import GraphListView, TopologyMapView +from extras.api.views import TopologyMapView from ipam.api.views import ServiceViewSet, DeviceServiceViewSet - from . import views diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 0f8f5d742..27724e289 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -33,6 +33,7 @@ from . import serializers class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Site.objects.select_related('tenant') serializer_class = serializers.SiteSerializer + write_serializer_class = serializers.WritableSiteSerializer @detail_route() def graphs(self, request, pk=None): @@ -50,6 +51,7 @@ class RackGroupViewSet(WritableSerializerMixin, ModelViewSet): queryset = RackGroup.objects.select_related('site') serializer_class = serializers.RackGroupSerializer filter_class = filters.RackGroupFilter + write_serializer_class = serializers.WritableRackGroupSerializer # @@ -68,6 +70,7 @@ class RackRoleViewSet(ModelViewSet): class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Rack.objects.select_related('site', 'group__site', 'tenant') serializer_class = serializers.RackSerializer + write_serializer_class = serializers.WritableRackSerializer filter_class = filters.RackFilter @detail_route(url_path='rack-units') @@ -112,6 +115,7 @@ class ManufacturerViewSet(ModelViewSet): class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = DeviceType.objects.select_related('manufacturer') serializer_class = serializers.DeviceTypeSerializer + write_serializer_class = serializers.WritableDeviceTypeSerializer # @@ -143,6 +147,7 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', ) serializer_class = serializers.DeviceSerializer + write_serializer_class = serializers.WritableDeviceSerializer filter_class = filters.DeviceFilter renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer] @@ -335,9 +340,10 @@ class DeviceModuleViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): # Interface connections # -class InterfaceConnectionViewSet(ModelViewSet): +class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet): queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device') serializer_class = serializers.InterfaceConnectionSerializer + write_serializer_class = serializers.WritableInterfaceConnectionSerializer # diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 01d348b0a..2dec2a360 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,35 +1,42 @@ from rest_framework import serializers -from extras.models import CF_TYPE_SELECT, CustomFieldChoice, Graph +from extras.models import CF_TYPE_SELECT, CustomFieldChoice, CustomFieldValue, Graph -class CustomFieldSerializer(serializers.Serializer): - """ - Extends a ModelSerializer to render any CustomFields and their values associated with an object. - """ - custom_fields = serializers.SerializerMethodField() +# class CustomFieldSerializer(serializers.ModelSerializer): +# """ +# Extends ModelSerializer to render any CustomFields and their values associated with an object. +# """ +# custom_fields = serializers.SerializerMethodField() +# +# def get_custom_fields(self, obj): +# +# # Gather all CustomFields applicable to this object +# fields = {cf.name: None for cf in self.context['custom_fields']} +# custom_field_choices = self.context['custom_field_choices'] +# +# # Attach any defined CustomFieldValues to their respective CustomFields +# for cfv in obj.custom_field_values.all(): +# +# # Attempt to suppress database lookups for CustomFieldChoices by using the cached choice set from the view +# # context. +# if cfv.field.type == CF_TYPE_SELECT: +# cfc = { +# 'id': int(cfv.serialized_value), +# 'value': custom_field_choices[int(cfv.serialized_value)] +# } +# fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfc).data +# else: +# fields[cfv.field.name] = cfv.value +# +# return fields - def get_custom_fields(self, obj): - # Gather all CustomFields applicable to this object - fields = {cf.name: None for cf in self.context['custom_fields']} - custom_field_choices = self.context['custom_field_choices'] +class CustomFieldValueSerializer(serializers.ModelSerializer): - # Attach any defined CustomFieldValues to their respective CustomFields - for cfv in obj.custom_field_values.all(): - - # Attempt to suppress database lookups for CustomFieldChoices by using the cached choice set from the view - # context. - if cfv.field.type == CF_TYPE_SELECT: - cfc = { - 'id': int(cfv.serialized_value), - 'value': custom_field_choices[int(cfv.serialized_value)] - } - fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfc).data - else: - fields[cfv.field.name] = cfv.value - - return fields + class Meta: + model = CustomFieldValue + fields = ['field', 'serialized_value'] class CustomFieldChoiceSerializer(serializers.ModelSerializer): diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index d4f60340b..9104f55b9 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,25 +1,25 @@ from rest_framework import serializers from dcim.api.serializers import NestedDeviceSerializer, DeviceInterfaceSerializer, NestedSiteSerializer -from extras.api.serializers import CustomFieldSerializer +from extras.api.serializers import CustomFieldValueSerializer from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import WritableSerializerMixin # # VRFs # -class VRFSerializer(CustomFieldSerializer, serializers.ModelSerializer): +class VRFSerializer(serializers.ModelSerializer): tenant = NestedTenantSerializer() + custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = VRF - fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields'] + fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_field_values'] -class NestedVRFSerializer(serializers.HyperlinkedModelSerializer): +class NestedVRFSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') class Meta: @@ -27,6 +27,13 @@ class NestedVRFSerializer(serializers.HyperlinkedModelSerializer): fields = ['id', 'url', 'name', 'rd'] +class WritableVRFSerializer(serializers.ModelSerializer): + + class Meta: + model = VRF + fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description'] + + # # Roles # @@ -38,7 +45,7 @@ class RoleSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'weight'] -class NestedRoleSerializer(serializers.HyperlinkedModelSerializer): +class NestedRoleSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') class Meta: @@ -57,7 +64,7 @@ class RIRSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'is_private'] -class NestedRIRSerializer(serializers.HyperlinkedModelSerializer): +class NestedRIRSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') class Meta: @@ -69,15 +76,16 @@ class NestedRIRSerializer(serializers.HyperlinkedModelSerializer): # Aggregates # -class AggregateSerializer(CustomFieldSerializer, serializers.ModelSerializer): +class AggregateSerializer(serializers.ModelSerializer): rir = NestedRIRSerializer() + custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Aggregate - fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields'] + fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_field_values'] -class NestedAggregateSerializer(serializers.HyperlinkedModelSerializer): +class NestedAggregateSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') class Meta(AggregateSerializer.Meta): @@ -85,11 +93,18 @@ class NestedAggregateSerializer(serializers.HyperlinkedModelSerializer): fields = ['id', 'url', 'family', 'prefix'] +class WritableAggregateSerializer(serializers.ModelSerializer): + + class Meta: + model = Aggregate + fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description'] + + # # VLAN groups # -class VLANGroupSerializer(WritableSerializerMixin, serializers.ModelSerializer): +class VLANGroupSerializer(serializers.ModelSerializer): site = NestedSiteSerializer() class Meta: @@ -97,7 +112,7 @@ class VLANGroupSerializer(WritableSerializerMixin, serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'site'] -class NestedVLANGroupSerializer(serializers.HyperlinkedModelSerializer): +class NestedVLANGroupSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') class Meta: @@ -105,25 +120,33 @@ class NestedVLANGroupSerializer(serializers.HyperlinkedModelSerializer): fields = ['id', 'url', 'name', 'slug'] +class WritableVLANGroupSerializer(serializers.ModelSerializer): + + class Meta: + model = VLANGroup + fields = ['id', 'name', 'slug', 'site'] + + # # VLANs # -class VLANSerializer(CustomFieldSerializer, serializers.ModelSerializer): +class VLANSerializer(serializers.ModelSerializer): site = NestedSiteSerializer() group = NestedVLANGroupSerializer() tenant = NestedTenantSerializer() role = NestedRoleSerializer() + custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = VLAN fields = [ 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name', - 'custom_fields', + 'custom_field_values', ] -class NestedVLANSerializer(serializers.HyperlinkedModelSerializer): +class NestedVLANSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') class Meta: @@ -131,26 +154,36 @@ class NestedVLANSerializer(serializers.HyperlinkedModelSerializer): fields = ['id', 'url', 'vid', 'name', 'display_name'] +class WritableVLANSerializer(serializers.ModelSerializer): + + class Meta: + model = VLAN + fields = [ + 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', + ] + + # # Prefixes # -class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer): +class PrefixSerializer(serializers.ModelSerializer): site = NestedSiteSerializer() vrf = NestedVRFSerializer() tenant = NestedTenantSerializer() vlan = NestedVLANSerializer() role = NestedRoleSerializer() + custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Prefix fields = [ 'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', - 'custom_fields', + 'custom_field_values', ] -class NestedPrefixSerializer(serializers.HyperlinkedModelSerializer): +class NestedPrefixSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') class Meta: @@ -158,24 +191,34 @@ class NestedPrefixSerializer(serializers.HyperlinkedModelSerializer): fields = ['id', 'url', 'family', 'prefix'] +class WritablePrefixSerializer(serializers.ModelSerializer): + + class Meta: + model = Prefix + fields = [ + 'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', + ] + + # # IP addresses # -class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer): +class IPAddressSerializer(serializers.ModelSerializer): vrf = NestedVRFSerializer() tenant = NestedTenantSerializer() interface = DeviceInterfaceSerializer() + custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = IPAddress fields = [ 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside', - 'nat_outside', 'custom_fields', + 'nat_outside', 'custom_field_values', ] -class NestedIPAddressSerializer(serializers.HyperlinkedModelSerializer): +class NestedIPAddressSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') class Meta: @@ -186,6 +229,13 @@ IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer() IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer() +class WritableIPAddressSerializer(serializers.ModelSerializer): + + class Meta: + model = IPAddress + fields = ['id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside'] + + # # Services # @@ -199,7 +249,7 @@ class ServiceSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] -class DeviceServiceSerializer(serializers.HyperlinkedModelSerializer): +class DeviceServiceSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') ipaddresses = NestedIPAddressSerializer(many=True) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 766acd97a..a95784578 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -20,6 +20,7 @@ from . import serializers class VRFViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = VRF.objects.select_related('tenant') serializer_class = serializers.VRFSerializer + write_serializer_class = serializers.WritableVRFSerializer filter_class = filters.VRFFilter @@ -48,6 +49,7 @@ class RIRViewSet(ModelViewSet): class AggregateViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Aggregate.objects.select_related('rir') serializer_class = serializers.AggregateSerializer + write_serializer_class = serializers.WritableAggregateSerializer filter_class = filters.AggregateFilter @@ -58,6 +60,7 @@ class AggregateViewSet(WritableSerializerMixin, CustomFieldModelViewSet): class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') serializer_class = serializers.PrefixSerializer + write_serializer_class = serializers.WritablePrefixSerializer filter_class = filters.PrefixFilter @@ -68,6 +71,7 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet): class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside') serializer_class = serializers.IPAddressSerializer + write_serializer_class = serializers.WritableIPAddressSerializer filter_class = filters.IPAddressFilter @@ -78,6 +82,7 @@ class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet): class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet): queryset = VLANGroup.objects.select_related('site') serializer_class = serializers.VLANGroupSerializer + write_serializer_class = serializers.WritableVLANGroupSerializer filter_class = filters.VLANGroupFilter @@ -88,6 +93,7 @@ class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet): class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') serializer_class = serializers.VLANSerializer + write_serializer_class = serializers.WritableVLANSerializer filter_class = filters.VLANFilter diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index a6ed6ed8c..0b70e7cf8 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -15,7 +15,7 @@ class SecretRoleSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug'] -class NestedSecretRoleSerializer(serializers.HyperlinkedModelSerializer): +class NestedSecretRoleSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') class Meta: diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 4091e5261..46e6edf63 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from extras.api.serializers import CustomFieldSerializer +from extras.api.serializers import CustomFieldValueSerializer from tenancy.models import Tenant, TenantGroup @@ -15,7 +15,7 @@ class TenantGroupSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug'] -class NestedTenantGroupSerializer(serializers.HyperlinkedModelSerializer): +class NestedTenantGroupSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') class Meta: @@ -27,17 +27,25 @@ class NestedTenantGroupSerializer(serializers.HyperlinkedModelSerializer): # Tenants # -class TenantSerializer(CustomFieldSerializer, serializers.ModelSerializer): +class TenantSerializer(serializers.ModelSerializer): group = NestedTenantGroupSerializer() + custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Tenant - fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields'] + fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_field_values'] -class NestedTenantSerializer(serializers.HyperlinkedModelSerializer): +class NestedTenantSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') class Meta: model = Tenant fields = ['id', 'url', 'name', 'slug'] + + +class WritableTenantSerializer(serializers.ModelSerializer): + + class Meta: + model = Tenant + fields = ['id', 'name', 'slug', 'group', 'description', 'comments'] diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index d288dd9f2..ae5069271 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -24,4 +24,5 @@ class TenantGroupViewSet(ModelViewSet): class TenantViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Tenant.objects.select_related('group') serializer_class = serializers.TenantSerializer + write_serializer_class = serializers.WritableTenantSerializer filter_class = TenantFilter diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 939ca3c05..818eb8289 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -12,18 +12,10 @@ class ServiceUnavailable(APIException): class WritableSerializerMixin(object): """ - Returns a flat Serializer from the given model suitable for write operations (POST, PUT, PATCH). This is necessary - to allow write operations on objects which utilize nested serializers. + Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT). """ def get_serializer_class(self): - - class WritableSerializer(ModelSerializer): - - class Meta(self.serializer_class.Meta): - pass - - if self.action in WRITE_OPERATIONS: - return WritableSerializer - + if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'): + return self.write_serializer_class return self.serializer_class From f52c247bd568ce18d564c9d33effcddcf0571e96 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Feb 2017 12:37:19 -0500 Subject: [PATCH 020/182] Re-implemented Swagger now that URL resolution has been fixed --- netbox/netbox/settings.py | 1 + netbox/netbox/urls.py | 1 + netbox/templates/_base.html | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 74bf6ae10..56b774f9a 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -105,6 +105,7 @@ INSTALLED_APPS = ( 'debug_toolbar', 'django_tables2', 'rest_framework', + 'rest_framework_swagger', 'circuits', 'dcim', 'ipam', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 9042e0a62..eb6b67fe7 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -31,6 +31,7 @@ _patterns = [ url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')), url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')), url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')), + url(r'^api/docs/', include('rest_framework_swagger.urls')), url(r'^api-auth/', include('rest_framework.urls')), # Error testing diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index d94081f65..4e63cf337 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -289,7 +289,7 @@

Docs · - API · + API · Code

From 6e10fea119a4508052d63188bd791e19bc48501a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Feb 2017 14:04:45 -0500 Subject: [PATCH 021/182] Started API documentation --- docs/api/structure.md | 32 ++++++++++++++++++++++++++++++++ mkdocs.yml | 2 ++ 2 files changed, 34 insertions(+) create mode 100644 docs/api/structure.md diff --git a/docs/api/structure.md b/docs/api/structure.md new file mode 100644 index 000000000..437ba9bfd --- /dev/null +++ b/docs/api/structure.md @@ -0,0 +1,32 @@ +# URL Hierarchy + +The API's URL structure is divided at the root level by application: circuits, DCIM, IPAM, secrets, and tenancy. Within each application, each model has its own path. For example: + +* /api/circuits/circuits/ +* /api/circuits/providers/ +* /api/dcim/sites/ +* /api/dcim/racks/ +* /api/dcim/devices/ + +Each model generally has two URLs associated with it: a list URL, and a detail URL. the list URL is used to request a list of multiple objects or to create a new object. The detail URL is used to retrieve, update, or delete an existing object. + +* /api/dcim/devices/ - List devices or create a new device +* /api/dcim/devices/123/ - Retrieve, update, or delete the device with ID 123 + +Lists of objects can be filtered using a set of query parameters. For example, to find all interfaces belonging to the device with ID 123: + +* /api/dcim/interfaces/?device_id=123 + +# Serializers + +The NetBox API employs three types of serializers to represent model data: + +* Base serializer +* Nested serializer +* Writable serializer + +The base serializer is used to represent the default view of a model. This includes all database table fields which comprise the model, and may include additional metadata. A base serializer includes relationships to parent objects, but **does not** include child objects. For example, the `VLANSerializer` includes a nested representation its parent VLANGroup (if any), but does not include any assigned Prefixes. + +Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. + +When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object. diff --git a/mkdocs.yml b/mkdocs.yml index 9a96fe0f7..07f319468 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,6 +19,8 @@ pages: - 'Secrets': 'data-model/secrets.md' - 'Tenancy': 'data-model/tenancy.md' - 'Extras': 'data-model/extras.md' + - 'API': + - 'Structure': 'api/structure.md' - 'API Integration': 'api-integration.md' markdown_extensions: From 77e5450746a612f8840eb1ca18523600b431de1d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Feb 2017 14:34:19 -0500 Subject: [PATCH 022/182] Removed all device-specific API endpoints --- netbox/circuits/api/serializers.py | 4 +- netbox/dcim/api/serializers.py | 127 ++++++++++------------- netbox/dcim/api/urls.py | 22 ++-- netbox/dcim/api/views.py | 159 +++++------------------------ netbox/dcim/filters.py | 40 +++++++- netbox/ipam/api/serializers.py | 10 +- netbox/ipam/api/views.py | 19 +--- 7 files changed, 135 insertions(+), 246 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 0cc2a277b..ec6d6a18a 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from circuits.models import Provider, Circuit, CircuitTermination, CircuitType -from dcim.api.serializers import NestedSiteSerializer, DeviceInterfaceSerializer +from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer from extras.api.serializers import CustomFieldValueSerializer from tenancy.api.serializers import NestedTenantSerializer @@ -63,7 +63,7 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer): class CircuitTerminationSerializer(serializers.ModelSerializer): site = NestedSiteSerializer() - interface = DeviceInterfaceSerializer() + interface = InterfaceSerializer() class Meta: model = CircuitTermination diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1585109f1..0fa883d40 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -345,20 +345,18 @@ class WritableDeviceSerializer(serializers.ModelSerializer): # class ConsoleServerPortSerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer(read_only=True) + device = NestedDeviceSerializer() class Meta: model = ConsoleServerPort fields = ['id', 'device', 'name', 'connected_console'] -class DeviceConsoleServerPortSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') +class WritableConsoleServerPortSerializer(serializers.ModelSerializer): class Meta: model = ConsoleServerPort - fields = ['id', 'url', 'name', 'connected_console'] - read_only_fields = ['connected_console'] + fields = ['id', 'device', 'name', 'connected_console'] # @@ -366,7 +364,7 @@ class DeviceConsoleServerPortSerializer(serializers.ModelSerializer): # class ConsolePortSerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer(read_only=True) + device = NestedDeviceSerializer() cs_port = ConsoleServerPortSerializer() class Meta: @@ -374,13 +372,11 @@ class ConsolePortSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] -class DeviceConsolePortSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') +class WritableConsolePortSerializer(serializers.ModelSerializer): class Meta: model = ConsolePort - fields = ['id', 'url', 'name', 'cs_port', 'connection_status'] - read_only_fields = ['cs_port', 'connection_status'] + fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] # @@ -388,20 +384,18 @@ class DeviceConsolePortSerializer(serializers.ModelSerializer): # class PowerOutletSerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer(read_only=True) + device = NestedDeviceSerializer() class Meta: model = PowerOutlet fields = ['id', 'device', 'name', 'connected_port'] -class DevicePowerOutletSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') +class WritablePowerOutletSerializer(serializers.ModelSerializer): class Meta: model = PowerOutlet - fields = ['id', 'url', 'name', 'connected_port'] - read_only_fields = ['connected_port'] + fields = ['id', 'device', 'name', 'connected_port'] # @@ -409,7 +403,7 @@ class DevicePowerOutletSerializer(serializers.ModelSerializer): # class PowerPortSerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer(read_only=True) + device = NestedDeviceSerializer() power_outlet = PowerOutletSerializer() class Meta: @@ -417,13 +411,11 @@ class PowerPortSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] -class DevicePowerPortSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') +class WritablePowerPortSerializer(serializers.ModelSerializer): class Meta: model = PowerPort - fields = ['id', 'url', 'name', 'power_outlet', 'connection_status'] - read_only_fields = ['power_outlet', 'connection_status'] + fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] # @@ -432,7 +424,7 @@ class DevicePowerPortSerializer(serializers.ModelSerializer): class InterfaceSerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer(read_only=True) + device = NestedDeviceSerializer() connection = serializers.SerializerMethodField(read_only=True) connected_interface = serializers.SerializerMethodField(read_only=True) @@ -463,18 +455,51 @@ class PeerInterfaceSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description'] -class DeviceInterfaceSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') - connection = serializers.SerializerMethodField() +class WritableInterfaceSerializer(serializers.ModelSerializer): class Meta: model = Interface - fields = ['id', 'url', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'connection'] + fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description'] - def get_connection(self, obj): - if obj.connection: - return NestedInterfaceConnectionSerializer(obj.connection, context=self.context).data - return None + +# +# Device bays +# + +class DeviceBaySerializer(serializers.ModelSerializer): + device = NestedDeviceSerializer() + installed_device = NestedDeviceSerializer() + + class Meta: + model = DeviceBay + fields = ['id', 'device', 'name', 'installed_device'] + + +class WritableDeviceBaySerializer(serializers.ModelSerializer): + + class Meta: + model = DeviceBay + fields = ['id', 'device', 'name'] + + +# +# Modules +# + +class ModuleSerializer(serializers.ModelSerializer): + device = NestedDeviceSerializer() + manufacturer = NestedManufacturerSerializer() + + class Meta: + model = Module + fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] + + +class WritableModuleSerializer(serializers.ModelSerializer): + + class Meta: + model = Module + fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] # @@ -503,47 +528,3 @@ class WritableInterfaceConnectionSerializer(serializers.ModelSerializer): class Meta: model = InterfaceConnection fields = ['id', 'interface_a', 'interface_b', 'connection_status'] - - -# -# Device bays -# - -class DeviceBaySerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer(read_only=True) - installed_device = NestedDeviceSerializer() - - class Meta: - model = DeviceBay - fields = ['id', 'device', 'name', 'installed_device'] - - -class DeviceDeviceBaySerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') - - class Meta: - model = DeviceBay - fields = ['id', 'url', 'name', 'installed_device'] - read_only_fields = ['installed_device'] - - -# -# Modules -# - -class ModuleSerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer(read_only=True) - manufacturer = NestedManufacturerSerializer() - - class Meta: - model = Module - fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] - - -class DeviceModuleSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') - manufacturer = NestedManufacturerSerializer() - - class Meta: - model = Module - fields = ['id', 'url', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 0bf692dbb..1ce2b518f 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -3,7 +3,7 @@ from django.conf.urls import include, url from rest_framework import routers from extras.api.views import TopologyMapView -from ipam.api.views import ServiceViewSet, DeviceServiceViewSet +from ipam.api.views import ServiceViewSet from . import views @@ -21,37 +21,29 @@ router.register(r'racks', views.RackViewSet) router.register(r'manufacturers', views.ManufacturerViewSet) router.register(r'device-types', views.DeviceTypeViewSet) +# TODO: Device type components + # Devices router.register(r'device-roles', views.DeviceRoleViewSet) router.register(r'platforms', views.PlatformViewSet) router.register(r'devices', views.DeviceViewSet) + +# Device components router.register(r'console-ports', views.ConsolePortViewSet) router.register(r'console-server-ports', views.ConsoleServerPortViewSet) router.register(r'power-ports', views.PowerPortViewSet) router.register(r'power-outlets', views.PowerOutletViewSet) router.register(r'interfaces', views.InterfaceViewSet) -router.register(r'interface-connections', views.InterfaceConnectionViewSet) router.register(r'device-bays', views.DeviceBayViewSet) router.register(r'modules', views.ModuleViewSet) router.register(r'services', ServiceViewSet) -# TODO: Device type components - -# Device components -device_router = routers.DefaultRouter() -device_router.register(r'console-ports', views.DeviceConsolePortViewSet, base_name='consoleport') -device_router.register(r'console-server-ports', views.DeviceConsoleServerPortViewSet, base_name='consoleserverport') -device_router.register(r'power-ports', views.DevicePowerPortViewSet, base_name='powerport') -device_router.register(r'power-outlets', views.DevicePowerOutletViewSet, base_name='poweroutlet') -device_router.register(r'interfaces', views.DeviceInterfaceViewSet, base_name='interface') -device_router.register(r'device-bays', views.DeviceDeviceBayViewSet, base_name='devicebay') -device_router.register(r'modules', views.DeviceModuleViewSet, base_name='module') -device_router.register(r'services', DeviceServiceViewSet, base_name='service') +# Interface connections +router.register(r'interface-connections', views.InterfaceConnectionViewSet) urlpatterns = [ url(r'', include(router.urls)), - url(r'^devices/(?P\d+)/', include(device_router.urls)), # Miscellaneous url(r'^related-connections/$', views.RelatedConnectionsView.as_view(), name='related_connections'), diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 27724e289..4c7899a6a 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,11 +1,8 @@ from rest_framework.decorators import detail_route -from rest_framework.mixins import ( - CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin, -) from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework.views import APIView -from rest_framework.viewsets import GenericViewSet, ModelViewSet +from rest_framework.viewsets import ModelViewSet from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -175,101 +172,42 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # -# Console Ports +# Device components # -class ConsolePortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, - GenericViewSet): - queryset = ConsolePort.objects.select_related('cs_port') +class ConsolePortViewSet(WritableSerializerMixin, ModelViewSet): + queryset = ConsolePort.objects.select_related('device', 'cs_port__device') serializer_class = serializers.ConsolePortSerializer + write_serializer_class= serializers.WritableConsolePortSerializer + filter_class = filters.ConsolePortFilter -class DeviceConsolePortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.DeviceConsolePortSerializer - - def get_queryset(self): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - return ConsolePort.objects.filter(device=device).select_related('cs_port') - - def perform_create(self, serializer): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - serializer.save(device=device) - - -# -# Console Server Ports -# - -class ConsoleServerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, - GenericViewSet): - queryset = ConsoleServerPort.objects.select_related('connected_console') +class ConsoleServerPortViewSet(WritableSerializerMixin, ModelViewSet): + queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device') serializer_class = serializers.ConsoleServerPortSerializer + write_serializer_class= serializers.WritableConsoleServerPortSerializer + filter_class = filters.ConsoleServerPortFilter -class DeviceConsoleServerPortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.DeviceConsoleServerPortSerializer - - def get_queryset(self): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - return ConsoleServerPort.objects.filter(device=device).select_related('connected_console') - - def perform_create(self, serializer): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - serializer.save(device=device) - - -# -# Power Ports -# - -class PowerPortViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, - GenericViewSet): - queryset = PowerPort.objects.select_related('power_outlet') +class PowerPortViewSet(WritableSerializerMixin, ModelViewSet): + queryset = PowerPort.objects.select_related('device', 'power_outlet__device') serializer_class = serializers.PowerPortSerializer + write_serializer_class= serializers.WritablePowerPortSerializer + filter_class = filters.PowerPortFilter -class DevicePowerPortViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.DevicePowerPortSerializer - - def get_queryset(self): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - return PowerPort.objects.filter(device=device).select_related('power_outlet') - - def perform_create(self, serializer): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - serializer.save(device=device) - - -# -# Power Outlets -# - -class PowerOutletViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, - GenericViewSet): - queryset = PowerOutlet.objects.select_related('connected_port') +class PowerOutletViewSet(WritableSerializerMixin, ModelViewSet): + queryset = PowerOutlet.objects.select_related('device', 'connected_port__device') serializer_class = serializers.PowerOutletSerializer + write_serializer_class= serializers.WritablePowerOutletSerializer + filter_class = filters.PowerOutletFilter -class DevicePowerOutletViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.DevicePowerOutletSerializer - - def get_queryset(self): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - return PowerOutlet.objects.filter(device=device).select_related('connected_port') - - def perform_create(self, serializer): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - serializer.save(device=device) - - -# -# Interfaces -# - -class InterfaceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, - GenericViewSet): +class InterfaceViewSet(WritableSerializerMixin, ModelViewSet): queryset = Interface.objects.select_related('device') serializer_class = serializers.InterfaceSerializer + write_serializer_class= serializers.WritableInterfaceSerializer + filter_class = filters.InterfaceFilter @detail_route() def graphs(self, request, pk=None): @@ -279,61 +217,18 @@ class InterfaceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, return Response(serializer.data) -class DeviceInterfaceViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.DeviceInterfaceSerializer - filter_class = filters.InterfaceFilter - - def get_queryset(self): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - return Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\ - .select_related('connected_as_a', 'connected_as_b', 'circuit_termination') - - def perform_create(self, serializer): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - serializer.save(device=device) - - -# -# Device bays -# - -class DeviceBayViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, - GenericViewSet): +class DeviceBayViewSet(WritableSerializerMixin, ModelViewSet): queryset = DeviceBay.objects.select_related('installed_device') serializer_class = serializers.DeviceBaySerializer + write_serializer_class= serializers.WritableDeviceBaySerializer + filter_class = filters.DeviceBayFilter -class DeviceDeviceBayViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.DeviceDeviceBaySerializer - - def get_queryset(self): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - return DeviceBay.objects.filter(device=device).select_related('installed_device') - - def perform_create(self, serializer): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - serializer.save(device=device) - - -# -# Modules -# - -class ModuleViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, GenericViewSet): +class ModuleViewSet(WritableSerializerMixin, ModelViewSet): queryset = Module.objects.select_related('device', 'manufacturer') serializer_class = serializers.ModuleSerializer - - -class DeviceModuleViewSet(CreateModelMixin, ListModelMixin, GenericViewSet): - serializer_class = serializers.DeviceModuleSerializer - - def get_queryset(self): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - return Module.objects.filter(device=device).select_related('device', 'manufacturer') - - def perform_create(self, serializer): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - serializer.save(device=device) + write_serializer_class= serializers.WritableModuleSerializer + filter_class = filters.ModuleFilter # diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 79024b605..60db5913a 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -7,8 +7,8 @@ from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter from .models import ( - ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer, - Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, + ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, Interface, InterfaceConnection, + Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, ) @@ -368,6 +368,42 @@ class InterfaceFilter(django_filters.FilterSet): fields = ['name'] +class DeviceBayFilter(django_filters.FilterSet): + device_id = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', + ) + + class Meta: + model = DeviceBay + fields = ['name'] + + +class ModuleFilter(django_filters.FilterSet): + device_id = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', + ) + + class Meta: + model = Module + fields = ['name'] + + class ConsoleConnectionFilter(django_filters.FilterSet): site = django_filters.MethodFilter( action='filter_site', diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 9104f55b9..bf7cf6e49 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from dcim.api.serializers import NestedDeviceSerializer, DeviceInterfaceSerializer, NestedSiteSerializer +from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer from extras.api.serializers import CustomFieldValueSerializer from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.serializers import NestedTenantSerializer @@ -207,7 +207,7 @@ class WritablePrefixSerializer(serializers.ModelSerializer): class IPAddressSerializer(serializers.ModelSerializer): vrf = NestedVRFSerializer() tenant = NestedTenantSerializer() - interface = DeviceInterfaceSerializer() + interface = InterfaceSerializer() custom_field_values = CustomFieldValueSerializer(many=True) class Meta: @@ -249,10 +249,8 @@ class ServiceSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] -class DeviceServiceSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') - ipaddresses = NestedIPAddressSerializer(many=True) +class WritableServiceSerializer(serializers.ModelSerializer): class Meta: model = Service - fields = ['id', 'url', 'name', 'port', 'protocol', 'ipaddresses', 'description'] + fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index a95784578..3bd1f71c3 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,11 +1,5 @@ -from django.shortcuts import get_object_or_404 +from rest_framework.viewsets import ModelViewSet -from rest_framework.mixins import ( - CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin, -) -from rest_framework.viewsets import GenericViewSet, ModelViewSet - -from dcim.models import Device from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam import filters from extras.api.views import CustomFieldModelViewSet @@ -101,14 +95,7 @@ class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # Services # -class ServiceViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, GenericViewSet): +class ServiceViewSet(WritableSerializerMixin, ModelViewSet): queryset = Service.objects.select_related('device') serializer_class = serializers.ServiceSerializer - - -class DeviceServiceViewSet(CreateModelMixin, ListModelMixin, WritableSerializerMixin, GenericViewSet): - serializer_class = serializers.DeviceServiceSerializer - - def get_queryset(self): - device = get_object_or_404(Device, pk=self.kwargs['pk']) - return Service.objects.filter(device=device).select_related('device') + write_serializer_class = serializers.WritableServiceSerializer From ea51f1c8962e5f21e5725eea1b9558e511d4d917 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Feb 2017 15:01:56 -0500 Subject: [PATCH 023/182] Removed circuit-specific endpoint for CircuitTerminations --- netbox/circuits/api/serializers.py | 38 ++++++++++++++++++++---------- netbox/circuits/api/urls.py | 3 --- netbox/circuits/api/views.py | 26 +++++++------------- netbox/circuits/filters.py | 15 ++++++++++-- 4 files changed, 46 insertions(+), 36 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index ec6d6a18a..b15e735eb 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -57,19 +57,6 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] -# -# Circuit Terminations -# - -class CircuitTerminationSerializer(serializers.ModelSerializer): - site = NestedSiteSerializer() - interface = InterfaceSerializer() - - class Meta: - model = CircuitTermination - fields = ['id', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info'] - - # # Circuits # @@ -103,3 +90,28 @@ class WritableCircuitSerializer(serializers.ModelSerializer): fields = [ 'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', ] + + +# +# Circuit Terminations +# + +class CircuitTerminationSerializer(serializers.ModelSerializer): + circuit = NestedCircuitSerializer() + site = NestedSiteSerializer() + interface = InterfaceSerializer() + + class Meta: + model = CircuitTermination + fields = [ + 'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', + ] + + +class WritableCircuitTerminationSerializer(serializers.ModelSerializer): + + class Meta: + model = CircuitTermination + fields = [ + 'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', + ] diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 59739e510..6bb49dc38 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -15,7 +15,4 @@ urlpatterns = [ url(r'', include(router.urls)), - # Circuits - url(r'^circuits/(?P\d+)/terminations/$', views.NestedCircuitTerminationViewSet.as_view({'get': 'list'})), - ] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 97d619fd2..c8aa7b010 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,15 +1,11 @@ from django.shortcuts import get_object_or_404 from rest_framework.decorators import detail_route -from rest_framework.mixins import ( - CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin, -) from rest_framework.response import Response -from rest_framework.viewsets import GenericViewSet, ModelViewSet +from rest_framework.viewsets import ModelViewSet +from circuits import filters from circuits.models import Provider, CircuitTermination, CircuitType, Circuit -from circuits.filters import CircuitFilter - from extras.models import Graph, GRAPH_TYPE_PROVIDER from extras.api.serializers import GraphSerializer from extras.api.views import CustomFieldModelViewSet @@ -25,6 +21,7 @@ class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Provider.objects.all() serializer_class = serializers.ProviderSerializer write_serializer_class = serializers.WritableProviderSerializer + filter_class = filters.ProviderFilter @detail_route() def graphs(self, request, pk=None): @@ -51,22 +48,15 @@ class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Circuit.objects.select_related('type', 'tenant', 'provider') serializer_class = serializers.CircuitSerializer write_serializer_class = serializers.WritableCircuitSerializer - filter_class = CircuitFilter + filter_class = filters.CircuitFilter # # Circuit Terminations # -class CircuitTerminationViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, WritableSerializerMixin, - GenericViewSet): - queryset = CircuitTermination.objects.select_related('site', 'interface__device') +class CircuitTerminationViewSet(WritableSerializerMixin, ModelViewSet): + queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device') serializer_class = serializers.CircuitTerminationSerializer - - -class NestedCircuitTerminationViewSet(CreateModelMixin, ListModelMixin ,WritableSerializerMixin, GenericViewSet): - serializer_class = serializers.CircuitTerminationSerializer - - def get_queryset(self): - circuit = get_object_or_404(Circuit, pk=self.kwargs['pk']) - return CircuitTermination.objects.filter(circuit=circuit).select_related('site', 'interface__device') + write_serializer_class = serializers.WritableCircuitTerminationSerializer + filter_class = filters.CircuitTerminationFilter diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index fa57a74dc..a920ae55c 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -6,8 +6,7 @@ from dcim.models import Site from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter - -from .models import Provider, Circuit, CircuitType +from .models import Provider, Circuit, CircuitTermination, CircuitType class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -101,3 +100,15 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): Q(description__icontains=value) | Q(comments__icontains=value) ).distinct() + + +class CircuitTerminationFilter(django_filters.FilterSet): + circuit_id = django_filters.ModelMultipleChoiceFilter( + name='circuit', + queryset=Circuit.objects.all(), + label='Circuit', + ) + + class Meta: + model = CircuitTermination + fields = ['term_side', 'site'] From 06e5966cb41f17a78096569b6883038e676214e1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Feb 2017 15:09:23 -0500 Subject: [PATCH 024/182] Include API routers directly where possible --- netbox/circuits/api/urls.py | 8 -------- netbox/ipam/api/urls.py | 8 -------- netbox/netbox/urls.py | 9 ++++++--- netbox/tenancy/api/urls.py | 8 -------- 4 files changed, 6 insertions(+), 27 deletions(-) diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 6bb49dc38..b104be594 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,5 +1,3 @@ -from django.conf.urls import include, url - from rest_framework import routers from . import views @@ -10,9 +8,3 @@ router.register(r'providers', views.ProviderViewSet) router.register(r'circuit-types', views.CircuitTypeViewSet) router.register(r'circuits', views.CircuitViewSet) router.register(r'circuit-terminations', views.CircuitTerminationViewSet) - -urlpatterns = [ - - url(r'', include(router.urls)), - -] diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 24c97b341..e084c9287 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,5 +1,3 @@ -from django.conf.urls import include, url - from rest_framework import routers from . import views @@ -15,9 +13,3 @@ router.register(r'ip-addresses', views.IPAddressViewSet) router.register(r'vlan-groups', views.VLANGroupViewSet) router.register(r'vlans', views.VLANViewSet) router.register(r'services', views.ServiceViewSet) - -urlpatterns = [ - - url(r'', include(router.urls)), - -] diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index eb6b67fe7..3b1d7b569 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -2,8 +2,11 @@ from django.conf import settings from django.conf.urls import include, url from django.contrib import admin +from circuits.api.urls import router as circuits_router +from ipam.api.urls import router as ipam_router from netbox.views import home, handle_500, trigger_500 from users.views import login, logout +from tenancy.api.urls import router as tenancy_router handler500 = handle_500 @@ -26,11 +29,11 @@ _patterns = [ url(r'^profile/', include('users.urls', namespace='users')), # API - url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')), + url(r'^api/circuits/', include(circuits_router.urls, namespace='circuits-api')), url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')), - url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')), + url(r'^api/ipam/', include(ipam_router.urls, namespace='ipam-api')), url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')), - url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')), + url(r'^api/tenancy/', include(tenancy_router.urls, namespace='tenancy-api')), url(r'^api/docs/', include('rest_framework_swagger.urls')), url(r'^api-auth/', include('rest_framework.urls')), diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 35e11cd6b..483f54d23 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -1,5 +1,3 @@ -from django.conf.urls import include, url - from rest_framework import routers from . import views @@ -8,9 +6,3 @@ from . import views router = routers.DefaultRouter() router.register(r'tenant-groups', views.TenantGroupViewSet) router.register(r'tenants', views.TenantViewSet) - -urlpatterns = [ - - url(r'', include(router.urls)), - -] From 4f8a5eb1a05fb514111a8f6185368d12f6510186 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Feb 2017 16:21:33 -0500 Subject: [PATCH 025/182] Moved secret views into a ViewSet (no write ability yet) --- netbox/secrets/api/serializers.py | 8 ++++++++ netbox/secrets/api/urls.py | 5 +---- netbox/secrets/api/views.py | 22 ++++++++++++++++++++++ netbox/secrets/filters.py | 7 ++++++- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 0b70e7cf8..d94881811 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -34,3 +34,11 @@ class SecretSerializer(serializers.ModelSerializer): class Meta: model = Secret fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated'] + + +class WritableSecretSerializer(serializers.ModelSerializer): + plaintext = serializers.CharField() + + class Meta: + model = Secret + fields = ['id', 'device', 'role', 'name', 'plaintext'] diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index a030b481e..fd38c12ec 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -7,15 +7,12 @@ from . import views router = routers.DefaultRouter() router.register(r'secret-roles', views.SecretRoleViewSet) +router.register(r'secrets', views.SecretViewSet) urlpatterns = [ url(r'', include(router.urls)), - # Secrets - url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'), - url(r'^secrets/(?P\d+)/$', views.SecretDetailView.as_view(), name='secret_detail'), - # Miscellaneous url(r'^generate-keys/$', views.RSAKeyGeneratorView.as_view(), name='generate_keys'), diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index e2ccbdb07..e34302286 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -4,6 +4,7 @@ from django.shortcuts import get_object_or_404 from rest_framework import generics from rest_framework import status +from rest_framework.authentication import BasicAuthentication, SessionAuthentication from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONRenderer @@ -14,6 +15,7 @@ from rest_framework.viewsets import ModelViewSet from extras.api.renderers import FormlessBrowsableAPIRenderer, FreeRADIUSClientsRenderer from secrets.filters import SecretFilter from secrets.models import Secret, SecretRole, UserKey +from utilities.api import WritableSerializerMixin from . import serializers @@ -37,6 +39,25 @@ class SecretRoleViewSet(ModelViewSet): # Secrets # +# TODO: Need to implement custom create() and update() methods to handle secret encryption, and custom list() and +# retrieve() methods to handle decryption. +class SecretViewSet(WritableSerializerMixin, ModelViewSet): + queryset = Secret.objects.select_related( + 'device__primary_ip4', 'device__primary_ip6', 'role', + ).prefetch_related( + 'role__users', 'role__groups', + ) + serializer_class = serializers.SecretSerializer + write_serializer_class = serializers.WritableSecretSerializer + filter_class = SecretFilter + # DRF's BrowsableAPIRenderer can't support passing the secret key as a header, so we disable it. + renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer] + # Enabled BasicAuthentication for testing (until we have TokenAuthentication implemented) + authentication_classes = [BasicAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] + + +# TODO: Delete class SecretListView(generics.GenericAPIView): """ List secrets (filterable). If a private key is POSTed, attempt to decrypt each Secret. @@ -83,6 +104,7 @@ class SecretListView(generics.GenericAPIView): return self.get(request, private_key=request.POST.get('private_key')) +# TODO: Delete class SecretDetailView(generics.GenericAPIView): """ Retrieve a single Secret. If a private key is POSTed, attempt to decrypt the Secret. diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index af6f62fbd..9c4e70b97 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -22,11 +22,16 @@ class SecretFilter(django_filters.FilterSet): to_field_name='slug', label='Role (slug)', ) + device_id = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + label='Device (ID)', + ) device = django_filters.ModelMultipleChoiceFilter( name='device', queryset=Device.objects.all(), to_field_name='name', - label='Device (Name)', + label='Device (name)', ) class Meta: From 2408d78f47938f49ca608cb708dc9bc7d9bd9489 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Feb 2017 17:40:50 -0500 Subject: [PATCH 026/182] Introduced ability to decrypt secrets by sending the user's private key in an HTTP header --- netbox/secrets/api/views.py | 144 +++++++++++++++--------------------- 1 file changed, 59 insertions(+), 85 deletions(-) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index e34302286..25663c503 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -1,11 +1,9 @@ +import base64 from Crypto.PublicKey import RSA -from django.shortcuts import get_object_or_404 +from django.http import HttpResponseBadRequest -from rest_framework import generics -from rest_framework import status from rest_framework.authentication import BasicAuthentication, SessionAuthentication -from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONRenderer from rest_framework.response import Response @@ -39,8 +37,8 @@ class SecretRoleViewSet(ModelViewSet): # Secrets # -# TODO: Need to implement custom create() and update() methods to handle secret encryption, and custom list() and -# retrieve() methods to handle decryption. +# TODO: Need to implement custom create() and update() methods to handle secret encryption. +# TODO: Figure out a better way of transmitting the private key class SecretViewSet(WritableSerializerMixin, ModelViewSet): queryset = Secret.objects.select_related( 'device__primary_ip4', 'device__primary_ip6', 'role', @@ -56,97 +54,73 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): authentication_classes = [BasicAuthentication, SessionAuthentication] permission_classes = [IsAuthenticated] + # The user's private key must be sent as a header (X-Private-Key). To overcome limitations around sending line + # breaks in a header field, the key must be base64-encoded and stripped of all whitespace. This is a temporary + # kludge until a more elegant approach can be devised. + def _get_private_key(self, request): + private_key_b64 = request.META.get('HTTP_X_PRIVATE_KEY', None) + if private_key_b64 is None: + return None + # TODO: Handle invalid encoding + return base64.b64decode(private_key_b64) -# TODO: Delete -class SecretListView(generics.GenericAPIView): - """ - List secrets (filterable). If a private key is POSTed, attempt to decrypt each Secret. - """ - queryset = Secret.objects.select_related('device__primary_ip4', 'device__primary_ip6', 'role')\ - .prefetch_related('role__users', 'role__groups') - serializer_class = serializers.SecretSerializer - filter_class = SecretFilter - renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer] - permission_classes = [IsAuthenticated] + def retrieve(self, request, *args, **kwargs): + private_key = self._get_private_key(request) + secret = self.get_object() - def get(self, request, private_key=None): - queryset = self.filter_queryset(self.get_queryset()) + # Attempt to unlock the Secret if a private key was provided + if private_key is not None: - # Attempt to decrypt each Secret if a private key was provided. - if private_key: try: - uk = UserKey.objects.get(user=request.user) + user_key = UserKey.objects.get(user=request.user) except UserKey.DoesNotExist: - return Response( - {'error': ERR_USERKEY_MISSING}, - status=status.HTTP_400_BAD_REQUEST - ) - if not uk.is_active(): - return Response( - {'error': ERR_USERKEY_INACTIVE}, - status=status.HTTP_400_BAD_REQUEST - ) - master_key = uk.get_master_key(private_key) - if master_key is not None: - for s in queryset: - if s.decryptable_by(request.user): - s.decrypt(master_key) - else: - return Response( - {'error': ERR_PRIVKEY_INVALID}, - status=status.HTTP_400_BAD_REQUEST - ) + return HttpResponseBadRequest(ERR_USERKEY_MISSING) + if not user_key.is_active(): + return HttpResponseBadRequest(ERR_USERKEY_INACTIVE) - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) - - def post(self, request): - return self.get(request, private_key=request.POST.get('private_key')) - - -# TODO: Delete -class SecretDetailView(generics.GenericAPIView): - """ - Retrieve a single Secret. If a private key is POSTed, attempt to decrypt the Secret. - """ - queryset = Secret.objects.select_related('device__primary_ip4', 'device__primary_ip6', 'role')\ - .prefetch_related('role__users', 'role__groups') - serializer_class = serializers.SecretSerializer - renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer] - permission_classes = [IsAuthenticated] - - def get(self, request, pk, private_key=None): - secret = get_object_or_404(Secret, pk=pk) - - # Attempt to decrypt the Secret if a private key was provided. - if private_key: - try: - uk = UserKey.objects.get(user=request.user) - except UserKey.DoesNotExist: - return Response( - {'error': ERR_USERKEY_MISSING}, - status=status.HTTP_400_BAD_REQUEST - ) - if not uk.is_active(): - return Response( - {'error': ERR_USERKEY_INACTIVE}, - status=status.HTTP_400_BAD_REQUEST - ) - if not secret.decryptable_by(request.user): - raise PermissionDenied(detail="You do not have permission to decrypt this secret.") - master_key = uk.get_master_key(private_key) + master_key = user_key.get_master_key(private_key) if master_key is None: - return Response( - {'error': ERR_PRIVKEY_INVALID}, - status=status.HTTP_400_BAD_REQUEST - ) + return HttpResponseBadRequest(ERR_PRIVKEY_INVALID) + secret.decrypt(master_key) serializer = self.get_serializer(secret) return Response(serializer.data) - def post(self, request, pk): - return self.get(request, pk, private_key=request.POST.get('private_key')) + def list(self, request, *args, **kwargs): + private_key = self._get_private_key(request) + queryset = self.filter_queryset(self.get_queryset()) + + # Attempt to unlock the Secrets if a private key was provided + master_key = None + if private_key is not None: + + try: + user_key = UserKey.objects.get(user=request.user) + except UserKey.DoesNotExist: + return HttpResponseBadRequest(ERR_USERKEY_MISSING) + if not user_key.is_active(): + return HttpResponseBadRequest(ERR_USERKEY_INACTIVE) + + master_key = user_key.get_master_key(private_key) + if master_key is None: + return HttpResponseBadRequest(ERR_PRIVKEY_INVALID) + + # Pagination + page = self.paginate_queryset(queryset) + if page is not None: + secrets = [] + if master_key is not None: + for secret in page: + secret.decrypt(master_key) + secrets.append(secret) + serializer = self.get_serializer(secrets, many=True) + else: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) class RSAKeyGeneratorView(APIView): From cf66f67fb68a708c336cee1835ab6e400ee425de Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Feb 2017 21:26:51 -0500 Subject: [PATCH 027/182] Initial work on using session-based master key ciphers --- netbox/secrets/api/urls.py | 1 + netbox/secrets/api/views.py | 98 ++++++++++++++++++++++--------------- 2 files changed, 59 insertions(+), 40 deletions(-) diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index fd38c12ec..d67ea92e4 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ url(r'', include(router.urls)), # Miscellaneous + url(r'^get-session-key/$', views.GetSessionKey.as_view(), name='get_session_key'), url(r'^generate-keys/$', views.RSAKeyGeneratorView.as_view(), name='generate_keys'), ] diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 25663c503..b4e75d82d 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -1,5 +1,7 @@ import base64 +from Crypto.Cipher import XOR from Crypto.PublicKey import RSA +import os from django.http import HttpResponseBadRequest @@ -20,6 +22,7 @@ from . import serializers ERR_USERKEY_MISSING = "No UserKey found for the current user." ERR_USERKEY_INACTIVE = "UserKey has not been activated for decryption." +ERR_PRIVKEY_MISSING = "Private key was not provided." ERR_PRIVKEY_INVALID = "Invalid private key." @@ -38,7 +41,6 @@ class SecretRoleViewSet(ModelViewSet): # # TODO: Need to implement custom create() and update() methods to handle secret encryption. -# TODO: Figure out a better way of transmitting the private key class SecretViewSet(WritableSerializerMixin, ModelViewSet): queryset = Secret.objects.select_related( 'device__primary_ip4', 'device__primary_ip6', 'role', @@ -54,58 +56,33 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): authentication_classes = [BasicAuthentication, SessionAuthentication] permission_classes = [IsAuthenticated] - # The user's private key must be sent as a header (X-Private-Key). To overcome limitations around sending line - # breaks in a header field, the key must be base64-encoded and stripped of all whitespace. This is a temporary - # kludge until a more elegant approach can be devised. - def _get_private_key(self, request): - private_key_b64 = request.META.get('HTTP_X_PRIVATE_KEY', None) - if private_key_b64 is None: + def _get_master_key(self, request): + cached_key = request.session.get('cached_key', None) + session_key = request.COOKIES.get('session_key', None) + if cached_key is None or session_key is None: return None - # TODO: Handle invalid encoding - return base64.b64decode(private_key_b64) + + cached_key = base64.b64decode(cached_key) + session_key = base64.b64decode(session_key) + + xor = XOR.new(session_key) + master_key = xor.encrypt(cached_key) + return master_key def retrieve(self, request, *args, **kwargs): - private_key = self._get_private_key(request) + master_key = self._get_master_key(request) secret = self.get_object() - # Attempt to unlock the Secret if a private key was provided - if private_key is not None: - - try: - user_key = UserKey.objects.get(user=request.user) - except UserKey.DoesNotExist: - return HttpResponseBadRequest(ERR_USERKEY_MISSING) - if not user_key.is_active(): - return HttpResponseBadRequest(ERR_USERKEY_INACTIVE) - - master_key = user_key.get_master_key(private_key) - if master_key is None: - return HttpResponseBadRequest(ERR_PRIVKEY_INVALID) - + if master_key is not None: secret.decrypt(master_key) serializer = self.get_serializer(secret) return Response(serializer.data) def list(self, request, *args, **kwargs): - private_key = self._get_private_key(request) + master_key = self._get_master_key(request) queryset = self.filter_queryset(self.get_queryset()) - # Attempt to unlock the Secrets if a private key was provided - master_key = None - if private_key is not None: - - try: - user_key = UserKey.objects.get(user=request.user) - except UserKey.DoesNotExist: - return HttpResponseBadRequest(ERR_USERKEY_MISSING) - if not user_key.is_active(): - return HttpResponseBadRequest(ERR_USERKEY_INACTIVE) - - master_key = user_key.get_master_key(private_key) - if master_key is None: - return HttpResponseBadRequest(ERR_PRIVKEY_INVALID) - # Pagination page = self.paginate_queryset(queryset) if page is not None: @@ -145,3 +122,44 @@ class RSAKeyGeneratorView(APIView): 'private_key': private_key, 'public_key': public_key, }) + + +class GetSessionKey(APIView): + """ + Cache an encrypted copy of the master key derived from the submitted private key. + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + + # Read private key + private_key = request.POST.get('private_key', None) + if private_key is None: + return HttpResponseBadRequest(ERR_PRIVKEY_MISSING) + + # Validate user key + try: + user_key = UserKey.objects.get(user=request.user) + except UserKey.DoesNotExist: + return HttpResponseBadRequest(ERR_USERKEY_MISSING) + if not user_key.is_active(): + return HttpResponseBadRequest(ERR_USERKEY_INACTIVE) + + # Validate private key + master_key = user_key.get_master_key(private_key) + if master_key is None: + return HttpResponseBadRequest(ERR_PRIVKEY_INVALID) + + # Generate a random 256-bit encryption key + session_key = os.urandom(32) + xor = XOR.new(session_key) + cached_key = xor.encrypt(master_key) + + # Save XORed copy of the master key + request.session['cached_key'] = base64.b64encode(cached_key) + + response = Response({ + 'session_key': base64.b64encode(session_key), + }) + response.set_cookie('session_key', base64.b64encode(session_key)) + return response From a42eeb12d2b6cbecbda158a8a0e1dd64b60655ec Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Feb 2017 12:49:32 -0500 Subject: [PATCH 028/182] Implemented SessionKeys for secrets --- netbox/secrets/api/views.py | 93 ++++++++++--------- .../migrations/0002_add_sessionkeys.py | 37 ++++++++ netbox/secrets/models.py | 76 +++++++++++++-- netbox/secrets/tests/test_models.py | 12 +-- 4 files changed, 164 insertions(+), 54 deletions(-) create mode 100644 netbox/secrets/migrations/0002_add_sessionkeys.py diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index b4e75d82d..0ac087c63 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -1,7 +1,5 @@ import base64 -from Crypto.Cipher import XOR from Crypto.PublicKey import RSA -import os from django.http import HttpResponseBadRequest @@ -14,7 +12,7 @@ from rest_framework.viewsets import ModelViewSet from extras.api.renderers import FormlessBrowsableAPIRenderer, FreeRADIUSClientsRenderer from secrets.filters import SecretFilter -from secrets.models import Secret, SecretRole, UserKey +from secrets.models import Secret, SecretRole, SessionKey, UserKey from utilities.api import WritableSerializerMixin from . import serializers @@ -57,16 +55,25 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): permission_classes = [IsAuthenticated] def _get_master_key(self, request): - cached_key = request.session.get('cached_key', None) - session_key = request.COOKIES.get('session_key', None) - if cached_key is None or session_key is None: + + # Check for a session key provided as a cookie or header + if 'session_key' in request.COOKIES: + session_key = base64.b64decode(request.COOKIES['session_key']) + elif 'HTTP_X_SESSION_KEY' in request.META: + session_key = base64.b64decode(request.META['HTTP_X_SESSION_KEY']) + else: return None - cached_key = base64.b64decode(cached_key) - session_key = base64.b64decode(session_key) + # Retrieve session key cipher (if any) for the current user + try: + sk = SessionKey.objects.get(user=request.user) + except SessionKey.DoesNotExist: + return None + + # Recover master key + # TODO: Exception handling + master_key = sk.get_master_key(session_key) - xor = XOR.new(session_key) - master_key = xor.encrypt(cached_key) return master_key def retrieve(self, request, *args, **kwargs): @@ -100,30 +107,6 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): return Response(serializer.data) -class RSAKeyGeneratorView(APIView): - """ - Generate a new RSA key pair for a user. Authenticated because it's a ripe avenue for DoS. - """ - permission_classes = [IsAuthenticated] - - def get(self, request): - - # Determine what size key to generate - key_size = request.GET.get('key_size', 2048) - if key_size not in range(2048, 4097, 256): - key_size = 2048 - - # Export RSA private and public keys in PEM format - key = RSA.generate(key_size) - private_key = key.exportKey('PEM') - public_key = key.publickey().exportKey('PEM') - - return Response({ - 'private_key': private_key, - 'public_key': public_key, - }) - - class GetSessionKey(APIView): """ Cache an encrypted copy of the master key derived from the submitted private key. @@ -150,16 +133,42 @@ class GetSessionKey(APIView): if master_key is None: return HttpResponseBadRequest(ERR_PRIVKEY_INVALID) - # Generate a random 256-bit encryption key - session_key = os.urandom(32) - xor = XOR.new(session_key) - cached_key = xor.encrypt(master_key) + # Delete the existing SessionKey for this user if one exists + SessionKey.objects.filter(user=request.user).delete() - # Save XORed copy of the master key - request.session['cached_key'] = base64.b64encode(cached_key) + # Create a new SessionKey + sk = SessionKey(user=request.user) + sk.save(master_key=master_key) + # Return the session key both as JSON and as a cookie response = Response({ - 'session_key': base64.b64encode(session_key), + 'session_key': base64.b64encode(sk.key), + 'expiration_time': sk.expiration_time, }) - response.set_cookie('session_key', base64.b64encode(session_key)) + # TODO: Limit cookie path to secrets API URLs + response.set_cookie('session_key', base64.b64encode(sk.key), expires=sk.expiration_time) return response + + +class RSAKeyGeneratorView(APIView): + """ + Generate a new RSA key pair for a user. Authenticated because it's a ripe avenue for DoS. + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + + # Determine what size key to generate + key_size = request.GET.get('key_size', 2048) + if key_size not in range(2048, 4097, 256): + key_size = 2048 + + # Export RSA private and public keys in PEM format + key = RSA.generate(key_size) + private_key = key.exportKey('PEM') + public_key = key.publickey().exportKey('PEM') + + return Response({ + 'private_key': private_key, + 'public_key': public_key, + }) diff --git a/netbox/secrets/migrations/0002_add_sessionkeys.py b/netbox/secrets/migrations/0002_add_sessionkeys.py new file mode 100644 index 000000000..c4b848b35 --- /dev/null +++ b/netbox/secrets/migrations/0002_add_sessionkeys.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-03 17:10 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('secrets', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='SessionKey', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('cipher', models.BinaryField(max_length=512)), + ('hash', models.CharField(editable=False, max_length=128)), + ('created', models.DateTimeField(auto_now_add=True)), + ('expiration_time', models.DateTimeField(blank=True, editable=False, null=True)), + ('user', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='session_key', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['user__username'], + }, + ), + migrations.AlterField( + model_name='userkey', + name='user', + field=models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='user_key', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index a0c3e6f8b..8542c9bf3 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -1,5 +1,6 @@ +import datetime import os -from Crypto.Cipher import AES, PKCS1_OAEP +from Crypto.Cipher import AES, PKCS1_OAEP, XOR from Crypto.PublicKey import RSA from django.conf import settings @@ -8,6 +9,7 @@ from django.contrib.auth.models import Group, User from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.db import models +from django.utils import timezone from django.utils.encoding import force_bytes, python_2_unicode_compatible from dcim.models import Device @@ -16,11 +18,13 @@ from utilities.models import CreatedUpdatedModel from .hashers import SecretValidationHasher -def generate_master_key(): +def generate_random_key(bits=256): """ - Generate a new 256-bit (32 bytes) AES key to be used for symmetric encryption of secrets. + Generate a random encryption key. Sizes is given in bits and must be in increments of 32. """ - return os.urandom(32) + if bits % 32: + raise Exception("Invalid key size ({}). Key sizes must be in increments of 32 bits.".format(bits)) + return os.urandom(bits / 8) def encrypt_master_key(master_key, public_key): @@ -41,6 +45,14 @@ def decrypt_master_key(master_key_cipher, private_key): return cipher.decrypt(master_key_cipher) +def xor_keys(key_a, key_b): + """ + Return the binary XOR of two given keys. + """ + xor = XOR.new(key_a) + return xor.encrypt(key_b) + + class UserKeyQuerySet(models.QuerySet): def active(self): @@ -58,7 +70,7 @@ class UserKey(CreatedUpdatedModel): copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's matching (private) decryption key. """ - user = models.OneToOneField(User, related_name='user_key', verbose_name='User') + user = models.OneToOneField(User, related_name='user_key', editable=False) public_key = models.TextField(verbose_name='RSA public key') master_key_cipher = models.BinaryField(max_length=512, blank=True, null=True, editable=False) @@ -121,7 +133,7 @@ class UserKey(CreatedUpdatedModel): # If no other active UserKeys exist, generate a new master key and use it to activate this UserKey. if self.is_filled() and not self.is_active() and not UserKey.objects.active().count(): - master_key = generate_master_key() + master_key = generate_random_key() self.master_key_cipher = encrypt_master_key(master_key, self.public_key) super(UserKey, self).save(*args, **kwargs) @@ -171,6 +183,58 @@ class UserKey(CreatedUpdatedModel): self.save() +@python_2_unicode_compatible +class SessionKey(models.Model): + """ + A SessionKey stores a User's temporary key to be used for the encryption and decryption of secrets. + """ + user = models.OneToOneField(User, related_name='session_key', editable=False) + cipher = models.BinaryField(max_length=512, editable=False) + hash = models.CharField(max_length=128, editable=False) + created = models.DateTimeField(auto_now_add=True) + expiration_time = models.DateTimeField(blank=True, null=True, editable=False) + + key = None + + class Meta: + ordering = ['user__username'] + + def __str__(self): + return self.user.username + + def save(self, master_key=None, *args, **kwargs): + + if master_key is None: + raise Exception("The master key must be provided to save a session key.") + + # Generate a random 256-bit session key if one is not already defined + if self.key is None: + self.key = generate_random_key() + + # Generate SHA256 hash using Django's built-in password hashing mechanism + self.hash = make_password(self.key) + + # Encrypt master key using the session key + self.cipher = xor_keys(self.key, master_key) + + # Calculate expiration time + # TODO: Define a SESSION_KEY_MAX_AGE configuration setting + self.expiration_time = timezone.now() + datetime.timedelta(hours=12) + + super(SessionKey, self).save(*args, **kwargs) + + def get_master_key(self, session_key): + + # Validate the provided session key + if not check_password(session_key, self.hash): + raise Exception("Invalid session key") + + # Decrypt master key using provided session key + master_key = xor_keys(session_key, self.cipher) + + return master_key + + @python_2_unicode_compatible class SecretRole(models.Model): """ diff --git a/netbox/secrets/tests/test_models.py b/netbox/secrets/tests/test_models.py index 132487765..e668f1185 100644 --- a/netbox/secrets/tests/test_models.py +++ b/netbox/secrets/tests/test_models.py @@ -5,7 +5,7 @@ from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.test import TestCase -from secrets.models import UserKey, Secret, generate_master_key, encrypt_master_key, decrypt_master_key +from secrets.models import UserKey, Secret, encrypt_master_key, decrypt_master_key, generate_random_key from secrets.hashers import SecretValidationHasher @@ -33,7 +33,7 @@ class UserKeyTestCase(TestCase): """ Validate the activation of a UserKey. """ - master_key = generate_master_key() + master_key = generate_random_key() alice_uk = UserKey(user=User.objects.get(username='alice'), public_key=self.TEST_KEYS['alice_public']) self.assertFalse(alice_uk.is_active(), "Inactive UserKey is_active() did not return False") alice_uk.activate(master_key) @@ -62,7 +62,7 @@ class UserKeyTestCase(TestCase): """ Test the decryption of a master key using the user's private key. """ - master_key = generate_master_key() + master_key = generate_random_key() alice_uk = UserKey(user=User.objects.get(username='alice'), public_key=self.TEST_KEYS['alice_public']) alice_uk.activate(master_key) retrieved_master_key = alice_uk.get_master_key(self.TEST_KEYS['alice_private']) @@ -72,7 +72,7 @@ class UserKeyTestCase(TestCase): """ Ensure that an exception is raised when attempting to retrieve a secret key using an invalid private key. """ - secret_key = generate_master_key() + secret_key = generate_random_key() secret_key_cipher = encrypt_master_key(secret_key, self.TEST_KEYS['alice_public']) try: decrypted_secret_key = decrypt_master_key(secret_key_cipher, self.TEST_KEYS['bob_private']) @@ -88,7 +88,7 @@ class SecretTestCase(TestCase): Test basic encryption and decryption functionality using a random master key. """ plaintext = "FooBar123" - secret_key = generate_master_key() + secret_key = generate_random_key() s = Secret(plaintext=plaintext) s.encrypt(secret_key) @@ -118,7 +118,7 @@ class SecretTestCase(TestCase): Generate 50 Secrets using the same plaintext and check for duplicate IVs or payloads. """ plaintext = "1234567890abcdef" - secret_key = generate_master_key() + secret_key = generate_random_key() ivs = [] ciphertexts = [] for i in range(1, 51): From 616ca4fe1fc9080b91acb974ea482906ba0039b8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Feb 2017 16:14:42 -0500 Subject: [PATCH 029/182] Adapted the web UI to work with the new secrets API --- netbox/project-static/js/secrets.js | 122 ++++++++++-------- netbox/secrets/forms.py | 9 +- netbox/secrets/views.py | 55 +++++--- .../secrets/inc/private_key_modal.html | 9 +- 4 files changed, 114 insertions(+), 81 deletions(-) diff --git a/netbox/project-static/js/secrets.js b/netbox/project-static/js/secrets.js index 22710236b..40f4b5f0c 100644 --- a/netbox/project-static/js/secrets.js +++ b/netbox/project-static/js/secrets.js @@ -4,13 +4,16 @@ $(document).ready(function() { $('button.unlock-secret').click(function (event) { var secret_id = $(this).attr('secret-id'); - // Retrieve from storage or prompt for private key - var private_key = sessionStorage.getItem('private_key'); - if (!private_key) { - $('#privkey_modal').modal('show'); + // If we have an active cookie containing a session key, send the API request. + if (document.cookie.indexOf('session_key') > 0) { + console.log("Retrieving secret..."); + unlock_secret(secret_id); + // Otherwise, prompt the user for a private key so we can request a session key. } else { - unlock_secret(secret_id, private_key); + console.log("No session key found. Prompt user for private key."); + $('#privkey_modal').modal('show'); } + }); // Locking a secret @@ -18,31 +21,72 @@ $(document).ready(function() { var secret_id = $(this).attr('secret-id'); var secret_div = $('#secret_' + secret_id); - // Delete the plaintext + // Delete the plaintext from the DOM element. secret_div.html('********'); $(this).hide(); $(this).siblings('button.unlock-secret').show(); }); - // Adding/editing a secret - private_key_field = $('#id_private_key'); - private_key_field.parents('form').submit(function(event) { - console.log("form submitted"); - var private_key = sessionStorage.getItem('private_key'); - if (private_key) { - private_key_field.val(private_key); - } else if ($('form .requires-private-key:first').val()) { - console.log("we need a key!"); - $('#privkey_modal').modal('show'); - return false; - } + // Retrieve a session key + $('#request_session_key').click(function() { + var private_key = $('#user_privkey').val(); + + // POST the user's private key to request a temporary session key. + console.log("Requesting a session key..."); + get_session_key(private_key); }); - // Saving a private RSA key locally - $('#submit_privkey').click(function() { - var private_key = $('#user_privkey').val(); - sessionStorage.setItem('private_key', private_key); - }); + // Retrieve a secret via the API + function unlock_secret(secret_id) { + $.ajax({ + url: netbox_api_path + 'secrets/secrets/' + secret_id + '/', + type: 'GET', + dataType: 'json', + success: function (response, status) { + console.log("Secret retrieved successfully"); + $('#secret_' + secret_id).html(response.plaintext); + $('button.unlock-secret[secret-id=' + secret_id + ']').hide(); + $('button.lock-secret[secret-id=' + secret_id + ']').show(); + }, + error: function (xhr, ajaxOptions, thrownError) { + console.log("Error: " + xhr.responseText); + if (xhr.status == 403) { + alert("Permission denied"); + } else { + var json = jQuery.parseJSON(xhr.responseText); + alert("Secret retrieval failed: " + json['error']); + } + } + }); + } + + // Request a session key via the API + function get_session_key(private_key) { + var csrf_token = $('input[name=csrfmiddlewaretoken]').val(); + $.ajax({ + url: netbox_api_path + 'secrets/get-session-key/', + type: 'POST', + data: { + private_key: private_key + }, + dataType: 'json', + beforeSend: function(xhr, settings) { + xhr.setRequestHeader("X-CSRFToken", csrf_token); + }, + success: function (response, status) { + console.log("Received a new session key; valid until " + response.expiration_time); + alert('Session key received! You may now unlock secrets.'); + }, + error: function (xhr, ajaxOptions, thrownError) { + if (xhr.status == 403) { + alert("Permission denied"); + } else { + var json = jQuery.parseJSON(xhr.responseText); + alert("Failed to retrieve a session key: " + json['error']); + } + } + }); + } // Generate a new public/private key pair via the API $('#generate_keypair').click(function() { @@ -63,41 +107,13 @@ $(document).ready(function() { }); }); - // Enter a newly generated public key + // Accept a new RSA key pair generated via the API $('#use_new_pubkey').click(function() { var new_pubkey = $('#new_pubkey'); + if (new_pubkey.val()) { $('#id_public_key').val(new_pubkey.val()); } }); - // Retrieve a secret via the API - function unlock_secret(secret_id, private_key) { - var csrf_token = $('input[name=csrfmiddlewaretoken]').val(); - $.ajax({ - url: netbox_api_path + 'secrets/secrets/' + secret_id + '/', - type: 'POST', - data: { - private_key: private_key - }, - dataType: 'json', - beforeSend: function(xhr, settings) { - xhr.setRequestHeader("X-CSRFToken", csrf_token); - }, - success: function (response, status) { - $('#secret_' + secret_id).html(response.plaintext); - $('button.unlock-secret[secret-id=' + secret_id + ']').hide(); - $('button.lock-secret[secret-id=' + secret_id + ']').show(); - }, - error: function (xhr, ajaxOptions, thrownError) { - if (xhr.status == 403) { - alert("Permission denied"); - } else { - var json = jQuery.parseJSON(xhr.responseText); - alert("Decryption failed: " + json['error']); - } - } - }); - } - }); diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index b4c64b485..15bfad9eb 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -47,9 +47,8 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm): # class SecretForm(BootstrapMixin, forms.ModelForm): - private_key = forms.CharField(required=False, widget=forms.HiddenInput()) plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext', - widget=forms.PasswordInput(attrs={'class': 'requires-private-key'})) + widget=forms.PasswordInput()) plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)', widget=forms.PasswordInput()) @@ -59,9 +58,6 @@ class SecretForm(BootstrapMixin, forms.ModelForm): def clean(self): - if self.cleaned_data['plaintext']: - validate_rsa_key(self.cleaned_data['private_key']) - if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']: raise forms.ValidationError({ 'plaintext2': "The two given plaintext values do not match. Please check your input." @@ -86,8 +82,7 @@ class SecretFromCSVForm(forms.ModelForm): class SecretImportForm(BootstrapMixin, BulkImportForm): - private_key = forms.CharField(widget=forms.HiddenInput()) - csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'})) + csv = CSVDataField(csv_form=SecretFromCSVForm) class SecretBulkEditForm(BootstrapMixin, BulkEditForm): diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index d67cd18a0..5a90eca6e 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -1,3 +1,5 @@ +import base64 + from django.contrib import messages from django.contrib.auth.decorators import permission_required, login_required from django.contrib.auth.mixins import PermissionRequiredMixin @@ -12,7 +14,7 @@ from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, Obje from . import filters, forms, tables from .decorators import userkey_required -from .models import SecretRole, Secret, UserKey +from .models import SecretRole, Secret, SessionKey, UserKey # @@ -110,32 +112,44 @@ def secret_add(request, pk): def secret_edit(request, pk): secret = get_object_or_404(Secret, pk=pk) - uk = UserKey.objects.get(user=request.user) if request.method == 'POST': form = forms.SecretForm(request.POST, instance=secret) if form.is_valid(): - # Re-encrypt the Secret if a plaintext has been specified. - if form.cleaned_data['plaintext']: + # Re-encrypt the Secret if a plaintext and session key have been provided. + session_key = request.COOKIES.get('session_key', None) + if form.cleaned_data['plaintext'] and session_key is not None: - # Retrieve the master key from the current user's UserKey - master_key = uk.get_master_key(form.cleaned_data['private_key']) - if master_key is None: - form.add_error(None, "Invalid private key! Unable to encrypt secret data.") + # Retrieve the master key using the provided session key + session_key = base64.b64decode(session_key) + master_key = None + try: + sk = SessionKey.objects.get(user=request.user) + master_key = sk.get_master_key(session_key) + except SessionKey.DoesNotExist: + form.add_error(None, "No session key found for this user.") # Create and encrypt the new Secret - else: + if master_key is not None: secret = form.save(commit=False) secret.plaintext = str(form.cleaned_data['plaintext']) secret.encrypt(master_key) secret.save() + messages.success(request, u"Modified secret {}.".format(secret)) + return redirect('secrets:secret', pk=secret.pk) + else: + form.add_error(None, "Invalid session key. Unable to encrypt secret data.") + # We can't save the plaintext without a session key. + elif form.cleaned_data['plaintext']: + form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") + + # If no new plaintext was specified, a session key is not needed. else: secret = form.save() - - messages.success(request, u"Modified secret {}.".format(secret)) - return redirect('secrets:secret', pk=secret.pk) + messages.success(request, u"Modified secret {}.".format(secret)) + return redirect('secrets:secret', pk=secret.pk) else: form = forms.SecretForm(instance=secret) @@ -157,19 +171,28 @@ class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView): @userkey_required() def secret_import(request): - uk = UserKey.objects.get(user=request.user) + session_key = request.COOKIES.get('session_key', None) if request.method == 'POST': form = forms.SecretImportForm(request.POST) + + if session_key is None: + form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") + if form.is_valid(): new_secrets = [] - # Retrieve the master key from the current user's UserKey - master_key = uk.get_master_key(form.cleaned_data['private_key']) + session_key = base64.b64decode(session_key) + master_key = None + try: + sk = SessionKey.objects.get(user=request.user) + master_key = sk.get_master_key(session_key) + except SessionKey.DoesNotExist: + form.add_error(None, "No session key found for this user.") + if master_key is None: form.add_error(None, "Invalid private key! Unable to encrypt secret data.") - else: try: with transaction.atomic(): diff --git a/netbox/templates/secrets/inc/private_key_modal.html b/netbox/templates/secrets/inc/private_key_modal.html index d55e6425e..00d2455c1 100644 --- a/netbox/templates/secrets/inc/private_key_modal.html +++ b/netbox/templates/secrets/inc/private_key_modal.html @@ -10,16 +10,15 @@ From 35f310885e4e4a1491229ce63fcd4592ab9bb081 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Feb 2017 16:20:14 -0500 Subject: [PATCH 030/182] Standardize API URL inclusions --- netbox/circuits/api/urls.py | 8 ++++++++ netbox/ipam/api/urls.py | 8 ++++++++ netbox/netbox/urls.py | 9 +++------ netbox/tenancy/api/urls.py | 8 ++++++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index b104be594..6bb49dc38 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,3 +1,5 @@ +from django.conf.urls import include, url + from rest_framework import routers from . import views @@ -8,3 +10,9 @@ router.register(r'providers', views.ProviderViewSet) router.register(r'circuit-types', views.CircuitTypeViewSet) router.register(r'circuits', views.CircuitViewSet) router.register(r'circuit-terminations', views.CircuitTerminationViewSet) + +urlpatterns = [ + + url(r'', include(router.urls)), + +] diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index e084c9287..24c97b341 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,3 +1,5 @@ +from django.conf.urls import include, url + from rest_framework import routers from . import views @@ -13,3 +15,9 @@ router.register(r'ip-addresses', views.IPAddressViewSet) router.register(r'vlan-groups', views.VLANGroupViewSet) router.register(r'vlans', views.VLANViewSet) router.register(r'services', views.ServiceViewSet) + +urlpatterns = [ + + url(r'', include(router.urls)), + +] diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 3b1d7b569..eb6b67fe7 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -2,11 +2,8 @@ from django.conf import settings from django.conf.urls import include, url from django.contrib import admin -from circuits.api.urls import router as circuits_router -from ipam.api.urls import router as ipam_router from netbox.views import home, handle_500, trigger_500 from users.views import login, logout -from tenancy.api.urls import router as tenancy_router handler500 = handle_500 @@ -29,11 +26,11 @@ _patterns = [ url(r'^profile/', include('users.urls', namespace='users')), # API - url(r'^api/circuits/', include(circuits_router.urls, namespace='circuits-api')), + url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')), url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')), - url(r'^api/ipam/', include(ipam_router.urls, namespace='ipam-api')), + url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')), url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')), - url(r'^api/tenancy/', include(tenancy_router.urls, namespace='tenancy-api')), + url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')), url(r'^api/docs/', include('rest_framework_swagger.urls')), url(r'^api-auth/', include('rest_framework.urls')), diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 483f54d23..35e11cd6b 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -1,3 +1,5 @@ +from django.conf.urls import include, url + from rest_framework import routers from . import views @@ -6,3 +8,9 @@ from . import views router = routers.DefaultRouter() router.register(r'tenant-groups', views.TenantGroupViewSet) router.register(r'tenants', views.TenantViewSet) + +urlpatterns = [ + + url(r'', include(router.urls)), + +] From 37f250ddc1e508dbc76a0fc1712a75429cdce0fe Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Feb 2017 16:54:13 -0500 Subject: [PATCH 031/182] Corrected API URL names --- netbox/templates/circuits/provider.html | 2 +- netbox/templates/dcim/inc/interface.html | 2 +- netbox/templates/dcim/site.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index c9c8b9742..3a7b129be 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -27,7 +27,7 @@
{% if show_graphs %} - diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index bfb44b75d..0b144fe1d 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -42,7 +42,7 @@ {% if show_graphs %} {% if iface.circuit_termination or iface.connection %} - {% endif %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 210ec0c82..b6f9c8847 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -28,7 +28,7 @@
{% if show_graphs %} - From 6f3c3b6d6181eb90e7b3fc2b0ba9eba24fbb75f8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Feb 2017 17:18:47 -0500 Subject: [PATCH 032/182] Added API endpoints for device type components --- netbox/dcim/api/serializers.py | 102 +++++++++++++++++++---- netbox/dcim/api/urls.py | 8 +- netbox/dcim/api/views.py | 69 +++++++++++++--- netbox/dcim/filters.py | 144 ++++++++++++++++----------------- 4 files changed, 221 insertions(+), 102 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 0fa883d40..41c4eaf9a 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -2,10 +2,10 @@ from rest_framework import serializers from ipam.models import IPAddress from dcim.models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType, - DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, - PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site, SUBDEVICE_ROLE_CHILD, - SUBDEVICE_ROLE_PARENT, + ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, + Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site, + SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, ) from extras.api.serializers import CustomFieldValueSerializer from tenancy.api.serializers import NestedTenantSerializer @@ -194,46 +194,118 @@ class WritableDeviceTypeSerializer(serializers.ModelSerializer): ] +# +# Console port templates +# + class ConsolePortTemplateSerializer(serializers.ModelSerializer): + device_type = NestedDeviceTypeSerializer() class Meta: model = ConsolePortTemplate - fields = ['id', 'name'] + fields = ['id', 'device_type', 'name'] +class WritableConsolePortTemplateSerializer(serializers.ModelSerializer): + + class Meta: + model = ConsolePortTemplate + fields = ['id', 'device_type', 'name'] + + +# +# Console server port templates +# + class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer): + device_type = NestedDeviceTypeSerializer() class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'name'] + fields = ['id', 'device_type', 'name'] +class WritableConsoleServerPortTemplateSerializer(serializers.ModelSerializer): + + class Meta: + model = ConsoleServerPortTemplate + fields = ['id', 'device_type', 'name'] + + +# +# Power port templates +# + class PowerPortTemplateSerializer(serializers.ModelSerializer): + device_type = NestedDeviceTypeSerializer() class Meta: model = PowerPortTemplate - fields = ['id', 'name'] + fields = ['id', 'device_type', 'name'] +class WritablePowerPortTemplateSerializer(serializers.ModelSerializer): + + class Meta: + model = PowerPortTemplate + fields = ['id', 'device_type', 'name'] + + +# +# Power outlet templates +# + class PowerOutletTemplateSerializer(serializers.ModelSerializer): + device_type = NestedDeviceTypeSerializer() class Meta: model = PowerOutletTemplate - fields = ['id', 'name'] + fields = ['id', 'device_type', 'name'] +class WritablePowerOutletTemplateSerializer(serializers.ModelSerializer): + + class Meta: + model = PowerOutletTemplate + fields = ['id', 'device_type', 'name'] + + +# +# Interface templates +# + class InterfaceTemplateSerializer(serializers.ModelSerializer): + device_type = NestedDeviceTypeSerializer() class Meta: model = InterfaceTemplate - fields = ['id', 'name', 'form_factor', 'mgmt_only'] + fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only'] -class DeviceBayTemplateSerializer(serializers.ModelSerializer): +class WritableInterfaceTemplateSerializer(serializers.ModelSerializer): class Meta: - model = DeviceBay - fields = ['id', 'name',] + model = InterfaceTemplate + fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only'] + + +# +# Device bay templates +# + +class DeviceBayTemplateSerializer(serializers.ModelSerializer): + device_type = NestedDeviceTypeSerializer() + + class Meta: + model = DeviceBayTemplate + fields = ['id', 'device_type', 'name'] + + +class WritableDeviceBayTemplateSerializer(serializers.ModelSerializer): + + class Meta: + model = DeviceBayTemplate + fields = ['id', 'device_type', 'name'] # @@ -302,7 +374,7 @@ class DeviceSerializer(serializers.ModelSerializer): class Meta: model = Device fields = [ - 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', + 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'custom_field_values', ] @@ -335,8 +407,8 @@ class WritableDeviceSerializer(serializers.ModelSerializer): class Meta: model = Device fields = [ - 'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'rack', - 'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', + 'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'rack', 'position', + 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', ] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 1ce2b518f..1d8eca592 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -21,7 +21,13 @@ router.register(r'racks', views.RackViewSet) router.register(r'manufacturers', views.ManufacturerViewSet) router.register(r'device-types', views.DeviceTypeViewSet) -# TODO: Device type components +# Device type components +router.register(r'console-port-templates', views.ConsolePortTemplateViewSet) +router.register(r'console-server-port-templates', views.ConsoleServerPortTemplateViewSet) +router.register(r'power-port-templates', views.PowerPortTemplateViewSet) +router.register(r'power-outlet-templates', views.PowerOutletTemplateViewSet) +router.register(r'interface-templates', views.InterfaceTemplateViewSet) +router.register(r'device-bay-templates', views.DeviceBayTemplateViewSet) # Devices router.register(r'device-roles', views.DeviceRoleViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 4c7899a6a..e44c29571 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -10,8 +10,9 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 from dcim.models import ( - ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, Interface, InterfaceConnection, - Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, + ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, + Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site, ) from dcim import filters from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer @@ -106,7 +107,7 @@ class ManufacturerViewSet(ModelViewSet): # -# Device Types +# Device types # class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet): @@ -116,7 +117,53 @@ class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # -# Device Roles +# Device type components +# + +class ConsolePortTemplateViewSet(WritableSerializerMixin, ModelViewSet): + queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer') + serializer_class = serializers.ConsolePortTemplateSerializer + write_serializer_class = serializers.WritableConsolePortTemplateSerializer + filter_class = filters.ConsolePortTemplateFilter + + +class ConsoleServerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet): + queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer') + serializer_class = serializers.ConsoleServerPortTemplateSerializer + write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer + filter_class = filters.ConsoleServerPortTemplateFilter + + +class PowerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet): + queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer') + serializer_class = serializers.PowerPortTemplateSerializer + write_serializer_class = serializers.WritablePowerPortTemplateSerializer + filter_class = filters.PowerPortTemplateFilter + + +class PowerOutletTemplateViewSet(WritableSerializerMixin, ModelViewSet): + queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer') + serializer_class = serializers.PowerOutletTemplateSerializer + write_serializer_class = serializers.WritablePowerOutletTemplateSerializer + filter_class = filters.PowerOutletTemplateFilter + + +class InterfaceTemplateViewSet(WritableSerializerMixin, ModelViewSet): + queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer') + serializer_class = serializers.InterfaceTemplateSerializer + write_serializer_class = serializers.WritableInterfaceTemplateSerializer + filter_class = filters.InterfaceTemplateFilter + + +class DeviceBayTemplateViewSet(WritableSerializerMixin, ModelViewSet): + queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer') + serializer_class = serializers.DeviceBayTemplateSerializer + write_serializer_class = serializers.WritableDeviceBayTemplateSerializer + filter_class = filters.DeviceBayTemplateFilter + + +# +# Device roles # class DeviceRoleViewSet(ModelViewSet): @@ -178,35 +225,35 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): class ConsolePortViewSet(WritableSerializerMixin, ModelViewSet): queryset = ConsolePort.objects.select_related('device', 'cs_port__device') serializer_class = serializers.ConsolePortSerializer - write_serializer_class= serializers.WritableConsolePortSerializer + write_serializer_class = serializers.WritableConsolePortSerializer filter_class = filters.ConsolePortFilter class ConsoleServerPortViewSet(WritableSerializerMixin, ModelViewSet): queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device') serializer_class = serializers.ConsoleServerPortSerializer - write_serializer_class= serializers.WritableConsoleServerPortSerializer + write_serializer_class = serializers.WritableConsoleServerPortSerializer filter_class = filters.ConsoleServerPortFilter class PowerPortViewSet(WritableSerializerMixin, ModelViewSet): queryset = PowerPort.objects.select_related('device', 'power_outlet__device') serializer_class = serializers.PowerPortSerializer - write_serializer_class= serializers.WritablePowerPortSerializer + write_serializer_class = serializers.WritablePowerPortSerializer filter_class = filters.PowerPortFilter class PowerOutletViewSet(WritableSerializerMixin, ModelViewSet): queryset = PowerOutlet.objects.select_related('device', 'connected_port__device') serializer_class = serializers.PowerOutletSerializer - write_serializer_class= serializers.WritablePowerOutletSerializer + write_serializer_class = serializers.WritablePowerOutletSerializer filter_class = filters.PowerOutletFilter class InterfaceViewSet(WritableSerializerMixin, ModelViewSet): queryset = Interface.objects.select_related('device') serializer_class = serializers.InterfaceSerializer - write_serializer_class= serializers.WritableInterfaceSerializer + write_serializer_class = serializers.WritableInterfaceSerializer filter_class = filters.InterfaceFilter @detail_route() @@ -220,14 +267,14 @@ class InterfaceViewSet(WritableSerializerMixin, ModelViewSet): class DeviceBayViewSet(WritableSerializerMixin, ModelViewSet): queryset = DeviceBay.objects.select_related('installed_device') serializer_class = serializers.DeviceBaySerializer - write_serializer_class= serializers.WritableDeviceBaySerializer + write_serializer_class = serializers.WritableDeviceBaySerializer filter_class = filters.DeviceBayFilter class ModuleViewSet(WritableSerializerMixin, ModelViewSet): queryset = Module.objects.select_related('device', 'manufacturer') serializer_class = serializers.ModuleSerializer - write_serializer_class= serializers.WritableModuleSerializer + write_serializer_class = serializers.WritableModuleSerializer filter_class = filters.ModuleFilter diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 60db5913a..1bfdcec80 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -7,8 +7,9 @@ from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter from .models import ( - ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, Interface, InterfaceConnection, - Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, + ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, + Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site, ) @@ -153,6 +154,62 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): ) +class DeviceTypeComponentFilterSet(django_filters.FilterSet): + devicetype_id = django_filters.ModelMultipleChoiceFilter( + name='device_type', + queryset=DeviceType.objects.all(), + label='Device type (ID)', + ) + devicetype = django_filters.ModelMultipleChoiceFilter( + name='device_type', + queryset=DeviceType.objects.all(), + to_field_name='name', + label='Device type (name)', + ) + + +class ConsolePortTemplateFilter(DeviceTypeComponentFilterSet): + + class Meta: + model = ConsolePortTemplate + fields = ['name'] + + +class ConsoleServerPortTemplateFilter(DeviceTypeComponentFilterSet): + + class Meta: + model = ConsoleServerPortTemplate + fields = ['name'] + + +class PowerPortTemplateFilter(DeviceTypeComponentFilterSet): + + class Meta: + model = PowerPortTemplate + fields = ['name'] + + +class PowerOutletTemplateFilter(DeviceTypeComponentFilterSet): + + class Meta: + model = PowerOutletTemplate + fields = ['name'] + + +class InterfaceTemplateFilter(DeviceTypeComponentFilterSet): + + class Meta: + model = InterfaceTemplate + fields = ['name'] + + +class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet): + + class Meta: + model = DeviceBayTemplate + fields = ['name'] + + class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', @@ -278,7 +335,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): return queryset.none() -class ConsolePortFilter(django_filters.FilterSet): +class DeviceComponentFilterSet(django_filters.FilterSet): device_id = django_filters.ModelMultipleChoiceFilter( name='device', queryset=Device.objects.all(), @@ -291,113 +348,50 @@ class ConsolePortFilter(django_filters.FilterSet): label='Device (name)', ) + +class ConsolePortFilter(DeviceComponentFilterSet): + class Meta: model = ConsolePort fields = ['name'] -class ConsoleServerPortFilter(django_filters.FilterSet): - device_id = django_filters.ModelMultipleChoiceFilter( - name='device', - queryset=Device.objects.all(), - label='Device (ID)', - ) - device = django_filters.ModelMultipleChoiceFilter( - name='device', - queryset=Device.objects.all(), - to_field_name='name', - label='Device (name)', - ) +class ConsoleServerPortFilter(DeviceComponentFilterSet): class Meta: model = ConsoleServerPort fields = ['name'] -class PowerPortFilter(django_filters.FilterSet): - device_id = django_filters.ModelMultipleChoiceFilter( - name='device', - queryset=Device.objects.all(), - label='Device (ID)', - ) - device = django_filters.ModelMultipleChoiceFilter( - name='device', - queryset=Device.objects.all(), - to_field_name='name', - label='Device (name)', - ) +class PowerPortFilter(DeviceComponentFilterSet): class Meta: model = PowerPort fields = ['name'] -class PowerOutletFilter(django_filters.FilterSet): - device_id = django_filters.ModelMultipleChoiceFilter( - name='device', - queryset=Device.objects.all(), - label='Device (ID)', - ) - device = django_filters.ModelMultipleChoiceFilter( - name='device', - queryset=Device.objects.all(), - to_field_name='name', - label='Device (name)', - ) +class PowerOutletFilter(DeviceComponentFilterSet): class Meta: model = PowerOutlet fields = ['name'] -class InterfaceFilter(django_filters.FilterSet): - device_id = django_filters.ModelMultipleChoiceFilter( - name='device', - queryset=Device.objects.all(), - label='Device (ID)', - ) - device = django_filters.ModelMultipleChoiceFilter( - name='device', - queryset=Device.objects.all(), - to_field_name='name', - label='Device (name)', - ) +class InterfaceFilter(DeviceComponentFilterSet): class Meta: model = Interface fields = ['name'] -class DeviceBayFilter(django_filters.FilterSet): - device_id = django_filters.ModelMultipleChoiceFilter( - name='device', - queryset=Device.objects.all(), - label='Device (ID)', - ) - device = django_filters.ModelMultipleChoiceFilter( - name='device', - queryset=Device.objects.all(), - to_field_name='name', - label='Device (name)', - ) +class DeviceBayFilter(DeviceComponentFilterSet): class Meta: model = DeviceBay fields = ['name'] -class ModuleFilter(django_filters.FilterSet): - device_id = django_filters.ModelMultipleChoiceFilter( - name='device', - queryset=Device.objects.all(), - label='Device (ID)', - ) - device = django_filters.ModelMultipleChoiceFilter( - name='device', - queryset=Device.objects.all(), - to_field_name='name', - label='Device (name)', - ) +class ModuleFilter(DeviceComponentFilterSet): class Meta: model = Module From 704008620170687a8e43d279f86891f1eeaa78ff Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Feb 2017 16:00:42 -0500 Subject: [PATCH 033/182] Introduced ChoiceFieldSerializer for choice fields --- netbox/dcim/api/serializers.py | 27 +++++++++++++++------------ netbox/ipam/api/serializers.py | 10 +++++++++- netbox/utilities/api.py | 18 +++++++++++++++++- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 41c4eaf9a..c2b7bbd81 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -2,13 +2,15 @@ from rest_framework import serializers from ipam.models import IPAddress from dcim.models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, - Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site, - SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, + CONNECTION_STATUS_CHOICES, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, + DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface, + InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, + PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Site, + STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, ) from extras.api.serializers import CustomFieldValueSerializer from tenancy.api.serializers import NestedTenantSerializer +from utilities.api import ChoiceFieldSerializer # @@ -102,6 +104,8 @@ class RackSerializer(serializers.ModelSerializer): group = NestedRackGroupSerializer() tenant = NestedTenantSerializer() role = NestedRackRoleSerializer() + type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES) + width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES) custom_field_values = CustomFieldValueSerializer(many=True) class Meta: @@ -155,7 +159,8 @@ class NestedManufacturerSerializer(serializers.ModelSerializer): class DeviceTypeSerializer(serializers.ModelSerializer): manufacturer = NestedManufacturerSerializer() - subdevice_role = serializers.SerializerMethodField() + interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES) + subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES) instance_count = serializers.IntegerField(source='instances.count', read_only=True) custom_field_values = CustomFieldValueSerializer(many=True) @@ -167,13 +172,6 @@ class DeviceTypeSerializer(serializers.ModelSerializer): 'instance_count', ] - def get_subdevice_role(self, obj): - return { - SUBDEVICE_ROLE_PARENT: 'parent', - SUBDEVICE_ROLE_CHILD: 'child', - None: None, - }[obj.subdevice_role] - class NestedDeviceTypeSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') @@ -276,6 +274,7 @@ class WritablePowerOutletTemplateSerializer(serializers.ModelSerializer): class InterfaceTemplateSerializer(serializers.ModelSerializer): device_type = NestedDeviceTypeSerializer() + form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) class Meta: model = InterfaceTemplate @@ -365,6 +364,8 @@ class DeviceSerializer(serializers.ModelSerializer): tenant = NestedTenantSerializer() platform = NestedPlatformSerializer() rack = NestedRackSerializer() + face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES) + status = ChoiceFieldSerializer(choices=STATUS_CHOICES) primary_ip = DeviceIPAddressSerializer() primary_ip4 = DeviceIPAddressSerializer() primary_ip6 = DeviceIPAddressSerializer() @@ -497,6 +498,7 @@ class WritablePowerPortSerializer(serializers.ModelSerializer): class InterfaceSerializer(serializers.ModelSerializer): device = NestedDeviceSerializer() + form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) connection = serializers.SerializerMethodField(read_only=True) connected_interface = serializers.SerializerMethodField(read_only=True) @@ -581,6 +583,7 @@ class WritableModuleSerializer(serializers.ModelSerializer): class InterfaceConnectionSerializer(serializers.ModelSerializer): interface_a = PeerInterfaceSerializer() interface_b = PeerInterfaceSerializer() + connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES) class Meta: model = InterfaceConnection diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index bf7cf6e49..86688f82c 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -2,8 +2,12 @@ from rest_framework import serializers from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer from extras.api.serializers import CustomFieldValueSerializer -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from ipam.models import ( + Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, + Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF, +) from tenancy.api.serializers import NestedTenantSerializer +from utilities.api import ChoiceFieldSerializer # @@ -135,6 +139,7 @@ class VLANSerializer(serializers.ModelSerializer): site = NestedSiteSerializer() group = NestedVLANGroupSerializer() tenant = NestedTenantSerializer() + status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES) role = NestedRoleSerializer() custom_field_values = CustomFieldValueSerializer(many=True) @@ -172,6 +177,7 @@ class PrefixSerializer(serializers.ModelSerializer): vrf = NestedVRFSerializer() tenant = NestedTenantSerializer() vlan = NestedVLANSerializer() + status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES) role = NestedRoleSerializer() custom_field_values = CustomFieldValueSerializer(many=True) @@ -207,6 +213,7 @@ class WritablePrefixSerializer(serializers.ModelSerializer): class IPAddressSerializer(serializers.ModelSerializer): vrf = NestedVRFSerializer() tenant = NestedTenantSerializer() + status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES) interface = InterfaceSerializer() custom_field_values = CustomFieldValueSerializer(many=True) @@ -242,6 +249,7 @@ class WritableIPAddressSerializer(serializers.ModelSerializer): class ServiceSerializer(serializers.ModelSerializer): device = NestedDeviceSerializer() + protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES) ipaddresses = NestedIPAddressSerializer(many=True) class Meta: diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 818eb8289..a44fadb0c 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,5 +1,5 @@ from rest_framework.exceptions import APIException -from rest_framework.serializers import ModelSerializer +from rest_framework.serializers import Field WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] @@ -10,6 +10,22 @@ class ServiceUnavailable(APIException): default_detail = "Service temporarily unavailable, please try again later." +class ChoiceFieldSerializer(Field): + """ + Represent a ChoiceField as a list of (value, label) tuples. + """ + + def __init__(self, choices, **kwargs): + self._choices = choices + super(ChoiceFieldSerializer, self).__init__(**kwargs) + + def to_representation(self, obj): + return self._choices[obj] + + def to_internal_value(self, data): + return getattr(self._choices, data) + + class WritableSerializerMixin(object): """ Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT). From 0e04d20762e287c3d6ace41c9c97a58e7bca2117 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Feb 2017 16:55:54 -0500 Subject: [PATCH 034/182] Re-implemented CustomFieldSerializer (read-only for now) --- netbox/circuits/api/serializers.py | 12 +++--- netbox/dcim/api/serializers.py | 22 +++++------ netbox/extras/api/serializers.py | 59 ++++++++++++++---------------- netbox/ipam/api/serializers.py | 27 ++++++-------- netbox/tenancy/api/serializers.py | 7 ++-- 5 files changed, 55 insertions(+), 72 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index b15e735eb..56395547d 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -2,7 +2,7 @@ from rest_framework import serializers from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer -from extras.api.serializers import CustomFieldValueSerializer +from extras.api.serializers import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer @@ -10,14 +10,13 @@ from tenancy.api.serializers import NestedTenantSerializer # Providers # -class ProviderSerializer(serializers.ModelSerializer): - custom_field_values = CustomFieldValueSerializer(many=True) +class ProviderSerializer(CustomFieldModelSerializer): class Meta: model = Provider fields = [ 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - 'custom_field_values', + 'custom_fields', ] @@ -61,17 +60,16 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer): # Circuits # -class CircuitSerializer(serializers.ModelSerializer): +class CircuitSerializer(CustomFieldModelSerializer): provider = NestedProviderSerializer() type = NestedCircuitTypeSerializer() tenant = NestedTenantSerializer() - custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Circuit fields = [ 'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', - 'custom_field_values', + 'custom_fields', ] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index c2b7bbd81..b4f079281 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -8,7 +8,7 @@ from dcim.models import ( PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, ) -from extras.api.serializers import CustomFieldValueSerializer +from extras.api.serializers import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceFieldSerializer @@ -17,15 +17,14 @@ from utilities.api import ChoiceFieldSerializer # Sites # -class SiteSerializer(serializers.ModelSerializer): +class SiteSerializer(CustomFieldModelSerializer): tenant = NestedTenantSerializer() - custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Site fields = [ 'id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name', - 'contact_phone', 'contact_email', 'comments', 'custom_field_values', 'count_prefixes', 'count_vlans', + 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits', ] @@ -99,20 +98,19 @@ class NestedRackRoleSerializer(serializers.ModelSerializer): # -class RackSerializer(serializers.ModelSerializer): +class RackSerializer(CustomFieldModelSerializer): site = NestedSiteSerializer() group = NestedRackGroupSerializer() tenant = NestedTenantSerializer() role = NestedRackRoleSerializer() type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES) width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES) - custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Rack fields = [ 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', - 'desc_units', 'comments', 'custom_field_values', + 'desc_units', 'comments', 'custom_fields', ] @@ -157,18 +155,17 @@ class NestedManufacturerSerializer(serializers.ModelSerializer): # Device types # -class DeviceTypeSerializer(serializers.ModelSerializer): +class DeviceTypeSerializer(CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES) subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES) instance_count = serializers.IntegerField(source='instances.count', read_only=True) - custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = DeviceType fields = [ 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', - 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_field_values', + 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields', 'instance_count', ] @@ -358,7 +355,7 @@ class DeviceIPAddressSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'family', 'address'] -class DeviceSerializer(serializers.ModelSerializer): +class DeviceSerializer(CustomFieldModelSerializer): device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() tenant = NestedTenantSerializer() @@ -370,14 +367,13 @@ class DeviceSerializer(serializers.ModelSerializer): primary_ip4 = DeviceIPAddressSerializer() primary_ip6 = DeviceIPAddressSerializer() parent_device = serializers.SerializerMethodField() - custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Device fields = [ 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'comments', 'custom_field_values', + 'comments', 'custom_fields', ] def get_parent_device(self, obj): diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 2dec2a360..fa7552dc9 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,42 +1,37 @@ +from django.contrib.contenttypes.models import ContentType + from rest_framework import serializers -from extras.models import CF_TYPE_SELECT, CustomFieldChoice, CustomFieldValue, Graph +from extras.models import CustomField, CustomFieldChoice, Graph -# class CustomFieldSerializer(serializers.ModelSerializer): -# """ -# Extends ModelSerializer to render any CustomFields and their values associated with an object. -# """ -# custom_fields = serializers.SerializerMethodField() -# -# def get_custom_fields(self, obj): -# -# # Gather all CustomFields applicable to this object -# fields = {cf.name: None for cf in self.context['custom_fields']} -# custom_field_choices = self.context['custom_field_choices'] -# -# # Attach any defined CustomFieldValues to their respective CustomFields -# for cfv in obj.custom_field_values.all(): -# -# # Attempt to suppress database lookups for CustomFieldChoices by using the cached choice set from the view -# # context. -# if cfv.field.type == CF_TYPE_SELECT: -# cfc = { -# 'id': int(cfv.serialized_value), -# 'value': custom_field_choices[int(cfv.serialized_value)] -# } -# fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfc).data -# else: -# fields[cfv.field.name] = cfv.value -# -# return fields +class CustomFieldSerializer(serializers.BaseSerializer): + """ + Extends ModelSerializer to render any CustomFields and their values associated with an object. + """ + + def to_representation(self, manager): + + # Initialize custom fields dictionary + data = {f.name: None for f in self.parent._custom_fields} + + # Assign CustomFieldValues from database + for cfv in manager.all(): + data[cfv.field.name] = cfv.value + + return data -class CustomFieldValueSerializer(serializers.ModelSerializer): +class CustomFieldModelSerializer(serializers.ModelSerializer): + custom_fields = CustomFieldSerializer(source='custom_field_values') - class Meta: - model = CustomFieldValue - fields = ['field', 'serialized_value'] + def __init__(self, *args, **kwargs): + + super(CustomFieldModelSerializer, self).__init__(*args, **kwargs) + + # Cache the list of custom fields for this model + content_type = ContentType.objects.get_for_model(self.Meta.model) + self._custom_fields = CustomField.objects.filter(obj_type=content_type) class CustomFieldChoiceSerializer(serializers.ModelSerializer): diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 86688f82c..75b47b6ca 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer -from extras.api.serializers import CustomFieldValueSerializer +from extras.api.serializers import CustomFieldModelSerializer from ipam.models import ( Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF, @@ -14,13 +14,12 @@ from utilities.api import ChoiceFieldSerializer # VRFs # -class VRFSerializer(serializers.ModelSerializer): +class VRFSerializer(CustomFieldModelSerializer): tenant = NestedTenantSerializer() - custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = VRF - fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_field_values'] + fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields'] class NestedVRFSerializer(serializers.ModelSerializer): @@ -80,13 +79,12 @@ class NestedRIRSerializer(serializers.ModelSerializer): # Aggregates # -class AggregateSerializer(serializers.ModelSerializer): +class AggregateSerializer(CustomFieldModelSerializer): rir = NestedRIRSerializer() - custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Aggregate - fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_field_values'] + fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields'] class NestedAggregateSerializer(serializers.ModelSerializer): @@ -135,19 +133,18 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer): # VLANs # -class VLANSerializer(serializers.ModelSerializer): +class VLANSerializer(CustomFieldModelSerializer): site = NestedSiteSerializer() group = NestedVLANGroupSerializer() tenant = NestedTenantSerializer() status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES) role = NestedRoleSerializer() - custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = VLAN fields = [ 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name', - 'custom_field_values', + 'custom_fields', ] @@ -172,20 +169,19 @@ class WritableVLANSerializer(serializers.ModelSerializer): # Prefixes # -class PrefixSerializer(serializers.ModelSerializer): +class PrefixSerializer(CustomFieldModelSerializer): site = NestedSiteSerializer() vrf = NestedVRFSerializer() tenant = NestedTenantSerializer() vlan = NestedVLANSerializer() status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES) role = NestedRoleSerializer() - custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Prefix fields = [ 'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', - 'custom_field_values', + 'custom_fields', ] @@ -210,18 +206,17 @@ class WritablePrefixSerializer(serializers.ModelSerializer): # IP addresses # -class IPAddressSerializer(serializers.ModelSerializer): +class IPAddressSerializer(CustomFieldModelSerializer): vrf = NestedVRFSerializer() tenant = NestedTenantSerializer() status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES) interface = InterfaceSerializer() - custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = IPAddress fields = [ 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside', - 'nat_outside', 'custom_field_values', + 'nat_outside', 'custom_fields', ] diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 46e6edf63..bde86ee28 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from extras.api.serializers import CustomFieldValueSerializer +from extras.api.serializers import CustomFieldModelSerializer from tenancy.models import Tenant, TenantGroup @@ -27,13 +27,12 @@ class NestedTenantGroupSerializer(serializers.ModelSerializer): # Tenants # -class TenantSerializer(serializers.ModelSerializer): +class TenantSerializer(CustomFieldModelSerializer): group = NestedTenantGroupSerializer() - custom_field_values = CustomFieldValueSerializer(many=True) class Meta: model = Tenant - fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_field_values'] + fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields'] class NestedTenantSerializer(serializers.ModelSerializer): From 21281789e0b4982950c8cf82a937e2a35dab2e9f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Feb 2017 14:37:21 -0500 Subject: [PATCH 035/182] Tweaked ChoiceFieldSerializer to display a field as (value, label) --- netbox/dcim/api/serializers.py | 2 +- netbox/utilities/api.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 5390b6078..217df25ab 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -103,7 +103,7 @@ class RackSerializer(CustomFieldModelSerializer): tenant = NestedTenantSerializer() role = NestedRackRoleSerializer() type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES) - # width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES) + width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES) class Meta: model = Rack diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index a44fadb0c..6ec252bbd 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -12,18 +12,18 @@ class ServiceUnavailable(APIException): class ChoiceFieldSerializer(Field): """ - Represent a ChoiceField as a list of (value, label) tuples. + Represent a ChoiceField as (value, label). """ def __init__(self, choices, **kwargs): - self._choices = choices + self._choices = {k: v for k, v in choices} super(ChoiceFieldSerializer, self).__init__(**kwargs) def to_representation(self, obj): - return self._choices[obj] + return obj, self._choices[obj] def to_internal_value(self, data): - return getattr(self._choices, data) + return self._choices.get(data) class WritableSerializerMixin(object): From c0152940f9bf2446c3eb39e9a825f87cd7d15782 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Feb 2017 16:10:53 -0500 Subject: [PATCH 036/182] Merged develop --- docs/data-model/dcim.md | 4 ++ netbox/circuits/views.py | 14 ++++- netbox/dcim/admin.py | 13 ++++- netbox/dcim/api/serializers.py | 30 +++++++++- netbox/dcim/api/urls.py | 1 + netbox/dcim/api/views.py | 12 +++- netbox/dcim/filters.py | 13 ++++- netbox/dcim/forms.py | 56 +++++++++++++++---- netbox/dcim/migrations/0031_regions.py | 38 +++++++++++++ netbox/dcim/models.py | 33 ++++++++++- netbox/dcim/tables.py | 54 +++++++++++++++++- netbox/dcim/tests/test_apis.py | 1 + netbox/dcim/urls.py | 6 ++ netbox/dcim/views.py | 37 ++++++++++-- netbox/ipam/views.py | 6 +- netbox/netbox/settings.py | 1 + netbox/templates/_base.html | 5 ++ netbox/templates/circuits/circuit.html | 4 ++ .../circuits/inc/circuit_termination.html | 7 ++- netbox/templates/dcim/device.html | 36 ++++++++---- netbox/templates/dcim/rack.html | 8 +++ netbox/templates/dcim/region_list.html | 21 +++++++ netbox/templates/dcim/site.html | 32 ++++++++++- netbox/templates/dcim/site_edit.html | 1 + netbox/templates/dcim/site_import.html | 7 ++- netbox/templates/ipam/prefix.html | 18 +++++- netbox/templates/ipam/vlan.html | 10 +++- netbox/utilities/forms.py | 19 +++++-- requirements.txt | 1 + 29 files changed, 437 insertions(+), 51 deletions(-) create mode 100644 netbox/dcim/migrations/0031_regions.py create mode 100644 netbox/templates/dcim/region_list.html diff --git a/docs/data-model/dcim.md b/docs/data-model/dcim.md index a345312d5..932affe69 100644 --- a/docs/data-model/dcim.md +++ b/docs/data-model/dcim.md @@ -6,6 +6,10 @@ How you define sites will depend on the nature of your organization, but typical Sites can be assigned an optional facility ID to identify the actual facility housing colocated equipment. +### Regions + +Sites can optionally be arranged by geographic region. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. + --- # Racks diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 466104883..1ffda899b 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -119,9 +119,17 @@ class CircuitListView(ObjectListView): def circuit(request, pk): - circuit = get_object_or_404(Circuit, pk=pk) - termination_a = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_A).first() - termination_z = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_Z).first() + circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk) + termination_a = CircuitTermination.objects.select_related( + 'site__region', 'interface__device' + ).filter( + circuit=circuit, term_side=TERM_SIDE_A + ).first() + termination_z = CircuitTermination.objects.select_related( + 'site__region', 'interface__device' + ).filter( + circuit=circuit, term_side=TERM_SIDE_Z + ).first() return render(request, 'circuits/circuit.html', { 'circuit': circuit, diff --git a/netbox/dcim/admin.py b/netbox/dcim/admin.py index fb4c281ac..16f07dfcf 100644 --- a/netbox/dcim/admin.py +++ b/netbox/dcim/admin.py @@ -1,13 +1,24 @@ from django.contrib import admin from django.db.models import Count +from mptt.admin import MPTTModelAdmin + from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform, - PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Site, + PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, + Site, ) +@admin.register(Region) +class RegionAdmin(MPTTModelAdmin): + list_display = ['name', 'parent', 'slug'] + prepopulated_fields = { + 'slug': ['name'], + } + + @admin.register(Site) class SiteAdmin(admin.ModelAdmin): list_display = ['name', 'slug', 'facility', 'asn'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 5f5d009cb..70377b1ba 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,18 +6,46 @@ from dcim.models import ( DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, - RACK_WIDTH_CHOICES, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, + RACK_WIDTH_CHOICES, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, ) from extras.api.serializers import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceFieldSerializer +# +# Regions +# + +class NestedRegionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') + + class Meta: + model = Region + fields = ['id', 'url', 'name', 'slug'] + + +class RegionSerializer(serializers.ModelSerializer): + parent = NestedRegionSerializer() + + class Meta: + model = Region + fields = ['id', 'url', 'name', 'slug', 'parent'] + + +class WritableRegionSerializer(serializers.ModelSerializer): + + class Meta: + model = Region + fields = ['id', 'name', 'slug', 'parent'] + + # # Sites # class SiteSerializer(CustomFieldModelSerializer): + region = NestedRegionSerializer() tenant = NestedTenantSerializer() class Meta: diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index d4afdaadc..fce61454b 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -10,6 +10,7 @@ from . import views router = routers.DefaultRouter() # Sites +router.register(r'regions', views.RegionViewSet) router.register(r'sites', views.SiteViewSet) # Racks diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e87f11255..6e65ac595 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -13,7 +13,7 @@ from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, - RackRole, Site, + RackRole, Region, Site, ) from dcim import filters from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer @@ -25,6 +25,16 @@ from .exceptions import MissingFilterException from . import serializers +# +# Regions +# + +class RegionViewSet(WritableSerializerMixin, CustomFieldModelViewSet): + queryset = Region.objects.all() + serializer_class = serializers.RegionSerializer + write_serializer_class = serializers.WritableRegionSerializer + + # # Sites # diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 2b8418363..96c763bfa 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -10,7 +10,7 @@ from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Site, VIRTUAL_IFACE_TYPES, + RackReservation, RackRole, Region, Site, VIRTUAL_IFACE_TYPES, ) @@ -19,6 +19,17 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): action='search', label='Search', ) + region_id = NullableModelMultipleChoiceFilter( + name='region', + queryset=Region.objects.all(), + label='Region (ID)', + ) + region = NullableModelMultipleChoiceFilter( + name='region', + queryset=Region.objects.all(), + to_field_name='slug', + label='Region (slug)', + ) tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index efd1860e3..da9b2411b 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1,5 +1,7 @@ import re +from mptt.forms import TreeNodeChoiceField + from django import forms from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ValidationError @@ -11,7 +13,7 @@ from tenancy.models import Tenant from utilities.forms import ( APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, - SmallTextarea, SlugField, + SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField, ) from .formfields import MACAddressFormField @@ -20,7 +22,7 @@ from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, - RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, + RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, VIRTUAL_IFACE_TYPES ) @@ -63,18 +65,33 @@ class DeviceComponentForm(BootstrapMixin, forms.Form): super(DeviceComponentForm, self).__init__(*args, **kwargs) +# +# Regions +# + +class RegionForm(BootstrapMixin, forms.ModelForm): + slug = SlugField() + + class Meta: + model = Region + fields = ['parent', 'name', 'slug'] + + # # Sites # class SiteForm(BootstrapMixin, CustomFieldForm): + region = TreeNodeChoiceField(queryset=Region.objects.all()) slug = SlugField() comments = CommentField() class Meta: model = Site - fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name', - 'contact_phone', 'contact_email', 'comments'] + fields = [ + 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', + 'contact_name', 'contact_phone', 'contact_email', 'comments', + ] widgets = { 'physical_address': SmallTextarea(attrs={'rows': 3}), 'shipping_address': SmallTextarea(attrs={'rows': 3}), @@ -89,12 +106,22 @@ class SiteForm(BootstrapMixin, CustomFieldForm): class SiteFromCSVForm(forms.ModelForm): - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) + region = forms.ModelChoiceField( + Region.objects.all(), to_field_name='name', required=False, error_messages={ + 'invalid_choice': 'Tenant not found.' + } + ) + tenant = forms.ModelChoiceField( + Tenant.objects.all(), to_field_name='name', required=False, error_messages={ + 'invalid_choice': 'Tenant not found.' + } + ) class Meta: model = Site - fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email'] + fields = [ + 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email', + ] class SiteImportForm(BootstrapMixin, BulkImportForm): @@ -103,18 +130,27 @@ class SiteImportForm(BootstrapMixin, BulkImportForm): class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput) + region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN') class Meta: - nullable_fields = ['tenant', 'asn'] + nullable_fields = ['region', 'tenant', 'asn'] class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Site q = forms.CharField(required=False, label='Search') - tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug', - null_option=(0, 'None')) + region = FilterTreeNodeMultipleChoiceField( + queryset=Region.objects.annotate(filter_count=Count('sites')), + to_field_name='slug', + required=False, + ) + tenant = FilterChoiceField( + queryset=Tenant.objects.annotate(filter_count=Count('sites')), + to_field_name='slug', + null_option=(0, 'None') + ) # diff --git a/netbox/dcim/migrations/0031_regions.py b/netbox/dcim/migrations/0031_regions.py new file mode 100644 index 000000000..d4fd4db5e --- /dev/null +++ b/netbox/dcim/migrations/0031_regions.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-28 17:14 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0030_interface_add_lag'), + ] + + operations = [ + migrations.CreateModel( + name='Region', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('lft', models.PositiveIntegerField(db_index=True, editable=False)), + ('rght', models.PositiveIntegerField(db_index=True, editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(db_index=True, editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.Region')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='site', + name='region', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sites', to='dcim.Region'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index f589c49a2..9beba5bde 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1,5 +1,7 @@ from collections import OrderedDict +from mptt.models import MPTTModel, TreeForeignKey + from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType @@ -200,6 +202,29 @@ RPC_CLIENT_CHOICES = [ ] +# +# Regions +# + +@python_2_unicode_compatible +class Region(MPTTModel): + """ + Sites can be grouped within geographic Regions. + """ + parent = TreeForeignKey('self', null=True, blank=True, related_name='children', db_index=True) + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(unique=True) + + class MPTTMeta: + order_insertion_by = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return "{}?region={}".format(reverse('dcim:site_list'), self.slug) + + # # Sites # @@ -218,7 +243,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel): """ name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) - tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='sites', on_delete=models.PROTECT) + region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL) + tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT) facility = models.CharField(max_length=50, blank=True) asn = ASNField(blank=True, null=True, verbose_name='ASN') physical_address = models.CharField(max_length=200, blank=True) @@ -244,6 +270,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): return csv_format([ self.name, self.slug, + self.region.name if self.region else None, self.tenant.name if self.tenant else None, self.facility, self.asn, @@ -1248,8 +1275,8 @@ class Interface(models.Model): ) }) - # A LAG interface cannot have a parent LAG - if self.form_factor == IFACE_FF_LAG and self.lag is not None: + # A virtual interface cannot have a parent LAG + if self.form_factor in VIRTUAL_IFACE_TYPES and self.lag is not None: raise ValidationError({ 'lag': u"{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display()) }) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 0a891efea..a6f6dbdc2 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -6,10 +6,28 @@ from utilities.tables import BaseTable, ToggleColumn from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, Site, + RackGroup, Region, Site, ) +REGION_LINK = """ +{% if record.get_children %} + +{% else %} + +{% endif %} + {{ record.name }} + +""" + +SITE_REGION_LINK = """ +{% if record.region %} + {{ record.region }} +{% else %} + — +{% endif %} +""" + COLOR_LABEL = """ """ @@ -20,6 +38,12 @@ DEVICE_LINK = """ """ +REGION_ACTIONS = """ +{% if perms.dcim.change_region %} + +{% endif %} +""" + RACKGROUP_ACTIONS = """ {% if perms.dcim.change_rackgroup %} @@ -76,6 +100,27 @@ UTILIZATION_GRAPH = """ """ +# +# Regions +# + +class RegionTable(BaseTable): + pk = ToggleColumn() + # name = tables.LinkColumn(verbose_name='Name') + name = tables.TemplateColumn(template_code=REGION_LINK, orderable=False) + site_count = tables.Column(verbose_name='Sites') + slug = tables.Column(verbose_name='Slug') + actions = tables.TemplateColumn( + template_code=REGION_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = Region + fields = ('pk', 'name', 'site_count', 'slug', 'actions') + + # # Sites # @@ -84,6 +129,7 @@ class SiteTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name') facility = tables.Column(verbose_name='Facility') + region = tables.TemplateColumn(template_code=SITE_REGION_LINK, verbose_name='Region') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') asn = tables.Column(verbose_name='ASN') rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks') @@ -94,8 +140,10 @@ class SiteTable(BaseTable): class Meta(BaseTable.Meta): model = Site - fields = ('pk', 'name', 'facility', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count', - 'vlan_count', 'circuit_count') + fields = ( + 'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count', + 'vlan_count', 'circuit_count', + ) # diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index 672a4fb6f..f4d03c633 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/netbox/dcim/tests/test_apis.py @@ -17,6 +17,7 @@ class SiteTest(APITestCase): 'id', 'name', 'slug', + 'region', 'tenant', 'facility', 'asn', diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 1b337ad6e..7fde6e9b3 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -8,6 +8,12 @@ from . import views urlpatterns = [ + # Regions + url(r'^regions/$', views.RegionListView.as_view(), name='region_list'), + url(r'^regions/add/$', views.RegionEditView.as_view(), name='region_add'), + url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), + url(r'^regions/(?P\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'), + # Sites url(r'^sites/$', views.SiteListView.as_view(), name='site_list'), url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 243d97e92..02d9acd10 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -26,7 +26,7 @@ from .models import ( CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Site, + RackReservation, RackRole, Region, Site, ) @@ -129,12 +129,37 @@ class ComponentDeleteView(ObjectDeleteView): return obj.device.get_absolute_url() +# +# Regions +# + +class RegionListView(ObjectListView): + queryset = Region.objects.annotate(site_count=Count('sites')) + table = tables.RegionTable + template_name = 'dcim/region_list.html' + + +class RegionEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_region' + model = Region + form_class = forms.RegionForm + + def get_return_url(self, obj): + return reverse('dcim:region_list') + + +class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_region' + cls = Region + default_return_url = 'dcim:region_list' + + # # Sites # class SiteListView(ObjectListView): - queryset = Site.objects.select_related('tenant') + queryset = Site.objects.select_related('region', 'tenant') filter = filters.SiteFilter filter_form = forms.SiteFilterForm table = tables.SiteTable @@ -143,7 +168,7 @@ class SiteListView(ObjectListView): def site(request, slug): - site = get_object_or_404(Site, slug=slug) + site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug) stats = { 'rack_count': Rack.objects.filter(site=site).count(), 'device_count': Device.objects.filter(rack__site=site).count(), @@ -263,7 +288,7 @@ class RackListView(ObjectListView): def rack(request, pk): - rack = get_object_or_404(Rack, pk=pk) + rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk) nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True)\ .select_related('device_type__manufacturer') @@ -638,7 +663,9 @@ class DeviceListView(ObjectListView): def device(request, pk): - device = get_object_or_404(Device, pk=pk) + device = get_object_or_404(Device.objects.select_related( + 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' + ), pk=pk) console_ports = natsorted( ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name') ) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 6eef522ec..71e261dce 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -393,7 +393,9 @@ class PrefixListView(ObjectListView): def prefix(request, pk): - prefix = get_object_or_404(Prefix.objects.select_related('site', 'vlan', 'role'), pk=pk) + prefix = get_object_or_404(Prefix.objects.select_related( + 'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role' + ), pk=pk) try: aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix)) @@ -731,7 +733,7 @@ class VLANListView(ObjectListView): def vlan(request, pk): - vlan = get_object_or_404(VLAN.objects.select_related('site', 'role'), pk=pk) + vlan = get_object_or_404(VLAN.objects.select_related('site__region', 'tenant__group', 'role'), pk=pk) prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role') prefix_table = tables.PrefixBriefTable(list(prefixes)) prefix_table.exclude = ('vlan',) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c6ff21b91..db38f3de3 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -104,6 +104,7 @@ INSTALLED_APPS = ( 'django.contrib.humanize', 'debug_toolbar', 'django_tables2', + 'mptt', 'rest_framework', 'rest_framework_swagger', 'circuits', diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 4e63cf337..90bb3ad62 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -37,6 +37,11 @@
  • Import Sites
  • {% endif %}
  • +
  • Regions
  • + {% if perms.dcim.add_region %} +
  • Add a Region
  • + {% endif %} +
  • Tenants
  • {% if perms.tenancy.add_tenant %}
  • Add a Tenant
  • diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index ab54b45a5..f311ccb73 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -66,6 +66,10 @@ Tenant {% if circuit.tenant %} + {% if circuit.tenant.group %} + {{ circuit.tenant.group.name }} + + {% endif %} {{ circuit.tenant }} {% else %} None diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index ba0f8b5fe..948ccfb9a 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -27,6 +27,10 @@ Site + {% if termination.site.region %} + {{ termination.site.region }} + + {% endif %} {{ termination.site }} @@ -34,7 +38,8 @@ Termination {% if termination.interface %} - {{ termination.interface.device }} {{ termination.interface }} + {{ termination.interface.device }} + {{ termination.interface }} {% else %} Not defined {% endif %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 99e06177b..081397774 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -14,19 +14,13 @@ Device
    - - - - @@ -34,7 +28,11 @@ + + + + @@ -91,6 +95,10 @@ + + + + + + + +{% for item in item.child_items.all %} + {% with template_name='dcim/inc/inventoryitem.html' indent=indent|add:20 %} + {% include template_name %} + {% endwith %} +{% endfor %} diff --git a/netbox/templates/dcim/inventoryitem_delete.html b/netbox/templates/dcim/inventoryitem_delete.html new file mode 100644 index 000000000..f5d8c648f --- /dev/null +++ b/netbox/templates/dcim/inventoryitem_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete inventory item {{ inventoryitem }}?{% endblock %} + +{% block message %} +

    Are you sure you want to delete this inventory item from {{ inventoryitem.device }}?

    +{% endblock %} diff --git a/netbox/templates/dcim/module_delete.html b/netbox/templates/dcim/module_delete.html deleted file mode 100644 index 017464293..000000000 --- a/netbox/templates/dcim/module_delete.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'utilities/confirmation_form.html' %} -{% load form_helpers %} - -{% block title %}Delete module {{ module }}?{% endblock %} - -{% block message %} -

    Are you sure you want to delete this module from {{ module.device }}?

    -{% endblock %} From 01f5435f6388a8161553e8abad52f47499a4be2c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Mar 2017 12:59:44 -0400 Subject: [PATCH 097/182] Tweak how we set the API version --- netbox/netbox/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c79175f45..a3c2526e2 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -184,7 +184,7 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH) SECRETS_MIN_PUBKEY_SIZE = 2048 # Django REST framework (API) -REST_FRAMEWORK_VERSION = VERSION.rsplit('.', 1)[0], # Use major.minor as API version +REST_FRAMEWORK_VERSION = VERSION[0:3] # Use major.minor as API version REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.SessionAuthentication', @@ -198,7 +198,7 @@ REST_FRAMEWORK = { 'utilities.api.TokenPermissions', ), 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, - 'ALLOWED_VERSIONS': REST_FRAMEWORK_VERSION, + 'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION], 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', 'PAGE_SIZE': PAGINATE_COUNT, } From 54468ab1a8065ba04535b5eb65c079c5d9f9a723 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Mar 2017 13:23:56 -0400 Subject: [PATCH 098/182] Include the API version in responses --- netbox/netbox/settings.py | 1 + netbox/netbox/urls.py | 2 +- netbox/utilities/middleware.py | 18 +++++++++++++++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a3c2526e2..f62570d2b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -129,6 +129,7 @@ MIDDLEWARE = ( 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', 'utilities.middleware.LoginRequiredMiddleware', + 'utilities.middleware.APIVersionMiddleware', ) ROOT_URLCONF = 'netbox.urls' diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index d44de7f56..e7a76bed9 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -26,7 +26,7 @@ _patterns = [ url(r'^user/', include('users.urls', namespace='user')), # API - url(r'^api/$', APIRootView.as_view()), + url(r'^api/$', APIRootView.as_view(), name='api-root'), url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')), url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')), url(r'^api/extras/', include('extras.api.urls', namespace='extras-api')), diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py index 68f3ef880..4ebfee3ed 100644 --- a/netbox/utilities/middleware.py +++ b/netbox/utilities/middleware.py @@ -1,5 +1,6 @@ from django.http import HttpResponseRedirect from django.conf import settings +from django.core.urlresolvers import reverse BASE_PATH = getattr(settings, 'BASE_PATH', False) @@ -17,7 +18,22 @@ class LoginRequiredMiddleware(object): if LOGIN_REQUIRED and not request.user.is_authenticated(): # Redirect unauthenticated requests to the login page. API requests are exempt from redirection as the API # performs its own authentication. - api_path = '/{}api/'.format(BASE_PATH) + api_path = reverse('api-root') if not request.path_info.startswith(api_path) and request.path_info != settings.LOGIN_URL: return HttpResponseRedirect('{}?next={}'.format(settings.LOGIN_URL, request.path_info)) return self.get_response(request) + + +class APIVersionMiddleware(object): + """ + If the request is for an API endpoint, include the API version as a response header. + """ + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + api_path = reverse('api-root') + response = self.get_response(request) + if request.path_info.startswith(api_path): + response['API-Version'] = settings.REST_FRAMEWORK_VERSION + return response From 7ba6e320e744bff0c5dca452dfc10a8f37b1707b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Mar 2017 13:53:07 -0400 Subject: [PATCH 099/182] Fixes #843: Implemented CORS headers for API --- docs/configuration/optional-settings.md | 16 +++++++++ netbox/netbox/configuration.example.py | 47 +++++++++++++++---------- netbox/netbox/settings.py | 22 ++++++++---- requirements.txt | 1 + 4 files changed, 62 insertions(+), 24 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 9e466ddc1..ed5d2c03c 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -38,6 +38,22 @@ BASE_PATH = 'netbox/' --- +## CORS_ORIGIN_ALLOW_ALL + +Default: False + +If True, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below). + +--- + +## CORS_ORIGIN_WHITELIST + +## CORS_ORIGIN_REGEX_WHITELIST + +These settings specify a list of origins that are authorized to make cross-site API requests. Use `CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is True.) + +--- + ## DEBUG Default: False diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index b85fcafbb..f185a68c7 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -38,6 +38,26 @@ ADMINS = [ # ['John Doe', 'jdoe@example.com'], ] +# Optionally display a persistent banner at the top and/or bottom of every page. To display the same content in both +# banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP. +BANNER_TOP = '' +BANNER_BOTTOM = '' + +# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set: +# BASE_PATH = 'netbox/' +BASE_PATH = '' + +# API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be +# allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or +# CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers +CORS_ORIGIN_ALLOW_ALL = False +CORS_ORIGIN_WHITELIST = [ + # 'hostname.example.com', +] +CORS_ORIGIN_REGEX_WHITELIST = [ + # r'^(https?://)?(\w+\.)?example\.com$', +] + # Email settings EMAIL = { 'SERVER': 'localhost', @@ -48,24 +68,28 @@ EMAIL = { 'FROM_EMAIL': '', } +# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table +# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True. +ENFORCE_GLOBAL_UNIQUE = False + # Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users # are permitted to access most data in NetBox (excluding secrets) but not make any changes. LOGIN_REQUIRED = False -# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set: -# BASE_PATH = 'netbox/' -BASE_PATH = '' - # Setting this to True will display a "maintenance mode" banner at the top of every page. MAINTENANCE_MODE = False -# Credentials that NetBox will use to access live devices. +# Credentials that NetBox will use to access live devices (future use). NETBOX_USERNAME = '' NETBOX_PASSWORD = '' # Determine how many objects to display per page within a list. (Default: 50) PAGINATE_COUNT = 50 +# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to +# prefer IPv4 instead. +PREFER_IPV4 = False + # Time zone (default: UTC) TIME_ZONE = 'UTC' @@ -77,16 +101,3 @@ TIME_FORMAT = 'g:i a' SHORT_TIME_FORMAT = 'H:i:s' DATETIME_FORMAT = 'N j, Y g:i a' SHORT_DATETIME_FORMAT = 'Y-m-d H:i' - -# Optionally display a persistent banner at the top and/or bottom of every page. To display the same content in both -# banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP. -BANNER_TOP = '' -BANNER_BOTTOM = '' - -# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to -# prefer IPv4 instead. -PREFER_IPV4 = False - -# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table -# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True. -ENFORCE_GLOBAL_UNIQUE = False diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f62570d2b..aeec93f06 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -8,19 +8,22 @@ from django.core.exceptions import ImproperlyConfigured try: from netbox import configuration except ImportError: - raise ImproperlyConfigured("Configuration file is not present. Please define netbox/netbox/configuration.py per " - "the documentation.") + raise ImproperlyConfigured( + "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation." + ) VERSION = '2.0.0-dev' # Import local configuration +ALLOWED_HOSTS = DATABASE = SECRET_KEY = None for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: try: globals()[setting] = getattr(configuration, setting) except AttributeError: - raise ImproperlyConfigured("Mandatory setting {} is missing from configuration.py. Please define it per the " - "documentation.".format(setting)) + raise ImproperlyConfigured( + "Mandatory setting {} is missing from configuration.py.".format(setting) + ) # Default configurations ADMINS = getattr(configuration, 'ADMINS', []) @@ -45,6 +48,9 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', False) BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) +CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) +CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) +CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS # Attempt to import LDAP configuration if it has been defined @@ -73,8 +79,10 @@ if LDAP_CONFIGURED: logger.addHandler(logging.StreamHandler()) logger.setLevel(logging.DEBUG) except ImportError: - raise ImproperlyConfigured("LDAP authentication has been configured, but django-auth-ldap is not installed. " - "You can remove netbox/ldap_config.py to disable LDAP.") + raise ImproperlyConfigured( + "LDAP authentication has been configured, but django-auth-ldap is not installed. You can remove " + "netbox/ldap_config.py to disable LDAP." + ) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -102,6 +110,7 @@ INSTALLED_APPS = ( 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.humanize', + 'corsheaders', 'debug_toolbar', 'django_tables2', 'mptt', @@ -120,6 +129,7 @@ INSTALLED_APPS = ( # Middleware MIDDLEWARE = ( 'debug_toolbar.middleware.DebugToolbarMiddleware', + 'corsheaders.middleware.CorsMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', diff --git a/requirements.txt b/requirements.txt index 2c6044f73..b732ab1b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ cffi>=1.8 cryptography>=1.4 Django>=1.10 +django-cors-headers>=2.0 django-debug-toolbar>=1.6 django-filter>=1.0.1 django-mptt==0.8.7 From 5c4741c5d48d78ce94769d0b8f3781016251ca3e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Mar 2017 14:34:52 -0400 Subject: [PATCH 100/182] Added section on pagination --- docs/api/overview.md | 49 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/api/overview.md b/docs/api/overview.md index c020d49c4..4086d9fad 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -87,3 +87,52 @@ When a base serializer includes one or more nested serializers, the hierarchical "description": "" } ``` + +# Pagination + +API responses which contain a list of objects (for example, a request to `/api/dcim/devices/`) will be paginated to avoid unnecessary overhead. The root JSON object will contain the following attributes: + +* `count`: The total count of all objects matching the query +* `next`: A hyperlink to the next page of results (if applicable) +* `previous`: A hyperlink to the previous page of results (if applicable) +* `results`: The list of returned objects + +Here is an example of a paginated response: + +``` +HTTP 200 OK +Allow: GET, POST, OPTIONS +Content-Type: application/json +Vary: Accept + +{ + "count": 2861, + "next": "http://localhost:8000/api/dcim/devices/?limit=50&offset=50", + "previous": null, + "results": [ + { + "id": 123, + "name": "DeviceName123", + ... + }, + ... + ] +} +``` + +The default page size derives from the `[PAGINATE_COUNT](../configuration/optional-settings/#paginate_count)` configuration setting, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: + +``` +http://localhost:8000/api/dcim/devices/?limit=100 +``` + +The response will return devices 1 through 100. The URL provided in the `next` attribute of the response will return devices 101 through 200: + +``` +{ + "count": 2861, + "next": "http://localhost:8000/api/dcim/devices/?limit=100&offset=100", + "previous": null, + "results": [...] +} +``` From 6d30fdb83dbc5f4f7eaccddd95658a5791c9020a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Mar 2017 15:30:36 -0400 Subject: [PATCH 101/182] Finished work on secrets views; removed path from cookie assignment --- netbox/secrets/api/views.py | 5 ++++- netbox/secrets/views.py | 40 +++++++++++++++++++++---------------- netbox/users/views.py | 2 +- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index adab5e51d..63476b126 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -169,6 +169,9 @@ class GetSessionKeyViewSet(ViewSet): sk = SessionKey(userkey=user_key) sk.save(master_key=master_key) encoded_key = base64.b64encode(sk.key) + # b64decode() returns a bytestring under Python 3 + if not isinstance(encoded_key, str): + encoded_key = encoded_key.decode() # Craft the response response = Response({ @@ -177,7 +180,7 @@ class GetSessionKeyViewSet(ViewSet): # If token authentication is not in use, assign the session key as a cookie if request.auth is None: - response.set_cookie('session_key', value=encoded_key, path=reverse('secrets-api:secret-list')) + response.set_cookie('session_key', value=encoded_key) return response diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 5a90eca6e..8a1b35a6b 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -79,23 +79,30 @@ def secret_add(request, pk): form = forms.SecretForm(request.POST, instance=secret) if form.is_valid(): - # Retrieve the master key from the current user's UserKey - master_key = uk.get_master_key(form.cleaned_data['private_key']) - if master_key is None: - form.add_error(None, "Invalid private key! Unable to encrypt secret data.") + # We need a valid session key in order to create a Secret + session_key = base64.b64decode(request.COOKIES.get('session_key', None)) + if session_key is None: + form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") # Create and encrypt the new Secret else: - secret = form.save(commit=False) - secret.plaintext = str(form.cleaned_data['plaintext']) - secret.encrypt(master_key) - secret.save() + master_key = None + try: + sk = SessionKey.objects.get(userkey__user=request.user) + master_key = sk.get_master_key(session_key) + except SessionKey.DoesNotExist: + form.add_error(None, "No session key found for this user.") - messages.success(request, u"Added new secret: {}.".format(secret)) - if '_addanother' in request.POST: - return redirect('dcim:device_addsecret', pk=device.pk) - else: - return redirect('secrets:secret', pk=secret.pk) + if master_key is not None: + secret = form.save(commit=False) + secret.plaintext = str(form.cleaned_data['plaintext']) + secret.encrypt(master_key) + secret.save() + messages.success(request, u"Added new secret: {}.".format(secret)) + if '_addanother' in request.POST: + return redirect('dcim:device_addsecret', pk=device.pk) + else: + return redirect('secrets:secret', pk=secret.pk) else: form = forms.SecretForm(instance=secret) @@ -118,14 +125,13 @@ def secret_edit(request, pk): if form.is_valid(): # Re-encrypt the Secret if a plaintext and session key have been provided. - session_key = request.COOKIES.get('session_key', None) + session_key = base64.b64decode(request.COOKIES.get('session_key', None)) if form.cleaned_data['plaintext'] and session_key is not None: # Retrieve the master key using the provided session key - session_key = base64.b64decode(session_key) master_key = None try: - sk = SessionKey.objects.get(user=request.user) + sk = SessionKey.objects.get(userkey__user=request.user) master_key = sk.get_master_key(session_key) except SessionKey.DoesNotExist: form.add_error(None, "No session key found for this user.") @@ -186,7 +192,7 @@ def secret_import(request): session_key = base64.b64decode(session_key) master_key = None try: - sk = SessionKey.objects.get(user=request.user) + sk = SessionKey.objects.get(userkey__user=request.user) master_key = sk.get_master_key(session_key) except SessionKey.DoesNotExist: form.add_error(None, "No session key found for this user.") diff --git a/netbox/users/views.py b/netbox/users/views.py index 41cecb96a..711c5a9c0 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -149,7 +149,7 @@ class SessionKeyDeleteView(LoginRequiredMixin, View): # Delete cookie response = redirect('user:userkey') - response.delete_cookie('session_key', path=reverse('secrets-api:secret-list')) + response.delete_cookie('session_key') return response From a36b138efee046268122bec619c05dc75f85ed79 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Mar 2017 16:00:02 -0400 Subject: [PATCH 102/182] Added API doc for working with secrets --- docs/api/working-with-secrets.md | 136 +++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 137 insertions(+) create mode 100644 docs/api/working-with-secrets.md diff --git a/docs/api/working-with-secrets.md b/docs/api/working-with-secrets.md new file mode 100644 index 000000000..35091aaf6 --- /dev/null +++ b/docs/api/working-with-secrets.md @@ -0,0 +1,136 @@ +As with most other objects, the NetBox API can be used to create, modify, and delete secrets. However, additional steps are needed to encrypt or decrypt secret data. + +# Generating a Session Key + +In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../data-model/secrets/#user-keys). The private key must be POSTed with the name `private_key`. + +``` +$ curl -X POST http://localhost:8000/api/secrets/get-session-key/ \ +-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \ +-H "Accept: application/json; indent=4" \ +--data-urlencode "private_key@" +{ + "session_key": "dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk=" +} +``` + +!!! note + To read the private key from a file, use the convention above. Alternatively, the private key can be read from an environment variable using `--data-urlencode "private_key=$PRIVATE_KEY"`. + +The request uses your private key to unlock your stored copy of the master key and generate a session key which can be attached in the `X-Session-Key` header of future API requests. + +# Retrieving Secrets + +A session key is not needed to retrieve unencrypted secrets: The secret is returned like any normal object with its `plaintext` field set to null. + +``` +$ curl http://localhost:8000/api/secrets/secrets/2587/ \ +-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \ +-H "Accept: application/json; indent=4" +{ + "id": 2587, + "device": { + "id": 1827, + "url": "http://localhost:8000/api/dcim/devices/1827/", + "name": "MyTestDevice", + "display_name": "MyTestDevice" + }, + "role": { + "id": 1, + "url": "http://localhost:8000/api/secrets/secret-roles/1/", + "name": "Login Credentials", + "slug": "login-creds" + }, + "name": "admin", + "plaintext": null, + "hash": "pbkdf2_sha256$1000$G6mMFe4FetZQ$f+0itZbAoUqW5pd8+NH8W5rdp/2QNLIBb+LGdt4OSKA=", + "created": "2017-03-21", + "last_updated": "2017-03-21T19:28:44.265582Z" +} +``` + +To decrypt a secret, we must include our session key in the `X-Session-Key` header: + +``` +$ curl http://localhost:8000/api/secrets/secrets/2587/ \ +-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \ +-H "Accept: application/json; indent=4" \ +-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk=" +{ + "id": 2587, + "device": { + "id": 1827, + "url": "http://localhost:8000/api/dcim/devices/1827/", + "name": "MyTestDevice", + "display_name": "MyTestDevice" + }, + "role": { + "id": 1, + "url": "http://localhost:8000/api/secrets/secret-roles/1/", + "name": "Login Credentials", + "slug": "login-creds" + }, + "name": "admin", + "plaintext": "foobar", + "hash": "pbkdf2_sha256$1000$G6mMFe4FetZQ$f+0itZbAoUqW5pd8+NH8W5rdp/2QNLIBb+LGdt4OSKA=", + "created": "2017-03-21", + "last_updated": "2017-03-21T19:28:44.265582Z" +} +``` + +Lists of secrets can be decrypted in this manner as well: + +``` +$ curl http://localhost:8000/api/secrets/secrets/?limit=3 \ +-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \ +-H "Accept: application/json; indent=4" \ +-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk=" +{ + "count": 3482, + "next": "http://localhost:8000/api/secrets/secrets/?limit=3&offset=3", + "previous": null, + "results": [ + { + "id": 2587, + ... + "plaintext": "foobar", + ... + }, + { + "id": 2588, + ... + "plaintext": "MyP@ssw0rd!", + ... + }, + { + "id": 2589, + ... + "plaintext": "AnotherSecret!", + ... + }, + ] +} +``` + +# Creating Secrets + +Session keys are also used to decrypt new or modified secrets. This is done by setting the `plaintext` field of the submitted object: + +``` +$ curl -X POST http://localhost:8000/api/secrets/secrets/ \ +-H "Content-Type: application/json" \ +-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \ +-H "Accept: application/json; indent=4" \ +-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk=" \ +--data '{"device": 1827, "role": 1, "name": "backup", "plaintext": "Drowssap1"}' +{ + "id": 2590, + "device": 1827, + "role": 1, + "name": "backup", + "plaintext": "Drowssap1" +} +``` + +!!! note + Don't forget to include the `Content-Type: application/json` header when making a POST request. diff --git a/mkdocs.yml b/mkdocs.yml index c0ee34356..d0347c8ce 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,6 +22,7 @@ pages: - 'API': - 'Overview': 'api/overview.md' - 'Authentication': 'api/authentication.md' + - 'Working with Secrets': 'api/working-with-secrets.md' - 'Examples': 'api/examples.md' markdown_extensions: From a5419ecc5c141c52125ebbd00b2e04b59cbcd31d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Mar 2017 17:24:16 -0400 Subject: [PATCH 103/182] RPC API fixes --- netbox/extras/rpc.py | 7 +++++-- netbox/templates/dcim/device_lldp_neighbors.html | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/netbox/extras/rpc.py b/netbox/extras/rpc.py index ae651b162..208ec20dd 100644 --- a/netbox/extras/rpc.py +++ b/netbox/extras/rpc.py @@ -130,8 +130,11 @@ class JunosNC(RPCClient): for neighbor_raw in lldp_neighbors_raw: neighbor = dict() neighbor['local-interface'] = neighbor_raw.get('lldp-local-port-id') - neighbor['name'] = neighbor_raw.get('lldp-remote-system-name') - neighbor['name'] = neighbor['name'].split('.')[0] # Split hostname from domain if one is present + name = neighbor_raw.get('lldp-remote-system-name') + if name: + neighbor['name'] = name.split('.')[0] # Split hostname from domain if one is present + else: + neighbor['name'] = '' try: neighbor['remote-interface'] = neighbor_raw['lldp-remote-port-description'] except KeyError: diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html index 6a5446c3b..2b95b0035 100644 --- a/netbox/templates/dcim/device_lldp_neighbors.html +++ b/netbox/templates/dcim/device_lldp_neighbors.html @@ -47,7 +47,7 @@ - + {% block javascript %}{% endblock %} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 8ccc05de2..22e02c4ee 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -90,5 +90,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index d6843358c..3cdeea36f 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -199,5 +199,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/consoleport_connect.html b/netbox/templates/dcim/consoleport_connect.html index f896ebbd5..a04a4674b 100644 --- a/netbox/templates/dcim/consoleport_connect.html +++ b/netbox/templates/dcim/consoleport_connect.html @@ -58,5 +58,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/consoleserverport_connect.html b/netbox/templates/dcim/consoleserverport_connect.html index 08850d1ed..6ba944b59 100644 --- a/netbox/templates/dcim/consoleserverport_connect.html +++ b/netbox/templates/dcim/consoleserverport_connect.html @@ -58,5 +58,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index d38f60cb3..2df324a69 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -629,6 +629,6 @@ $(".interface-toggle").click(function() { return toggleConnection($(this), "dcim/interface-connections/"); }); - - + + {% endblock %} diff --git a/netbox/templates/dcim/interfaceconnection_edit.html b/netbox/templates/dcim/interfaceconnection_edit.html index ad0335398..488c11472 100644 --- a/netbox/templates/dcim/interfaceconnection_edit.html +++ b/netbox/templates/dcim/interfaceconnection_edit.html @@ -93,5 +93,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_connect.html b/netbox/templates/dcim/poweroutlet_connect.html index 927ed7b71..6fcc3e858 100644 --- a/netbox/templates/dcim/poweroutlet_connect.html +++ b/netbox/templates/dcim/poweroutlet_connect.html @@ -58,5 +58,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/powerport_connect.html b/netbox/templates/dcim/powerport_connect.html index 9e7f1fae9..f77a0e352 100644 --- a/netbox/templates/dcim/powerport_connect.html +++ b/netbox/templates/dcim/powerport_connect.html @@ -58,5 +58,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 772474155..b83139b53 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -260,5 +260,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/ipam/ipaddress_assign.html b/netbox/templates/ipam/ipaddress_assign.html index 4b27eae0b..03ec181ff 100644 --- a/netbox/templates/ipam/ipaddress_assign.html +++ b/netbox/templates/ipam/ipaddress_assign.html @@ -68,5 +68,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index 7226a75fc..c0a210cc1 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -79,5 +79,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 391bc8868..016015cbe 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -97,5 +97,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index a83024fb4..0ed5cc875 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -70,5 +70,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/secrets/secret_import.html b/netbox/templates/secrets/secret_import.html index 0a9a11c69..76022fac1 100644 --- a/netbox/templates/secrets/secret_import.html +++ b/netbox/templates/secrets/secret_import.html @@ -68,5 +68,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/users/userkey_edit.html b/netbox/templates/users/userkey_edit.html index 45bac1938..c590f4423 100644 --- a/netbox/templates/users/userkey_edit.html +++ b/netbox/templates/users/userkey_edit.html @@ -56,5 +56,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} From b643939cc4197fd288b9ef33152cc9ae9de6610f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Mar 2017 21:55:57 -0400 Subject: [PATCH 125/182] Initial work on #152: Image attachments --- netbox/dcim/models.py | 3 +- netbox/dcim/urls.py | 3 + netbox/extras/forms.py | 12 +++- .../migrations/0005_add_imageattachment.py | 34 ++++++++++++ netbox/extras/models.py | 55 +++++++++++++++++++ netbox/extras/urls.py | 12 ++++ netbox/extras/views.py | 30 ++++++++++ netbox/media/image-attachments/.gitignore | 2 + netbox/netbox/settings.py | 7 ++- netbox/netbox/urls.py | 3 + netbox/templates/dcim/rack.html | 49 +++++++++++++++++ netbox/templates/utilities/obj_edit.html | 2 +- netbox/utilities/forms.py | 11 ++-- netbox/utilities/views.py | 2 +- requirements.txt | 1 + 15 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 netbox/extras/migrations/0005_add_imageattachment.py create mode 100644 netbox/extras/urls.py create mode 100644 netbox/extras/views.py create mode 100644 netbox/media/image-attachments/.gitignore diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index d0971b556..76d8c7fbc 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -15,7 +15,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist from django.utils.encoding import python_2_unicode_compatible from circuits.models import Circuit -from extras.models import CustomFieldModel, CustomField, CustomFieldValue +from extras.models import CustomFieldModel, CustomField, CustomFieldValue, ImageAttachment from extras.rpc import RPC_CLIENTS from tenancy.models import Tenant from utilities.fields import ColorField, NullableCharField @@ -375,6 +375,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): help_text='Units are numbered top-to-bottom') comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + images = GenericRelation(ImageAttachment) objects = RackManager() diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index b4731df33..9e35a1d85 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -3,6 +3,8 @@ from django.conf.urls import url from ipam.views import ServiceEditView from secrets.views import secret_add +from extras.views import ImageAttachmentEditView +from .models import Rack from . import views @@ -49,6 +51,7 @@ urlpatterns = [ url(r'^racks/(?P\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'), url(r'^racks/(?P\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'), url(r'^racks/(?P\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'), + url(r'^racks/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), # Manufacturers url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'), diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index b4549fcf1..d85697c8d 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -3,9 +3,10 @@ from collections import OrderedDict from django import forms from django.contrib.contenttypes.models import ContentType -from utilities.forms import BulkEditForm, LaxURLField +from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField from .models import ( - CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue + CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue, + ImageAttachment, ) @@ -158,3 +159,10 @@ class CustomFieldFilterForm(forms.Form): for name, field in custom_fields: field.required = False self.fields[name] = field + + +class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = ImageAttachment + fields = ['name', 'image'] diff --git a/netbox/extras/migrations/0005_add_imageattachment.py b/netbox/extras/migrations/0005_add_imageattachment.py new file mode 100644 index 000000000..23ed8b786 --- /dev/null +++ b/netbox/extras/migrations/0005_add_imageattachment.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.6 on 2017-03-30 21:09 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import extras.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0004_topologymap_change_comma_to_semicolon'), + ] + + operations = [ + migrations.CreateModel( + name='ImageAttachment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('image', models.ImageField(height_field=b'image_height', upload_to=extras.models.image_upload, width_field=b'image_width')), + ('image_height', models.PositiveSmallIntegerField()), + ('image_width', models.PositiveSmallIntegerField()), + ('name', models.CharField(blank=True, max_length=50)), + ('created', models.DateTimeField(auto_now_add=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ], + options={ + 'ordering': ['name'], + }, + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 3101757d6..cdf2af31c 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -359,6 +359,61 @@ class TopologyMap(models.Model): return graph.pipe(format=img_format) +# +# Image attachments +# + +def image_upload(instance, filename): + + path = 'image-attachments/' + + # Rename the file to the provided name, if any. Attempt to preserve the file extension. + extension = filename.rsplit('.')[-1] + if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']: + filename = '.'.join([instance.name, extension]) + elif instance.name: + filename = instance.name + + return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) + + +@python_2_unicode_compatible +class ImageAttachment(models.Model): + """ + An uploaded image which is associated with an object. + """ + content_type = models.ForeignKey(ContentType) + object_id = models.PositiveIntegerField() + obj = GenericForeignKey('content_type', 'object_id') + image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width') + image_height = models.PositiveSmallIntegerField() + image_width = models.PositiveSmallIntegerField() + name = models.CharField(max_length=50, blank=True) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['name'] + + def __str__(self): + if self.name: + return self.name + filename = self.image.name.rsplit('/', 1)[-1] + return filename.split('_', 2)[2] + + def delete(self, *args, **kwargs): + + _name = self.image.name + + super(ImageAttachment, self).delete(*args, **kwargs) + + # Delete file from disk + self.image.delete(save=False) + + # Deleting the file erases its name. We restore the image's filename here in case we still need to reference it + # before the request finishes. (For example, to display a message indicating the ImageAttachment was deleted.) + self.image.name = _name + + # # User actions # diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py new file mode 100644 index 000000000..6e0e91a0d --- /dev/null +++ b/netbox/extras/urls.py @@ -0,0 +1,12 @@ +from django.conf.urls import url + +from extras import views + + +urlpatterns = [ + + # Image attachments + url(r'^image-attachments/(?P\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), + url(r'^image-attachments/(?P\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + +] diff --git a/netbox/extras/views.py b/netbox/extras/views.py new file mode 100644 index 000000000..af0a98745 --- /dev/null +++ b/netbox/extras/views.py @@ -0,0 +1,30 @@ +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.shortcuts import get_object_or_404 + +from utilities.views import ObjectDeleteView, ObjectEditView +from .forms import ImageAttachmentForm +from .models import ImageAttachment + + +class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'extras.change_imageattachment' + model = ImageAttachment + form_class = ImageAttachmentForm + + def alter_obj(self, imageattachment, request, args, kwargs): + if not imageattachment.pk: + # Assign the parent object based on URL kwargs + model = kwargs.get('model') + imageattachment.obj = get_object_or_404(model, pk=kwargs['object_id']) + return imageattachment + + def get_return_url(self, imageattachment): + return imageattachment.obj.get_absolute_url() + + +class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_imageattachment' + model = ImageAttachment + + def get_return_url(self, imageattachment): + return imageattachment.obj.get_absolute_url() diff --git a/netbox/media/image-attachments/.gitignore b/netbox/media/image-attachments/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/netbox/media/image-attachments/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index aeec93f06..4a486c434 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -153,6 +153,7 @@ TEMPLATES = [ 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', + 'django.template.context_processors.media', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'utilities.context_processors.settings', @@ -167,19 +168,21 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') USE_X_FORWARDED_HOST = True # Internationalization -# https://docs.djangoproject.com/en/1.8/topics/i18n/ LANGUAGE_CODE = 'en-us' USE_I18N = True USE_TZ = True # Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.8/howto/static-files/ STATIC_ROOT = BASE_DIR + '/static/' STATIC_URL = '/{}static/'.format(BASE_PATH) STATICFILES_DIRS = ( os.path.join(BASE_DIR, "project-static"), ) +# Media +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + # Disable default limit of 1000 fields per request. Needed for bulk deletion of objects. (Added in Django 1.10.) DATA_UPLOAD_MAX_NUMBER_FIELDS = None diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 724ab3090..8a81e3ebb 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -1,6 +1,7 @@ from django.conf import settings from django.conf.urls import include, url from django.contrib import admin +from django.views.static import serve from netbox.views import APIRootView, home, handle_500, SearchView, trigger_500 from users.views import login, logout @@ -21,6 +22,7 @@ _patterns = [ # Apps url(r'^circuits/', include('circuits.urls', namespace='circuits')), url(r'^dcim/', include('dcim.urls', namespace='dcim')), + url(r'^extras/', include('extras.urls', namespace='extras')), url(r'^ipam/', include('ipam.urls', namespace='ipam')), url(r'^secrets/', include('secrets.urls', namespace='secrets')), url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')), @@ -48,6 +50,7 @@ if settings.DEBUG: import debug_toolbar _patterns += [ url(r'^__debug__/', include(debug_toolbar.urls)), + url(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), ] # Prepend BASE_PATH diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index d6529c2a4..4ef8277e2 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -197,6 +197,55 @@ {% endif %} +
    +
    + Images +
    + {% if rack.images.all %} +
    Tenant - {% if device.tenant %} - {{ device.tenant }} - {% else %} - None - {% endif %} -
    Site + {% if device.site.region %} + {{ device.site.region }} + + {% endif %} {{ device.site }}
    Rack {% if device.rack %} - {{ device.rack.name }}{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %} + {% if device.rack.group %} + {{ device.rack.group.name }} + + {% endif %} + {{ device.rack.name }}{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %} {% else %} None {% endif %} @@ -57,6 +55,20 @@ {% endif %}
    Tenant + {% if device.tenant %} + {% if device.tenant.group %} + {{ device.tenant.group.name }} + + {% endif %} + {{ device.tenant }} + {% else %} + None + {% endif %} +
    Device Type @@ -393,7 +405,7 @@ {% endif %} {% endif %} {% if interfaces or device.device_type.is_network_device %} - {% if perms.dcim.delete_interface %} + {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
    {% csrf_token %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 37cddf213..d6529c2a4 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -64,6 +64,10 @@
    Site + {% if rack.site.region %} + {{ rack.site.region }} + + {% endif %} {{ rack.site }}
    Tenant {% if rack.tenant %} + {% if rack.tenant.group %} + {{ rack.tenant.group.name }} + + {% endif %} {{ rack.tenant }} {% else %} None diff --git a/netbox/templates/dcim/region_list.html b/netbox/templates/dcim/region_list.html new file mode 100644 index 000000000..b54201a34 --- /dev/null +++ b/netbox/templates/dcim/region_list.html @@ -0,0 +1,21 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}Regions{% endblock %} + +{% block content %} +
    + {% if perms.dcim.add_region %} + + + Add a region + + {% endif %} +
    +

    {{ block.title }}

    +
    +
    + {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:region_bulk_delete' %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index b6f9c8847..772474155 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -9,7 +9,12 @@
    @@ -55,10 +60,28 @@ Site
    + + + + +
    Region + {% if site.region %} + {% for region in site.region.get_ancestors %} + {{ region }} + + {% endfor %} + {{ site.region }} + {% else %} + None + {% endif %} +
    Tenant {% if site.tenant %} + {% if site.tenant.group %} + {{ site.tenant.group.name }} + + {% endif %} {{ site.tenant }} {% else %} None @@ -85,6 +108,13 @@ {% endif %}
    + +
    +
    + Contact Info +
    + + + + + + @@ -71,7 +76,7 @@
    Physical Address diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index d1f211adb..98f16ad25 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -7,6 +7,7 @@
    {% render_field form.name %} {% render_field form.slug %} + {% render_field form.region %} {% render_field form.tenant %} {% render_field form.facility %} {% render_field form.asn %} diff --git a/netbox/templates/dcim/site_import.html b/netbox/templates/dcim/site_import.html index 3018cc2f1..a7ac47ab5 100644 --- a/netbox/templates/dcim/site_import.html +++ b/netbox/templates/dcim/site_import.html @@ -38,6 +38,11 @@
    URL-friendly name ash4-south
    RegionName of region (optional)North America
    Tenant Name of tenant (optional)

    Example

    -
    ASH-4 South,ash4-south,Pied Piper,Equinix DC6,65000,Hank Hill,+1-214-555-1234,hhill@example.com
    +
    ASH-4 South,ash4-south,North America,Pied Piper,Equinix DC6,65000,Hank Hill,+1-214-555-1234,hhill@example.com
    {% endblock %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 4ad5dba05..9187b81b4 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -30,8 +30,16 @@
    Tenant {% if prefix.tenant %} + {% if prefix.tenant.group %} + {{ prefix.tenant.group.name }} + + {% endif %} {{ prefix.tenant }} {% elif prefix.vrf.tenant %} + {% if prefix.vrf.tenant.group %} + {{ prefix.vrf.tenant.group.name }} + + {% endif %} {{ prefix.vrf.tenant }} {% else %} @@ -53,6 +61,10 @@ Site {% if prefix.site %} + {% if prefix.site.region %} + {{ prefix.site.region }} + + {% endif %} {{ prefix.site }} {% else %} None @@ -63,6 +75,10 @@ VLAN {% if prefix.vlan %} + {% if prefix.vlan.group %} + {{ prefix.vlan.group.name }} + + {% endif %} {{ prefix.vlan.display_name }} {% else %} None @@ -79,7 +95,7 @@ Role {% if prefix.role %} - {{ prefix.role }} + {{ prefix.role }} {% else %} None {% endif %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 1b6ddb5dd..6c1fb07d2 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -57,6 +57,10 @@ Site {% if vlan.site %} + {% if vlan.site.region %} + {{ vlan.site.region }} + + {% endif %} {{ vlan.site }} {% else %} None @@ -85,6 +89,10 @@ Tenant {% if vlan.tenant %} + {% if vlan.tenant.group %} + {{ vlan.tenant.group.name }} + + {% endif %} {{ vlan.tenant }} {% else %} None @@ -101,7 +109,7 @@ Role {% if vlan.role %} - {{ vlan.role }} + {{ vlan.role }} {% else %} None {% endif %} diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 6eb11c208..76ce1796c 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -2,6 +2,8 @@ import csv import itertools import re +from mptt.forms import TreeNodeMultipleChoiceField + from django import forms from django.conf import settings from django.core.urlresolvers import reverse_lazy @@ -365,7 +367,7 @@ class SlugField(forms.SlugField): self.widget.attrs['slug-source'] = slug_source -class FilterChoiceField(forms.ModelMultipleChoiceField): +class FilterChoiceFieldMixin(object): iterator = forms.models.ModelChoiceIterator def __init__(self, null_option=None, *args, **kwargs): @@ -374,12 +376,13 @@ class FilterChoiceField(forms.ModelMultipleChoiceField): kwargs['required'] = False if 'widget' not in kwargs: kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) - super(FilterChoiceField, self).__init__(*args, **kwargs) + super(FilterChoiceFieldMixin, self).__init__(*args, **kwargs) def label_from_instance(self, obj): + label = super(FilterChoiceFieldMixin, self).label_from_instance(obj) if hasattr(obj, 'filter_count'): - return u'{} ({})'.format(obj, obj.filter_count) - return force_text(obj) + return u'{} ({})'.format(label, obj.filter_count) + return label def _get_choices(self): if hasattr(self, '_choices'): @@ -391,6 +394,14 @@ class FilterChoiceField(forms.ModelMultipleChoiceField): choices = property(_get_choices, forms.ChoiceField._set_choices) +class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField): + pass + + +class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField): + pass + + class LaxURLField(forms.URLField): """ Custom URLField which allows any valid URL scheme diff --git a/requirements.txt b/requirements.txt index caa678f4c..2d1c81ffa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ cryptography>=1.4 Django>=1.10 django-debug-toolbar>=1.6 django-filter==0.15.3 +django-mptt==0.8.7 django-rest-swagger==0.3.10 django-tables2>=1.2.5 djangorestframework>=3.5.0 From 90fe556e5fd5fbb1149fc58cca78a80022b68761 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Feb 2017 16:21:31 -0500 Subject: [PATCH 037/182] Corrected region serializers --- netbox/dcim/api/serializers.py | 8 ++++---- netbox/dcim/api/views.py | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 70377b1ba..9cae166eb 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -30,7 +30,7 @@ class RegionSerializer(serializers.ModelSerializer): class Meta: model = Region - fields = ['id', 'url', 'name', 'slug', 'parent'] + fields = ['id', 'name', 'slug', 'parent'] class WritableRegionSerializer(serializers.ModelSerializer): @@ -51,9 +51,9 @@ class SiteSerializer(CustomFieldModelSerializer): class Meta: model = Site fields = [ - 'id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name', - 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes', 'count_vlans', - 'count_racks', 'count_devices', 'count_circuits', + 'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', + 'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes', + 'count_vlans', 'count_racks', 'count_devices', 'count_circuits', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 6e65ac595..1bf13b57b 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -29,7 +29,7 @@ from . import serializers # Regions # -class RegionViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class RegionViewSet(WritableSerializerMixin, ModelViewSet): queryset = Region.objects.all() serializer_class = serializers.RegionSerializer write_serializer_class = serializers.WritableRegionSerializer @@ -40,8 +40,9 @@ class RegionViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet): - queryset = Site.objects.select_related('tenant') + queryset = Site.objects.select_related('region', 'tenant') serializer_class = serializers.SiteSerializer + filter_class = filters.SiteFilter write_serializer_class = serializers.WritableSiteSerializer @detail_route() From a7a7b956b173ca11bb3b0242953f433403974080 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Mar 2017 16:20:16 -0500 Subject: [PATCH 038/182] Enable API versioning --- netbox/netbox/settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 5b3995367..647e5f957 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.9.1-dev' +VERSION = '2.0.0-dev' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: @@ -187,6 +187,8 @@ SECRETS_MIN_PUBKEY_SIZE = 2048 REST_FRAMEWORK = { 'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',), 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'DEFAULT_VERSION': VERSION.rsplit('.', 1)[0], # Use major.minor as API version + 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', 'PAGE_SIZE': PAGINATE_COUNT, } if LOGIN_REQUIRED: From be0a3fb1f2c9dddf4f7fead26e2f97c54a92c016 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 7 Mar 2017 16:55:49 -0500 Subject: [PATCH 039/182] Corrected merge conflict --- requirements.txt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index ddf3a336f..2c6044f73 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,7 @@ cffi>=1.8 cryptography>=1.4 Django>=1.10 django-debug-toolbar>=1.6 -<<<<<<< HEAD -django-filter==0.15.3 -======= django-filter>=1.0.1 ->>>>>>> develop django-mptt==0.8.7 django-rest-swagger==0.3.10 django-tables2>=1.2.5 From 0b10d98e0bd55eb11fb5058fa5b7f645d6abc99e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 7 Mar 2017 17:17:39 -0500 Subject: [PATCH 040/182] Initial work on token authentication --- netbox/netbox/settings.py | 13 ++++-- netbox/users/admin.py | 8 ++++ netbox/users/migrations/0001_api_tokens.py | 31 ++++++++++++++ netbox/users/migrations/__init__.py | 0 netbox/users/models.py | 32 ++++++++++++++ netbox/utilities/api.py | 50 +++++++++++++++++++++- 6 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 netbox/users/admin.py create mode 100644 netbox/users/migrations/0001_api_tokens.py create mode 100644 netbox/users/migrations/__init__.py diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 647e5f957..454e4ccd5 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -185,14 +185,21 @@ SECRETS_MIN_PUBKEY_SIZE = 2048 # Django REST framework (API) REST_FRAMEWORK = { - 'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',), + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.SessionAuthentication', + 'utilities.api.TokenAuthentication', + ), + 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework.filters.DjangoFilterBackend', + ), 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'DEFAULT_PERMISSION_CLASSES': ( + 'utilities.api.TokenPermissions', + ), 'DEFAULT_VERSION': VERSION.rsplit('.', 1)[0], # Use major.minor as API version 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', 'PAGE_SIZE': PAGINATE_COUNT, } -if LOGIN_REQUIRED: - REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = ('rest_framework.permissions.IsAuthenticated',) # Django debug toolbar INTERNAL_IPS = ( diff --git a/netbox/users/admin.py b/netbox/users/admin.py new file mode 100644 index 000000000..29c149f21 --- /dev/null +++ b/netbox/users/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from .models import Token + + +@admin.register(Token) +class TokenAdmin(admin.ModelAdmin): + list_display = ['user', 'key', 'created', 'expires', 'write_enabled', 'description'] diff --git a/netbox/users/migrations/0001_api_tokens.py b/netbox/users/migrations/0001_api_tokens.py new file mode 100644 index 000000000..0f0943925 --- /dev/null +++ b/netbox/users/migrations/0001_api_tokens.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.6 on 2017-03-07 20:57 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Token', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('expires', models.DateTimeField(blank=True, null=True)), + ('key', models.CharField(max_length=64, unique=True)), + ('write_enabled', models.BooleanField(default=True, help_text=b'Permit POST/PUT/DELETE operations using this key')), + ('description', models.CharField(blank=True, max_length=100)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/netbox/users/migrations/__init__.py b/netbox/users/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/users/models.py b/netbox/users/models.py index e69de29bb..02a95c384 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -0,0 +1,32 @@ +import binascii +import os + +from django.contrib.auth.models import User +from django.db import models +from django.utils.encoding import python_2_unicode_compatible + + +@python_2_unicode_compatible +class Token(models.Model): + """ + An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. + It also supports setting an expiration time and toggling write ability. + """ + user = models.ForeignKey(User, related_name='tokens', on_delete=models.CASCADE) + created = models.DateTimeField(auto_now_add=True) + expires = models.DateTimeField(blank=True, null=True) + key = models.CharField(max_length=64, unique=True) + write_enabled = models.BooleanField(default=True, help_text="Permit create/update/delete operations using this key") + description = models.CharField(max_length=100, blank=True) + + def __str__(self): + return u"API key for {}".format(self.user) + + def save(self, *args, **kwargs): + if not self.key: + self.key = self.generate_key() + return super(Token, self).save(*args, **kwargs) + + def generate_key(self): + # Generate a random 256-bit key expressed in hexadecimal. + return binascii.hexlify(os.urandom(32)).decode() diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 6ec252bbd..64142812c 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,6 +1,13 @@ +from django.conf import settings +from django.utils import timezone + +from rest_framework import authentication, exceptions from rest_framework.exceptions import APIException +from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS from rest_framework.serializers import Field +from users.models import Token + WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] @@ -10,11 +17,51 @@ class ServiceUnavailable(APIException): default_detail = "Service temporarily unavailable, please try again later." +class TokenAuthentication(authentication.TokenAuthentication): + """ + A custom authentication scheme which enforces Token expiration times. + """ + model = Token + + def authenticate_credentials(self, key): + model = self.get_model() + try: + token = model.objects.select_related('user').get(key=key) + except model.DoesNotExist: + raise exceptions.AuthenticationFailed("Invalid token") + + # Enforce the Token's expiration time, if one has been set. + if token.expires and token.expires < timezone.now(): + raise exceptions.AuthenticationFailed("Token expired") + + if not token.user.is_active: + raise exceptions.AuthenticationFailed("User inactive") + + return token.user, token + + +class TokenPermissions(DjangoModelPermissions): + """ + Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability + for unsafe requests (POST/PUT/PATCH/DELETE). + """ + def __init__(self): + # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users. + self.authenticated_users_only = settings.LOGIN_REQUIRED + super(TokenPermissions, self).__init__() + + def has_permission(self, request, view): + # If token authentication is in use, verify that the token allows write operations (for unsafe methods). + if request.method not in SAFE_METHODS and isinstance(request.auth, Token): + if not request.auth.write_enabled: + return False + return super(TokenPermissions, self).has_permission(request, view) + + class ChoiceFieldSerializer(Field): """ Represent a ChoiceField as (value, label). """ - def __init__(self, choices, **kwargs): self._choices = {k: v for k, v in choices} super(ChoiceFieldSerializer, self).__init__(**kwargs) @@ -30,7 +77,6 @@ class WritableSerializerMixin(object): """ Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT). """ - def get_serializer_class(self): if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'): return self.write_serializer_class From fd5536067260bfe5befb797f014a488be2858c00 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 7 Mar 2017 22:40:05 -0500 Subject: [PATCH 041/182] Suppress default permissions for Token model --- netbox/users/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/users/models.py b/netbox/users/models.py index 02a95c384..6c7608292 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -19,6 +19,9 @@ class Token(models.Model): write_enabled = models.BooleanField(default=True, help_text="Permit create/update/delete operations using this key") description = models.CharField(max_length=100, blank=True) + class Meta: + default_permissions = [] + def __str__(self): return u"API key for {}".format(self.user) From 26225aff575e2fafd936323800b4585c16b61eb5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 7 Mar 2017 22:56:29 -0500 Subject: [PATCH 042/182] Shorten key length to 20 bytes --- netbox/users/migrations/0001_api_tokens.py | 9 ++++++--- netbox/users/models.py | 6 +++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/netbox/users/migrations/0001_api_tokens.py b/netbox/users/migrations/0001_api_tokens.py index 0f0943925..3ab282277 100644 --- a/netbox/users/migrations/0001_api_tokens.py +++ b/netbox/users/migrations/0001_api_tokens.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.6 on 2017-03-07 20:57 +# Generated by Django 1.10.6 on 2017-03-08 03:52 from __future__ import unicode_literals from django.conf import settings @@ -22,10 +22,13 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created', models.DateTimeField(auto_now_add=True)), ('expires', models.DateTimeField(blank=True, null=True)), - ('key', models.CharField(max_length=64, unique=True)), - ('write_enabled', models.BooleanField(default=True, help_text=b'Permit POST/PUT/DELETE operations using this key')), + ('key', models.CharField(max_length=40, unique=True)), + ('write_enabled', models.BooleanField(default=True, help_text=b'Permit create/update/delete operations using this key')), ('description', models.CharField(blank=True, max_length=100)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)), ], + options={ + 'default_permissions': [], + }, ), ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 6c7608292..c3a2129e7 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -15,7 +15,7 @@ class Token(models.Model): user = models.ForeignKey(User, related_name='tokens', on_delete=models.CASCADE) created = models.DateTimeField(auto_now_add=True) expires = models.DateTimeField(blank=True, null=True) - key = models.CharField(max_length=64, unique=True) + key = models.CharField(max_length=40, unique=True) write_enabled = models.BooleanField(default=True, help_text="Permit create/update/delete operations using this key") description = models.CharField(max_length=100, blank=True) @@ -31,5 +31,5 @@ class Token(models.Model): return super(Token, self).save(*args, **kwargs) def generate_key(self): - # Generate a random 256-bit key expressed in hexadecimal. - return binascii.hexlify(os.urandom(32)).decode() + # Generate a random 160-bit key expressed in hexadecimal. + return binascii.hexlify(os.urandom(20)).decode() From 6be465fe9b9709a9ed31be12547e95c6b6d197cb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 7 Mar 2017 23:30:31 -0500 Subject: [PATCH 043/182] Addded is_expired property to Token --- netbox/users/models.py | 7 +++++++ netbox/utilities/api.py | 3 +-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/netbox/users/models.py b/netbox/users/models.py index c3a2129e7..9191b2fd6 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -4,6 +4,7 @@ import os from django.contrib.auth.models import User from django.db import models from django.utils.encoding import python_2_unicode_compatible +from django.utils import timezone @python_2_unicode_compatible @@ -33,3 +34,9 @@ class Token(models.Model): def generate_key(self): # Generate a random 160-bit key expressed in hexadecimal. return binascii.hexlify(os.urandom(20)).decode() + + @property + def is_expired(self): + if self.expires is not None and timezone.now() > self.expires: + return True + return False diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 64142812c..dc305c5d8 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,5 +1,4 @@ from django.conf import settings -from django.utils import timezone from rest_framework import authentication, exceptions from rest_framework.exceptions import APIException @@ -31,7 +30,7 @@ class TokenAuthentication(authentication.TokenAuthentication): raise exceptions.AuthenticationFailed("Invalid token") # Enforce the Token's expiration time, if one has been set. - if token.expires and token.expires < timezone.now(): + if token.expires and not token.is_expired: raise exceptions.AuthenticationFailed("Token expired") if not token.user.is_active: From d58a8ebba0479f107a351e440fc88ea7cb1cd8ba Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 7 Mar 2017 23:30:53 -0500 Subject: [PATCH 044/182] Initial work on user control panel for tokens --- netbox/templates/users/_user.html | 1 + netbox/templates/users/api_tokens.html | 44 ++++++++++++++++++++++++++ netbox/users/urls.py | 1 + netbox/users/views.py | 20 +++++++++++- 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 netbox/templates/users/api_tokens.html diff --git a/netbox/templates/users/_user.html b/netbox/templates/users/_user.html index 01978a59e..535a14cf2 100644 --- a/netbox/templates/users/_user.html +++ b/netbox/templates/users/_user.html @@ -11,6 +11,7 @@ diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html new file mode 100644 index 000000000..2dd4d9813 --- /dev/null +++ b/netbox/templates/users/api_tokens.html @@ -0,0 +1,44 @@ +{% extends 'users/_user.html' %} +{% load helpers %} + +{% block title %}API Tokens{% endblock %} + +{% block usercontent %} +
    +
    + {% for token in tokens %} +
    +
    + {% if token.is_expired %} +
    + Expired +
    + {% endif %} + {{ token.key }} +
    +
    +
    +
    + Created: {{ token.created|date }} +
    +
    + Expires: {{ token.expires|default:"Never" }} +
    +
    + Write operations: + {% if token.write_enabled %} + Enabled + {% else %} + Disabled + {% endif %} +
    +
    + {% if token.description %} +
    {{ token.description }} + {% endif %} +
    +
    + {% endfor %} +
    +
    +{% endblock %} diff --git a/netbox/users/urls.py b/netbox/users/urls.py index d33d14beb..31ff11830 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -8,6 +8,7 @@ urlpatterns = [ # User profiles url(r'^profile/$', views.profile, name='profile'), url(r'^profile/password/$', views.change_password, name='change_password'), + url(r'^profile/api-tokens/$', views.TokenList.as_view(), name='api_tokens'), url(r'^profile/user-key/$', views.userkey, name='userkey'), url(r'^profile/user-key/edit/$', views.userkey_edit, name='userkey_edit'), url(r'^profile/recent-activity/$', views.recent_activity, name='recent_activity'), diff --git a/netbox/users/views.py b/netbox/users/views.py index 95f5f3bfc..76e73f568 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -1,15 +1,17 @@ from django.contrib import messages from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import LoginRequiredMixin from django.core.urlresolvers import reverse from django.http import HttpResponseRedirect from django.shortcuts import redirect, render from django.utils.http import is_safe_url +from django.views.generic import View from secrets.forms import UserKeyForm from secrets.models import UserKey - from .forms import LoginForm, PasswordChangeForm +from .models import Token # @@ -128,3 +130,19 @@ def recent_activity(request): 'recent_activity': request.user.actions.all()[:50], 'active_tab': 'recent_activity', }) + + +# +# API tokens +# + +class TokenList(LoginRequiredMixin, View): + + def get(self, request): + + tokens = Token.objects.filter(user=request.user) + + return render(request, 'users/api_tokens.html', { + 'tokens': tokens, + 'active_tab': 'api_tokens', + }) From 4f6d2a8b71c911a47c2a9b9f7f3e9369db62c706 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Mar 2017 11:34:47 -0500 Subject: [PATCH 045/182] Finished user control panel for tokens --- netbox/templates/users/_user.html | 20 ++++-- netbox/templates/users/api_tokens.html | 32 ++++++--- netbox/users/forms.py | 13 ++++ netbox/users/migrations/0001_api_tokens.py | 5 +- netbox/users/models.py | 6 +- netbox/users/urls.py | 5 +- netbox/users/views.py | 78 +++++++++++++++++++++- 7 files changed, 137 insertions(+), 22 deletions(-) diff --git a/netbox/templates/users/_user.html b/netbox/templates/users/_user.html index 535a14cf2..71eacac6e 100644 --- a/netbox/templates/users/_user.html +++ b/netbox/templates/users/_user.html @@ -9,11 +9,21 @@
    diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index 2dd4d9813..659b01a70 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -9,28 +9,36 @@ {% for token in tokens %}
    - {% if token.is_expired %} -
    - Expired -
    - {% endif %} +
    + Edit + Delete +
    {{ token.key }} + {% if token.is_expired %} + Expired + {% endif %}
    - Created: {{ token.created|date }} + {{ token.created|date }}
    + Created
    - Expires: {{ token.expires|default:"Never" }} + {% if token.expires %} + {{ token.expires|date }}
    + {% else %} + Never
    + {% endif %} + Expires
    - Write operations: {% if token.write_enabled %} Enabled {% else %} Disabled - {% endif %} + {% endif %}
    + Create/edit/delete operations
    {% if token.description %} @@ -38,7 +46,13 @@ {% endif %}
    + {% empty %} +

    You do not have any API tokens.

    {% endfor %} + + + Add a token +
    {% endblock %} diff --git a/netbox/users/forms.py b/netbox/users/forms.py index b3330ddbf..d84bac0e1 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,6 +1,8 @@ from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm +from django import forms from utilities.forms import BootstrapMixin +from .models import Token class LoginForm(BootstrapMixin, AuthenticationForm): @@ -14,3 +16,14 @@ class LoginForm(BootstrapMixin, AuthenticationForm): class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm): pass + + +class TokenForm(BootstrapMixin, forms.ModelForm): + key = forms.CharField(required=False, help_text="If no key is provided, one will be generated automatically.") + + class Meta: + model = Token + fields = ['key', 'write_enabled', 'expires', 'description'] + help_texts = { + 'expires': 'YYYY-MM-DD [HH:MM:SS]' + } diff --git a/netbox/users/migrations/0001_api_tokens.py b/netbox/users/migrations/0001_api_tokens.py index 3ab282277..d766b2ef0 100644 --- a/netbox/users/migrations/0001_api_tokens.py +++ b/netbox/users/migrations/0001_api_tokens.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.6 on 2017-03-08 03:52 +# Generated by Django 1.10.6 on 2017-03-08 15:32 from __future__ import unicode_literals from django.conf import settings +import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -22,7 +23,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created', models.DateTimeField(auto_now_add=True)), ('expires', models.DateTimeField(blank=True, null=True)), - ('key', models.CharField(max_length=40, unique=True)), + ('key', models.CharField(max_length=40, unique=True, validators=[django.core.validators.MinLengthValidator(40)])), ('write_enabled', models.BooleanField(default=True, help_text=b'Permit create/update/delete operations using this key')), ('description', models.CharField(blank=True, max_length=100)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)), diff --git a/netbox/users/models.py b/netbox/users/models.py index 9191b2fd6..0dd303104 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -2,6 +2,7 @@ import binascii import os from django.contrib.auth.models import User +from django.core.validators import MinLengthValidator from django.db import models from django.utils.encoding import python_2_unicode_compatible from django.utils import timezone @@ -16,7 +17,7 @@ class Token(models.Model): user = models.ForeignKey(User, related_name='tokens', on_delete=models.CASCADE) created = models.DateTimeField(auto_now_add=True) expires = models.DateTimeField(blank=True, null=True) - key = models.CharField(max_length=40, unique=True) + key = models.CharField(max_length=40, unique=True, validators=[MinLengthValidator(40)]) write_enabled = models.BooleanField(default=True, help_text="Permit create/update/delete operations using this key") description = models.CharField(max_length=100, blank=True) @@ -24,7 +25,8 @@ class Token(models.Model): default_permissions = [] def __str__(self): - return u"API key for {}".format(self.user) + # Only display the last 24 bits of the token to avoid accidental exposure. + return u"{} ({})".format(self.key[-6:], self.user) def save(self, *args, **kwargs): if not self.key: diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 31ff11830..e27e61b8d 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -8,7 +8,10 @@ urlpatterns = [ # User profiles url(r'^profile/$', views.profile, name='profile'), url(r'^profile/password/$', views.change_password, name='change_password'), - url(r'^profile/api-tokens/$', views.TokenList.as_view(), name='api_tokens'), + url(r'^profile/api-tokens/$', views.TokenListView.as_view(), name='token_list'), + url(r'^profile/api-tokens/add/$', views.TokenEditView.as_view(), name='token_add'), + url(r'^profile/api-tokens/(?P\d+)/edit/$', views.TokenEditView.as_view(), name='token_edit'), + url(r'^profile/api-tokens/(?P\d+)/delete/$', views.TokenDeleteView.as_view(), name='token_delete'), url(r'^profile/user-key/$', views.userkey, name='userkey'), url(r'^profile/user-key/edit/$', views.userkey_edit, name='userkey_edit'), url(r'^profile/recent-activity/$', views.recent_activity, name='recent_activity'), diff --git a/netbox/users/views.py b/netbox/users/views.py index 76e73f568..2e14374fd 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -4,13 +4,14 @@ from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin from django.core.urlresolvers import reverse from django.http import HttpResponseRedirect -from django.shortcuts import redirect, render +from django.shortcuts import get_object_or_404, redirect, render from django.utils.http import is_safe_url from django.views.generic import View from secrets.forms import UserKeyForm from secrets.models import UserKey -from .forms import LoginForm, PasswordChangeForm +from utilities.forms import ConfirmationForm +from .forms import LoginForm, PasswordChangeForm, TokenForm from .models import Token @@ -136,7 +137,7 @@ def recent_activity(request): # API tokens # -class TokenList(LoginRequiredMixin, View): +class TokenListView(LoginRequiredMixin, View): def get(self, request): @@ -146,3 +147,74 @@ class TokenList(LoginRequiredMixin, View): 'tokens': tokens, 'active_tab': 'api_tokens', }) + + +class TokenEditView(LoginRequiredMixin, View): + + def get(self, request, pk=None): + + if pk is not None: + token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) + else: + token = Token(user=request.user) + + form = TokenForm(instance=token) + + return render(request, 'utilities/obj_edit.html', { + 'obj': token, + 'obj_type': token._meta.verbose_name, + 'form': form, + 'return_url': reverse('users:token_list'), + }) + + def post(self, request, pk=None): + + if pk is not None: + token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) + form = TokenForm(request.POST, instance=token) + else: + form = TokenForm(request.POST) + + if form.is_valid(): + token = form.save(commit=False) + token.user = request.user + token.save() + + msg = "Token updated" if pk else "New token created" + messages.success(request, msg) + + return redirect('users:token_list') + + +class TokenDeleteView(LoginRequiredMixin, View): + + def get(self, request, pk): + + token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) + initial_data = { + 'return_url': reverse('users:token_list'), + } + form = ConfirmationForm(initial=initial_data) + + return render(request, 'utilities/obj_delete.html', { + 'obj': token, + 'obj_type': token._meta.verbose_name, + 'form': form, + 'return_url': reverse('users:token_list'), + }) + + def post(self, request, pk): + + token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) + form = ConfirmationForm(request.POST) + if form.is_valid(): + token.delete() + messages.success(request, "Token deleted") + return redirect('users:token_list') + + return render(request, 'utilities/obj_delete.html', { + 'obj': token, + 'obj_type': token._meta.verbose_name, + 'form': form, + 'return_url': reverse('users:token_list'), + }) From f43fbffdf71babb975c806664a7d9bda42e6ce6a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Mar 2017 16:12:14 -0500 Subject: [PATCH 046/182] Moved TopologyMaps from DCIM to extras --- netbox/dcim/api/urls.py | 2 - netbox/dcim/api/views.py | 31 +------------ netbox/extras/api/serializers.py | 30 ++++++++++++- netbox/extras/api/urls.py | 16 +++++++ netbox/extras/api/views.py | 76 +++++++++----------------------- netbox/extras/filters.py | 21 ++++++++- netbox/extras/models.py | 68 ++++++++++++++++++++++++++++ netbox/netbox/urls.py | 1 + 8 files changed, 157 insertions(+), 88 deletions(-) create mode 100644 netbox/extras/api/urls.py diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index fce61454b..75c1fc1dc 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -2,7 +2,6 @@ from django.conf.urls import include, url from rest_framework import routers -from extras.api.views import TopologyMapView from ipam.api.views import ServiceViewSet from . import views @@ -55,6 +54,5 @@ urlpatterns = [ # Miscellaneous url(r'^related-connections/$', views.RelatedConnectionsView.as_view(), name='related_connections'), - url(r'^topology-maps/(?P[\w-]+)/$', TopologyMapView.as_view(), name='topology_map'), ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 1bf13b57b..70e293265 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -208,7 +208,7 @@ class PlatformViewSet(ModelViewSet): class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Device.objects.select_related( - 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'rack__site', 'parent_bay', + 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay', ).prefetch_related( 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', ) @@ -310,35 +310,6 @@ class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet): write_serializer_class = serializers.WritableInterfaceConnectionSerializer -# -# Live queries -# - -class LLDPNeighborsView(APIView): - """ - Retrieve live LLDP neighbors of a device - """ - - def get(self, request, pk): - - device = get_object_or_404(Device, pk=pk) - if not device.primary_ip: - raise ServiceUnavailable(detail="No IP configured for this device.") - - RPC = device.get_rpc_client() - if not RPC: - raise ServiceUnavailable(detail="No RPC client available for this platform ({}).".format(device.platform)) - - # Connect to device and retrieve inventory info - try: - with RPC(device, username=settings.NETBOX_USERNAME, password=settings.NETBOX_PASSWORD) as rpc_client: - lldp_neighbors = rpc_client.get_lldp_neighbors() - except: - raise ServiceUnavailable(detail="Error connecting to the remote device.") - - return Response(lldp_neighbors) - - # # Miscellaneous # diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index fa7552dc9..8e767fbef 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -2,9 +2,14 @@ from django.contrib.contenttypes.models import ContentType from rest_framework import serializers -from extras.models import CustomField, CustomFieldChoice, Graph +# from dcim.api.serializers import NestedSiteSerializer +from extras.models import CustomField, CustomFieldChoice, Graph, TopologyMap +# +# Custom fields +# + class CustomFieldSerializer(serializers.BaseSerializer): """ Extends ModelSerializer to render any CustomFields and their values associated with an object. @@ -41,6 +46,10 @@ class CustomFieldChoiceSerializer(serializers.ModelSerializer): fields = ['id', 'value'] +# +# Graphs +# + class GraphSerializer(serializers.ModelSerializer): embed_url = serializers.SerializerMethodField() embed_link = serializers.SerializerMethodField() @@ -54,3 +63,22 @@ class GraphSerializer(serializers.ModelSerializer): def get_embed_link(self, obj): return obj.embed_link(self.context['graphed_object']) + + +# +# Topology maps +# + +class TopologyMapSerializer(CustomFieldModelSerializer): + # site = NestedSiteSerializer() + + class Meta: + model = TopologyMap + fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] + + +class WritableTopologyMapSerializer(serializers.ModelSerializer): + + class Meta: + model = TopologyMap + fields = ['name', 'slug', 'site', 'device_patterns', 'description'] diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py new file mode 100644 index 000000000..b652abf9e --- /dev/null +++ b/netbox/extras/api/urls.py @@ -0,0 +1,16 @@ +from django.conf.urls import include, url + +from rest_framework import routers + +from . import views + + +router = routers.DefaultRouter() + +router.register(r'topology-maps', views.TopologyMapViewSet) + +urlpatterns = [ + + url(r'', include(router.urls)), + +] diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 74ebf073c..fe4bdc309 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,6 +1,6 @@ import graphviz from rest_framework import generics -from rest_framework.views import APIView +from rest_framework.decorators import detail_route from rest_framework.viewsets import ModelViewSet from django.contrib.contenttypes.models import ContentType @@ -10,9 +10,10 @@ from django.shortcuts import get_object_or_404 from circuits.models import Provider from dcim.models import Site, Device, Interface, InterfaceConnection +from extras import filters from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE - -from .serializers import GraphSerializer +from utilities.api import WritableSerializerMixin +from . import serializers class CustomFieldModelViewSet(ModelViewSet): @@ -49,7 +50,7 @@ class GraphListView(generics.ListAPIView): """ Returns a list of relevant graphs """ - serializer_class = GraphSerializer + serializer_class = serializers.GraphSerializer def get_serializer_context(self): cls = { @@ -72,60 +73,27 @@ class GraphListView(generics.ListAPIView): return queryset -class TopologyMapView(APIView): - """ - Generate a topology diagram - """ +class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): + queryset = TopologyMap.objects.select_related('site') + serializer_class = serializers.TopologyMapSerializer + write_serializer_class = serializers.WritableTopologyMapSerializer + filter_class = filters.TopologyMapFilter - def get(self, request, slug): + @detail_route() + def render(self, request, pk): - tmap = get_object_or_404(TopologyMap, slug=slug) + tmap = get_object_or_404(TopologyMap, pk=pk) + format = 'png' - # Construct the graph - graph = graphviz.Graph() - graph.graph_attr['ranksep'] = '1' - for i, device_set in enumerate(tmap.device_sets): - - subgraph = graphviz.Graph(name='sg{}'.format(i)) - subgraph.graph_attr['rank'] = 'same' - - # Add a pseudonode for each device_set to enforce hierarchical layout - subgraph.node('set{}'.format(i), label='', shape='none', width='0') - if i: - graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis') - - # Add each device to the graph - devices = [] - for query in device_set.split(';'): # Split regexes on semicolons - devices += Device.objects.filter(name__regex=query) - for d in devices: - subgraph.node(d.name) - - # Add an invisible connection to each successive device in a set to enforce horizontal order - for j in range(0, len(devices) - 1): - subgraph.edge(devices[j].name, devices[j + 1].name, style='invis') - - graph.subgraph(subgraph) - - # Compile list of all devices - device_superset = Q() - for device_set in tmap.device_sets: - for query in device_set.split(';'): # Split regexes on semicolons - device_superset = device_superset | Q(name__regex=query) - - # Add all connections to the graph - devices = Device.objects.filter(*(device_superset,)) - connections = InterfaceConnection.objects.filter(interface_a__device__in=devices, - interface_b__device__in=devices) - for c in connections: - graph.edge(c.interface_a.device.name, c.interface_b.device.name) - - # Get the image data and return try: - topo_data = graph.pipe(format='png') + data = tmap.render(format=format) except: - return HttpResponse("There was an error generating the requested graph. Ensure that the GraphViz " - "executables have been installed correctly.") - response = HttpResponse(topo_data, content_type='image/png') + return HttpResponse( + "There was an error generating the requested graph. Ensure that the GraphViz executables have been " + "installed correctly." + ) + + response = HttpResponse(data, content_type='image/{}'.format(format)) + response['Content-Disposition'] = 'inline; filename="{}.{}"'.format(tmap.slug, format) return response diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index bcd9f175f..98f1a5a1e 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -2,7 +2,8 @@ import django_filters from django.contrib.contenttypes.models import ContentType -from .models import CF_TYPE_SELECT, CustomField +from dcim.models import Site +from .models import CF_TYPE_SELECT, CustomField, TopologyMap class CustomFieldFilter(django_filters.Filter): @@ -44,3 +45,21 @@ class CustomFieldFilterSet(django_filters.FilterSet): custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True) for cf in custom_fields: self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type) + + +class TopologyMapFilter(django_filters.FilterSet): + site_id = django_filters.ModelMultipleChoiceFilter( + name='site', + queryset=Site.objects.all(), + label='Site', + ) + site = django_filters.ModelMultipleChoiceFilter( + name='site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) + + class Meta: + model = TopologyMap + fields = ['name', 'slug'] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index f06d0aa29..f86132f0d 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1,11 +1,13 @@ from collections import OrderedDict from datetime import date +import graphviz from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.validators import ValidationError from django.db import models +from django.db.models import Q from django.http import HttpResponse from django.template import Template, Context from django.utils.encoding import python_2_unicode_compatible @@ -66,6 +68,10 @@ ACTION_CHOICES = ( ) +# +# Custom fields +# + class CustomFieldModel(object): def cf(self): @@ -211,6 +217,10 @@ class CustomFieldChoice(models.Model): CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete() +# +# Graphs +# + @python_2_unicode_compatible class Graph(models.Model): type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES) @@ -236,6 +246,10 @@ class Graph(models.Model): return template.render(Context({'obj': obj})) +# +# Export templates +# + @python_2_unicode_compatible class ExportTemplate(models.Model): content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}) @@ -270,6 +284,10 @@ class ExportTemplate(models.Model): return response +# +# Topology maps +# + @python_2_unicode_compatible class TopologyMap(models.Model): name = models.CharField(max_length=50, unique=True) @@ -294,6 +312,56 @@ class TopologyMap(models.Model): return None return [line.strip() for line in self.device_patterns.split('\n')] + def render(self, format='png'): + + from dcim.models import Device, InterfaceConnection + + # Construct the graph + graph = graphviz.Graph() + graph.graph_attr['ranksep'] = '1' + for i, device_set in enumerate(self.device_sets): + + subgraph = graphviz.Graph(name='sg{}'.format(i)) + subgraph.graph_attr['rank'] = 'same' + + # Add a pseudonode for each device_set to enforce hierarchical layout + subgraph.node('set{}'.format(i), label='', shape='none', width='0') + if i: + graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis') + + # Add each device to the graph + devices = [] + for query in device_set.split(';'): # Split regexes on semicolons + devices += Device.objects.filter(name__regex=query) + for d in devices: + subgraph.node(d.name) + + # Add an invisible connection to each successive device in a set to enforce horizontal order + for j in range(0, len(devices) - 1): + subgraph.edge(devices[j].name, devices[j + 1].name, style='invis') + + graph.subgraph(subgraph) + + # Compile list of all devices + device_superset = Q() + for device_set in self.device_sets: + for query in device_set.split(';'): # Split regexes on semicolons + device_superset = device_superset | Q(name__regex=query) + + # Add all connections to the graph + devices = Device.objects.filter(*(device_superset,)) + connections = InterfaceConnection.objects.filter( + interface_a__device__in=devices, interface_b__device__in=devices + ) + for c in connections: + graph.edge(c.interface_a.device.name, c.interface_b.device.name) + + return graph.pipe(format=format) + + +# +# User actions +# class UserActionManager(models.Manager): diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index eb6b67fe7..e48c371a2 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -28,6 +28,7 @@ _patterns = [ # API url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')), url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')), + url(r'^api/extras/', include('extras.api.urls', namespace='extras-api')), url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')), url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')), url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')), From a6ceaf8d96b601c8f69c63b22d21eed8ecaa9eb2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Mar 2017 16:18:41 -0500 Subject: [PATCH 047/182] Moved custom field serializers to their own module to avoid circular dependency --- netbox/circuits/api/serializers.py | 2 +- netbox/dcim/api/serializers.py | 2 +- netbox/extras/api/customfields.py | 45 +++++++++++++++++++++++++++ netbox/extras/api/serializers.py | 50 +++--------------------------- netbox/ipam/api/serializers.py | 2 +- netbox/tenancy/api/serializers.py | 2 +- 6 files changed, 53 insertions(+), 50 deletions(-) create mode 100644 netbox/extras/api/customfields.py diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 56395547d..26f57d000 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -2,7 +2,7 @@ from rest_framework import serializers from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer -from extras.api.serializers import CustomFieldModelSerializer +from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9cae166eb..e513a64de 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -8,7 +8,7 @@ from dcim.models import ( PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, ) -from extras.api.serializers import CustomFieldModelSerializer +from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceFieldSerializer diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py new file mode 100644 index 000000000..0def1a554 --- /dev/null +++ b/netbox/extras/api/customfields.py @@ -0,0 +1,45 @@ +from django.contrib.contenttypes.models import ContentType + +from rest_framework import serializers + +from extras.models import CustomField, CustomFieldChoice + + +# +# Custom fields +# + +class CustomFieldSerializer(serializers.BaseSerializer): + """ + Extends ModelSerializer to render any CustomFields and their values associated with an object. + """ + + def to_representation(self, manager): + + # Initialize custom fields dictionary + data = {f.name: None for f in self.parent._custom_fields} + + # Assign CustomFieldValues from database + for cfv in manager.all(): + data[cfv.field.name] = cfv.value + + return data + + +class CustomFieldModelSerializer(serializers.ModelSerializer): + custom_fields = CustomFieldSerializer(source='custom_field_values') + + def __init__(self, *args, **kwargs): + + super(CustomFieldModelSerializer, self).__init__(*args, **kwargs) + + # Cache the list of custom fields for this model + content_type = ContentType.objects.get_for_model(self.Meta.model) + self._custom_fields = CustomField.objects.filter(obj_type=content_type) + + +class CustomFieldChoiceSerializer(serializers.ModelSerializer): + + class Meta: + model = CustomFieldChoice + fields = ['id', 'value'] \ No newline at end of file diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 8e767fbef..76088bf3c 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,49 +1,7 @@ -from django.contrib.contenttypes.models import ContentType - from rest_framework import serializers -# from dcim.api.serializers import NestedSiteSerializer -from extras.models import CustomField, CustomFieldChoice, Graph, TopologyMap - - -# -# Custom fields -# - -class CustomFieldSerializer(serializers.BaseSerializer): - """ - Extends ModelSerializer to render any CustomFields and their values associated with an object. - """ - - def to_representation(self, manager): - - # Initialize custom fields dictionary - data = {f.name: None for f in self.parent._custom_fields} - - # Assign CustomFieldValues from database - for cfv in manager.all(): - data[cfv.field.name] = cfv.value - - return data - - -class CustomFieldModelSerializer(serializers.ModelSerializer): - custom_fields = CustomFieldSerializer(source='custom_field_values') - - def __init__(self, *args, **kwargs): - - super(CustomFieldModelSerializer, self).__init__(*args, **kwargs) - - # Cache the list of custom fields for this model - content_type = ContentType.objects.get_for_model(self.Meta.model) - self._custom_fields = CustomField.objects.filter(obj_type=content_type) - - -class CustomFieldChoiceSerializer(serializers.ModelSerializer): - - class Meta: - model = CustomFieldChoice - fields = ['id', 'value'] +from dcim.api.serializers import NestedSiteSerializer +from extras.models import Graph, TopologyMap # @@ -69,8 +27,8 @@ class GraphSerializer(serializers.ModelSerializer): # Topology maps # -class TopologyMapSerializer(CustomFieldModelSerializer): - # site = NestedSiteSerializer() +class TopologyMapSerializer(serializers.ModelSerializer): + site = NestedSiteSerializer() class Meta: model = TopologyMap diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 75b47b6ca..c33694d88 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer -from extras.api.serializers import CustomFieldModelSerializer +from extras.api.customfields import CustomFieldModelSerializer from ipam.models import ( Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF, diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index bde86ee28..67231fe67 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from extras.api.serializers import CustomFieldModelSerializer +from extras.api.customfields import CustomFieldModelSerializer from tenancy.models import Tenant, TenantGroup From c19725506d69a2c23ee64b154784b8d8fdb0cd11 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Mar 2017 16:30:32 -0500 Subject: [PATCH 048/182] Cleanup --- netbox/extras/api/views.py | 12 +++++------- netbox/extras/models.py | 4 ++-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index fe4bdc309..20e2901f7 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,15 +1,13 @@ -import graphviz from rest_framework import generics from rest_framework.decorators import detail_route from rest_framework.viewsets import ModelViewSet from django.contrib.contenttypes.models import ContentType -from django.db.models import Q from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from circuits.models import Provider -from dcim.models import Site, Device, Interface, InterfaceConnection +from dcim.models import Site, Interface from extras import filters from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE from utilities.api import WritableSerializerMixin @@ -83,17 +81,17 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): def render(self, request, pk): tmap = get_object_or_404(TopologyMap, pk=pk) - format = 'png' + img_format = 'png' try: - data = tmap.render(format=format) + data = tmap.render(img_format=img_format) except: return HttpResponse( "There was an error generating the requested graph. Ensure that the GraphViz executables have been " "installed correctly." ) - response = HttpResponse(data, content_type='image/{}'.format(format)) - response['Content-Disposition'] = 'inline; filename="{}.{}"'.format(tmap.slug, format) + response = HttpResponse(data, content_type='image/{}'.format(img_format)) + response['Content-Disposition'] = 'inline; filename="{}.{}"'.format(tmap.slug, img_format) return response diff --git a/netbox/extras/models.py b/netbox/extras/models.py index f86132f0d..3101757d6 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -312,7 +312,7 @@ class TopologyMap(models.Model): return None return [line.strip() for line in self.device_patterns.split('\n')] - def render(self, format='png'): + def render(self, img_format='png'): from dcim.models import Device, InterfaceConnection @@ -356,7 +356,7 @@ class TopologyMap(models.Model): for c in connections: graph.edge(c.interface_a.device.name, c.interface_b.device.name) - return graph.pipe(format=format) + return graph.pipe(format=img_format) # From 7e6d061646426143cd2669f706e75b0b62f7d22d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Mar 2017 17:57:51 -0500 Subject: [PATCH 049/182] Converted GetSessionKey and RSAKeyGeneratorView to ViewSets --- netbox/project-static/js/secrets.js | 2 +- netbox/secrets/api/urls.py | 7 +++--- netbox/secrets/api/views.py | 33 +++++++++++++++++++++-------- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/netbox/project-static/js/secrets.js b/netbox/project-static/js/secrets.js index 40f4b5f0c..f2467c365 100644 --- a/netbox/project-static/js/secrets.js +++ b/netbox/project-static/js/secrets.js @@ -92,7 +92,7 @@ $(document).ready(function() { $('#generate_keypair').click(function() { $('#new_keypair_modal').modal('show'); $.ajax({ - url: netbox_api_path + 'secrets/generate-keys/', + url: netbox_api_path + 'secrets/generate-rsa-key-pair/', type: 'GET', dataType: 'json', success: function (response, status) { diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index d67ea92e4..e8457074c 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -6,15 +6,14 @@ from . import views router = routers.DefaultRouter() + router.register(r'secret-roles', views.SecretRoleViewSet) router.register(r'secrets', views.SecretViewSet) +router.register(r'get-session-key', views.GetSessionKeyViewSet, base_name='get-session-key') +router.register(r'generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, base_name='generate-rsa-key-pair') urlpatterns = [ url(r'', include(router.urls)), - # Miscellaneous - url(r'^get-session-key/$', views.GetSessionKey.as_view(), name='get_session_key'), - url(r'^generate-keys/$', views.RSAKeyGeneratorView.as_view(), name='generate_keys'), - ] diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 0ac087c63..e2874c9cc 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -7,14 +7,12 @@ from rest_framework.authentication import BasicAuthentication, SessionAuthentica from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONRenderer from rest_framework.response import Response -from rest_framework.views import APIView -from rest_framework.viewsets import ModelViewSet +from rest_framework.viewsets import ViewSet, ModelViewSet from extras.api.renderers import FormlessBrowsableAPIRenderer, FreeRADIUSClientsRenderer from secrets.filters import SecretFilter from secrets.models import Secret, SecretRole, SessionKey, UserKey from utilities.api import WritableSerializerMixin - from . import serializers @@ -107,13 +105,25 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): return Response(serializer.data) -class GetSessionKey(APIView): +class GetSessionKeyViewSet(ViewSet): """ - Cache an encrypted copy of the master key derived from the submitted private key. + Retrieve a temporary session key to use for encrypting and decrypting secrets via the API. The user's private RSA + key is POSTed with the name `private_key`. An example: + + curl -v -X POST -H "Authorization: Token " -H "Accept: application/json; indent=4" \\ + --data-urlencode "private_key@" https://netbox/api/secrets/get-session-key/ + + This request will yield a session key to be included in an `X-Session-Key` header in future requests, as well as its + expiration time: + + { + "expiration_time": "2017-03-09T10:42:23.095267Z", + "session_key": "+8t4SI6XikgVmB5+/urhozx9O5qCQANyOk1MNe6taRf=" + } """ permission_classes = [IsAuthenticated] - def post(self, request): + def create(self, request): # Read private key private_key = request.POST.get('private_key', None) @@ -150,13 +160,18 @@ class GetSessionKey(APIView): return response -class RSAKeyGeneratorView(APIView): +class GenerateRSAKeyPairViewSet(ViewSet): """ - Generate a new RSA key pair for a user. Authenticated because it's a ripe avenue for DoS. + This endpoint can be used to generate a new RSA key pair. The keys are returned in PEM format. + + { + "public_key": "", + "private_key": "" + } """ permission_classes = [IsAuthenticated] - def get(self, request): + def list(self, request): # Determine what size key to generate key_size = request.GET.get('key_size', 2048) From ddec4244294264f4a7dc067e3d39c470e03d049a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Mar 2017 12:18:53 -0500 Subject: [PATCH 050/182] Replaced RelatedConnectionsView with views.ConnectedDeviceViewSet --- netbox/dcim/api/urls.py | 7 ++-- netbox/dcim/api/views.py | 91 ++++++++++------------------------------ 2 files changed, 25 insertions(+), 73 deletions(-) diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 75c1fc1dc..401c53a07 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -48,11 +48,12 @@ router.register(r'services', ServiceViewSet) # Interface connections router.register(r'interface-connections', views.InterfaceConnectionViewSet) +# Miscellaneous +router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device') + + urlpatterns = [ url(r'', include(router.urls)), - # Miscellaneous - url(r'^related-connections/$', views.RelatedConnectionsView.as_view(), name='related_connections'), - ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 70e293265..96be490ef 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,8 +1,9 @@ from rest_framework.decorators import detail_route +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework.views import APIView -from rest_framework.viewsets import ModelViewSet +from rest_framework.viewsets import ModelViewSet, ViewSet from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -314,79 +315,29 @@ class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet): # Miscellaneous # -class RelatedConnectionsView(APIView): +class ConnectedDeviceViewSet(ViewSet): """ - Retrieve all connections related to a given console/power/interface connection + This endpoint allows a user to determine what device (if any) is connected to a given peer device and peer + interface. This is useful in a situation where a device boots with no configuration, but can detect its neighbors + via a protocol such as LLDP. Two query parameters must be included in the request: + + * `peer-device`: The name of the peer device + * `peer-interface`: The name of the peer interface """ + permission_classes = [IsAuthenticated] - def __init__(self): - super(RelatedConnectionsView, self).__init__() + def list(self, request): - # Custom fields - content_type = ContentType.objects.get_for_model(Device) - custom_fields = content_type.custom_fields.prefetch_related('choices') + peer_device_name = request.query_params.get('peer-device') + peer_interface_name = request.query_params.get('peer-interface') + if not peer_device_name or not peer_interface_name: + raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.') - # Cache all relevant CustomFieldChoices. This saves us from having to do a lookup per select field per object. - custom_field_choices = {} - for field in custom_fields: - for cfc in field.choices.all(): - custom_field_choices[cfc.id] = cfc.value + # Determine local interface from peer interface's connection + peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name) + local_interface = peer_interface.connected_interface - self.context = { - 'custom_fields': custom_fields, - 'custom_field_choices': custom_field_choices, - } + if local_interface is None: + return Response() - def get(self, request): - - peer_device = request.GET.get('peer-device') - peer_interface = request.GET.get('peer-interface') - - # Search by interface - if peer_device and peer_interface: - - # Determine local interface from peer interface's connection - try: - peer_iface = Interface.objects.get(device__name=peer_device, name=peer_interface) - except Interface.DoesNotExist: - raise Http404() - local_iface = peer_iface.connected_interface - if local_iface: - device = local_iface.device - else: - return Response() - - else: - raise MissingFilterException(detail='Must specify search parameters "peer-device" and "peer-interface".') - - # Initialize response skeleton - response = { - 'device': serializers.DeviceSerializer(device, context=self.context).data, - 'console-ports': [], - 'power-ports': [], - 'interfaces': [], - } - - # Console connections - console_ports = ConsolePort.objects.filter(device=device).select_related('cs_port__device') - for cp in console_ports: - data = serializers.ConsolePortSerializer(instance=cp).data - del(data['device']) - response['console-ports'].append(data) - - # Power connections - power_ports = PowerPort.objects.filter(device=device).select_related('power_outlet__device') - for pp in power_ports: - data = serializers.PowerPortSerializer(instance=pp).data - del(data['device']) - response['power-ports'].append(data) - - # Interface connections - interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\ - .select_related('connected_as_a', 'connected_as_b', 'circuit_termination') - for iface in interfaces: - data = serializers.InterfaceSerializer(instance=iface).data - del(data['device']) - response['interfaces'].append(data) - - return Response(response) + return Response(serializers.DeviceSerializer(local_interface.device, context={'request': request}).data) From 6c2ed1be22048cc85c224990faf7a2430ff9421f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Mar 2017 13:24:02 -0500 Subject: [PATCH 051/182] Standardized API URL definitions --- netbox/circuits/api/urls.py | 12 +++++------- netbox/dcim/api/urls.py | 9 +-------- netbox/extras/api/urls.py | 9 ++------- netbox/ipam/api/urls.py | 22 +++++++++++++++------- netbox/secrets/api/urls.py | 11 ++++------- netbox/tenancy/api/urls.py | 10 +++------- 6 files changed, 30 insertions(+), 43 deletions(-) diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 6bb49dc38..d53f13d83 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,18 +1,16 @@ -from django.conf.urls import include, url - from rest_framework import routers from . import views router = routers.DefaultRouter() + +# Providers router.register(r'providers', views.ProviderViewSet) + +# Circuits router.register(r'circuit-types', views.CircuitTypeViewSet) router.register(r'circuits', views.CircuitViewSet) router.register(r'circuit-terminations', views.CircuitTerminationViewSet) -urlpatterns = [ - - url(r'', include(router.urls)), - -] +urlpatterns = router.urls diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 401c53a07..cde304afb 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -1,5 +1,3 @@ -from django.conf.urls import include, url - from rest_framework import routers from ipam.api.views import ServiceViewSet @@ -51,9 +49,4 @@ router.register(r'interface-connections', views.InterfaceConnectionViewSet) # Miscellaneous router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device') - -urlpatterns = [ - - url(r'', include(router.urls)), - -] +urlpatterns = router.urls diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index b652abf9e..2ca80ea37 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -1,5 +1,3 @@ -from django.conf.urls import include, url - from rest_framework import routers from . import views @@ -7,10 +5,7 @@ from . import views router = routers.DefaultRouter() +# Topology maps router.register(r'topology-maps', views.TopologyMapViewSet) -urlpatterns = [ - - url(r'', include(router.urls)), - -] +urlpatterns = router.urls diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 24c97b341..c72d501dd 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,23 +1,31 @@ -from django.conf.urls import include, url - from rest_framework import routers from . import views router = routers.DefaultRouter() + +# VRFs router.register(r'vrfs', views.VRFViewSet) + +# RIRs router.register(r'rirs', views.RIRViewSet) + +# Aggregates router.register(r'aggregates', views.AggregateViewSet) + +# Prefixes router.register(r'roles', views.RoleViewSet) router.register(r'prefixes', views.PrefixViewSet) + +# IP addresses router.register(r'ip-addresses', views.IPAddressViewSet) + +# VLANs router.register(r'vlan-groups', views.VLANGroupViewSet) router.register(r'vlans', views.VLANViewSet) + +# Services router.register(r'services', views.ServiceViewSet) -urlpatterns = [ - - url(r'', include(router.urls)), - -] +urlpatterns = router.urls diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index e8457074c..da84afe82 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -1,5 +1,3 @@ -from django.conf.urls import include, url - from rest_framework import routers from . import views @@ -7,13 +5,12 @@ from . import views router = routers.DefaultRouter() +# Secrets router.register(r'secret-roles', views.SecretRoleViewSet) router.register(r'secrets', views.SecretViewSet) + +# Miscellaneous router.register(r'get-session-key', views.GetSessionKeyViewSet, base_name='get-session-key') router.register(r'generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, base_name='generate-rsa-key-pair') -urlpatterns = [ - - url(r'', include(router.urls)), - -] +urlpatterns = router.urls diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 35e11cd6b..5fb0be708 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -1,16 +1,12 @@ -from django.conf.urls import include, url - from rest_framework import routers from . import views router = routers.DefaultRouter() + +# Tenants router.register(r'tenant-groups', views.TenantGroupViewSet) router.register(r'tenants', views.TenantViewSet) -urlpatterns = [ - - url(r'', include(router.urls)), - -] +urlpatterns = router.urls From 0ed13f694334c08797185c4c760c092c6d8b5a9f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Mar 2017 13:38:15 -0500 Subject: [PATCH 052/182] Removed browsable API login/logout --- netbox/netbox/urls.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index e48c371a2..ce55ab135 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -33,7 +33,6 @@ _patterns = [ url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')), url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')), url(r'^api/docs/', include('rest_framework_swagger.urls')), - url(r'^api-auth/', include('rest_framework.urls')), # Error testing url(r'^500/$', trigger_500), From 41826fc3cbcb054b2b601b5ef969d32fd1e45487 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Mar 2017 13:50:30 -0500 Subject: [PATCH 053/182] Fixed serialization of CustomFieldChoices --- netbox/extras/api/customfields.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 0def1a554..5aa737da0 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -2,7 +2,7 @@ from django.contrib.contenttypes.models import ContentType from rest_framework import serializers -from extras.models import CustomField, CustomFieldChoice +from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice # @@ -21,7 +21,10 @@ class CustomFieldSerializer(serializers.BaseSerializer): # Assign CustomFieldValues from database for cfv in manager.all(): - data[cfv.field.name] = cfv.value + if cfv.field.type == CF_TYPE_SELECT: + data[cfv.field.name] = CustomFieldChoiceSerializer(cfv.value).data + else: + data[cfv.field.name] = cfv.value return data From 9dfda83946c22bf5b3df3fcda9d4e17c16bced0e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Mar 2017 14:26:39 -0500 Subject: [PATCH 054/182] Closes #855: Added an API endpoint for recent activity --- netbox/extras/api/serializers.py | 17 ++++++++++++++++- netbox/extras/api/urls.py | 3 +++ netbox/extras/api/views.py | 15 ++++++++++++--- netbox/extras/filters.py | 15 ++++++++++++++- netbox/users/api/__init__.py | 0 netbox/users/api/serializers.py | 10 ++++++++++ 6 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 netbox/users/api/__init__.py create mode 100644 netbox/users/api/serializers.py diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 76088bf3c..ec72910c9 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,7 +1,9 @@ from rest_framework import serializers from dcim.api.serializers import NestedSiteSerializer -from extras.models import Graph, TopologyMap +from extras.models import ACTION_CHOICES, Graph, TopologyMap, UserAction +from users.api.serializers import NestedUserSerializer +from utilities.api import ChoiceFieldSerializer # @@ -40,3 +42,16 @@ class WritableTopologyMapSerializer(serializers.ModelSerializer): class Meta: model = TopologyMap fields = ['name', 'slug', 'site', 'device_patterns', 'description'] + + +# +# User actions +# + +class UserActionSerializer(serializers.ModelSerializer): + user = NestedUserSerializer() + action = ChoiceFieldSerializer(choices=ACTION_CHOICES) + + class Meta: + model = UserAction + fields = ['id', 'time', 'user', 'action', 'message'] diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 2ca80ea37..141b4b7e4 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -8,4 +8,7 @@ router = routers.DefaultRouter() # Topology maps router.register(r'topology-maps', views.TopologyMapViewSet) +# Recent activity +router.register(r'recent-activity', views.RecentActivityViewSet) + urlpatterns = router.urls diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 20e2901f7..9047fd403 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,6 +1,6 @@ from rest_framework import generics from rest_framework.decorators import detail_route -from rest_framework.viewsets import ModelViewSet +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from django.contrib.contenttypes.models import ContentType from django.http import Http404, HttpResponse @@ -9,14 +9,14 @@ from django.shortcuts import get_object_or_404 from circuits.models import Provider from dcim.models import Site, Interface from extras import filters -from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE +from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE, UserAction from utilities.api import WritableSerializerMixin from . import serializers class CustomFieldModelViewSet(ModelViewSet): """ - Include the applicable set of CustomField in the ModelViewSet context. + Include the applicable set of CustomFields in the ModelViewSet context. """ def get_serializer_context(self): @@ -95,3 +95,12 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): response['Content-Disposition'] = 'inline; filename="{}.{}"'.format(tmap.slug, img_format) return response + + +class RecentActivityViewSet(ReadOnlyModelViewSet): + """ + List all UserActions to provide a log of recent activity. + """ + queryset = UserAction.objects.all() + serializer_class = serializers.UserActionSerializer + filter_class = filters.UserActionFilter diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 98f1a5a1e..609a0789a 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -1,9 +1,10 @@ import django_filters +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from dcim.models import Site -from .models import CF_TYPE_SELECT, CustomField, TopologyMap +from .models import CF_TYPE_SELECT, CustomField, TopologyMap, UserAction class CustomFieldFilter(django_filters.Filter): @@ -63,3 +64,15 @@ class TopologyMapFilter(django_filters.FilterSet): class Meta: model = TopologyMap fields = ['name', 'slug'] + + +class UserActionFilter(django_filters.FilterSet): + username = django_filters.ModelMultipleChoiceFilter( + name='user__username', + queryset=User.objects.all(), + to_field_name='username', + ) + + class Meta: + model = UserAction + fields = ['user'] diff --git a/netbox/users/api/__init__.py b/netbox/users/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py new file mode 100644 index 000000000..893a989bc --- /dev/null +++ b/netbox/users/api/serializers.py @@ -0,0 +1,10 @@ +from django.contrib.auth.models import User + +from rest_framework import serializers + + +class NestedUserSerializer(serializers.ModelSerializer): + + class Meta: + model = User + fields = ['id', 'username'] From 94a29be415999ebf60b5393aed18ce21fc427ced Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Mar 2017 14:28:52 -0500 Subject: [PATCH 055/182] Removed deprecated GraphListView --- netbox/extras/api/views.py | 34 ++-------------------------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 9047fd403..93bc724ec 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,15 +1,12 @@ -from rest_framework import generics from rest_framework.decorators import detail_route from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from django.contrib.contenttypes.models import ContentType -from django.http import Http404, HttpResponse +from django.http import HttpResponse from django.shortcuts import get_object_or_404 -from circuits.models import Provider -from dcim.models import Site, Interface from extras import filters -from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE, UserAction +from extras.models import TopologyMap, UserAction from utilities.api import WritableSerializerMixin from . import serializers @@ -44,33 +41,6 @@ class CustomFieldModelViewSet(ModelViewSet): return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field') -class GraphListView(generics.ListAPIView): - """ - Returns a list of relevant graphs - """ - serializer_class = serializers.GraphSerializer - - def get_serializer_context(self): - cls = { - GRAPH_TYPE_INTERFACE: Interface, - GRAPH_TYPE_PROVIDER: Provider, - GRAPH_TYPE_SITE: Site, - } - obj = get_object_or_404(cls[self.kwargs.get('type')], pk=self.kwargs['pk']) - context = super(GraphListView, self).get_serializer_context() - context.update({ - 'graphed_object': obj, - }) - return context - - def get_queryset(self): - graph_type = self.kwargs.get('type', None) - if not graph_type: - raise Http404() - queryset = Graph.objects.filter(type=graph_type) - return queryset - - class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): queryset = TopologyMap.objects.select_related('site') serializer_class = serializers.TopologyMapSerializer From 7e51ca9912b2b7e8c9e7fd3088872e295f1032a1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Mar 2017 15:05:01 -0500 Subject: [PATCH 056/182] Provided a root API view --- netbox/netbox/urls.py | 3 ++- netbox/netbox/views.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index ce55ab135..46737bf3c 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -2,7 +2,7 @@ from django.conf import settings from django.conf.urls import include, url from django.contrib import admin -from netbox.views import home, handle_500, trigger_500 +from netbox.views import APIRootView, home, handle_500, trigger_500 from users.views import login, logout @@ -26,6 +26,7 @@ _patterns = [ url(r'^profile/', include('users.urls', namespace='users')), # API + url(r'^api/$', APIRootView.as_view()), url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')), url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')), url(r'^api/extras/', include('extras.api.urls', namespace='extras-api')), diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 7aa144295..349612330 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -1,5 +1,10 @@ import sys +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.reverse import reverse + from django.shortcuts import render from circuits.models import Provider, Circuit @@ -47,6 +52,24 @@ def home(request): }) +class APIRootView(APIView): + permission_classes = [IsAuthenticated] + + def get_view_name(self): + return u"API Root" + + def get(self, request, format=None): + + return Response({ + 'circuits': reverse('circuits-api:api-root', request=request, format=format), + 'dcim': reverse('dcim-api:api-root', request=request, format=format), + 'extras': reverse('extras-api:api-root', request=request, format=format), + 'ipam': reverse('ipam-api:api-root', request=request, format=format), + 'secrets': reverse('secrets-api:api-root', request=request, format=format), + 'tenancy': reverse('tenancy-api:api-root', request=request, format=format), + }) + + def handle_500(request): """ Custom server error handler From 4d4441217fe05e55a3b44d89c078e6726c0f3012 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Mar 2017 15:18:50 -0500 Subject: [PATCH 057/182] APIRootView tweaks --- netbox/netbox/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 349612330..17a1e19d2 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -53,7 +53,8 @@ def home(request): class APIRootView(APIView): - permission_classes = [IsAuthenticated] + _ignore_model_permissions = True + exclude_from_schema = True def get_view_name(self): return u"API Root" From 9e4e3a8dfaba1e4a3c5c4a7cad8ba6b0ffed7673 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 13 Mar 2017 10:00:13 -0400 Subject: [PATCH 058/182] Updated API docs --- docs/api/structure.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/api/structure.md b/docs/api/structure.md index 437ba9bfd..f61082b38 100644 --- a/docs/api/structure.md +++ b/docs/api/structure.md @@ -1,21 +1,23 @@ # URL Hierarchy -The API's URL structure is divided at the root level by application: circuits, DCIM, IPAM, secrets, and tenancy. Within each application, each model has its own path. For example: +NetBox's entire REST API is housed under the API root, `/api/`. The API's URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, secrets, and tenancy. Within each application, each model has its own path. For example: -* /api/circuits/circuits/ * /api/circuits/providers/ +* /api/circuits/circuits/ * /api/dcim/sites/ * /api/dcim/racks/ * /api/dcim/devices/ -Each model generally has two URLs associated with it: a list URL, and a detail URL. the list URL is used to request a list of multiple objects or to create a new object. The detail URL is used to retrieve, update, or delete an existing object. +Each model generally has two URLs associated with it: a list URL and a detail URL. The list URL is used to request a list of multiple objects or to create a new object. The detail URL is used to retrieve, update, or delete an existing object. * /api/dcim/devices/ - List devices or create a new device * /api/dcim/devices/123/ - Retrieve, update, or delete the device with ID 123 Lists of objects can be filtered using a set of query parameters. For example, to find all interfaces belonging to the device with ID 123: -* /api/dcim/interfaces/?device_id=123 +``` +GET /api/dcim/interfaces/?device_id=123 +``` # Serializers From dd27950fae26d300d224620ba57d8812a0205bf0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 14 Mar 2017 10:58:57 -0400 Subject: [PATCH 059/182] Simplify SessionKey usage --- netbox/secrets/api/views.py | 18 ++++++++++-------- .../secrets/migrations/0002_add_sessionkeys.py | 3 +-- netbox/secrets/models.py | 7 ------- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index e2874c9cc..aaf94f998 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -1,6 +1,7 @@ import base64 from Crypto.PublicKey import RSA +from django.core.urlresolvers import reverse from django.http import HttpResponseBadRequest from rest_framework.authentication import BasicAuthentication, SessionAuthentication @@ -113,11 +114,9 @@ class GetSessionKeyViewSet(ViewSet): curl -v -X POST -H "Authorization: Token " -H "Accept: application/json; indent=4" \\ --data-urlencode "private_key@" https://netbox/api/secrets/get-session-key/ - This request will yield a session key to be included in an `X-Session-Key` header in future requests, as well as its - expiration time: + This request will yield a base64-encoded session key to be included in an `X-Session-Key` header in future requests: { - "expiration_time": "2017-03-09T10:42:23.095267Z", "session_key": "+8t4SI6XikgVmB5+/urhozx9O5qCQANyOk1MNe6taRf=" } """ @@ -149,14 +148,17 @@ class GetSessionKeyViewSet(ViewSet): # Create a new SessionKey sk = SessionKey(user=request.user) sk.save(master_key=master_key) + encoded_key = base64.b64encode(sk.key) - # Return the session key both as JSON and as a cookie + # Craft the response response = Response({ - 'session_key': base64.b64encode(sk.key), - 'expiration_time': sk.expiration_time, + 'session_key': encoded_key, }) - # TODO: Limit cookie path to secrets API URLs - response.set_cookie('session_key', base64.b64encode(sk.key), expires=sk.expiration_time) + + # If token authentication is not in use, assign the session key as a cookie + if request.auth is None: + response.set_cookie('session_key', value=encoded_key, path=reverse('secrets-api:secret-list')) + return response diff --git a/netbox/secrets/migrations/0002_add_sessionkeys.py b/netbox/secrets/migrations/0002_add_sessionkeys.py index c4b848b35..139b68331 100644 --- a/netbox/secrets/migrations/0002_add_sessionkeys.py +++ b/netbox/secrets/migrations/0002_add_sessionkeys.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-02-03 17:10 +# Generated by Django 1.10.6 on 2017-03-14 14:46 from __future__ import unicode_literals from django.conf import settings @@ -22,7 +22,6 @@ class Migration(migrations.Migration): ('cipher', models.BinaryField(max_length=512)), ('hash', models.CharField(editable=False, max_length=128)), ('created', models.DateTimeField(auto_now_add=True)), - ('expiration_time', models.DateTimeField(blank=True, editable=False, null=True)), ('user', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='session_key', to=settings.AUTH_USER_MODEL)), ], options={ diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 8542c9bf3..f51fdd664 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -1,4 +1,3 @@ -import datetime import os from Crypto.Cipher import AES, PKCS1_OAEP, XOR from Crypto.PublicKey import RSA @@ -9,7 +8,6 @@ from django.contrib.auth.models import Group, User from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.db import models -from django.utils import timezone from django.utils.encoding import force_bytes, python_2_unicode_compatible from dcim.models import Device @@ -192,7 +190,6 @@ class SessionKey(models.Model): cipher = models.BinaryField(max_length=512, editable=False) hash = models.CharField(max_length=128, editable=False) created = models.DateTimeField(auto_now_add=True) - expiration_time = models.DateTimeField(blank=True, null=True, editable=False) key = None @@ -217,10 +214,6 @@ class SessionKey(models.Model): # Encrypt master key using the session key self.cipher = xor_keys(self.key, master_key) - # Calculate expiration time - # TODO: Define a SESSION_KEY_MAX_AGE configuration setting - self.expiration_time = timezone.now() + datetime.timedelta(hours=12) - super(SessionKey, self).save(*args, **kwargs) def get_master_key(self, session_key): From 105d17748edd0d59ff93c7a21bcb2693637e3399 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 14 Mar 2017 12:32:08 -0400 Subject: [PATCH 060/182] Secrets UI work --- netbox/project-static/js/secrets.js | 52 ++++++++---------- netbox/secrets/api/views.py | 53 +++++++++++-------- netbox/secrets/exceptions.py | 5 ++ netbox/secrets/models.py | 3 +- .../secrets/inc/private_key_modal.html | 2 +- 5 files changed, 62 insertions(+), 53 deletions(-) create mode 100644 netbox/secrets/exceptions.py diff --git a/netbox/project-static/js/secrets.js b/netbox/project-static/js/secrets.js index f2467c365..895ea5695 100644 --- a/netbox/project-static/js/secrets.js +++ b/netbox/project-static/js/secrets.js @@ -1,38 +1,20 @@ $(document).ready(function() { // Unlocking a secret - $('button.unlock-secret').click(function (event) { + $('button.unlock-secret').click(function() { var secret_id = $(this).attr('secret-id'); - - // If we have an active cookie containing a session key, send the API request. - if (document.cookie.indexOf('session_key') > 0) { - console.log("Retrieving secret..."); - unlock_secret(secret_id); - // Otherwise, prompt the user for a private key so we can request a session key. - } else { - console.log("No session key found. Prompt user for private key."); - $('#privkey_modal').modal('show'); - } - + unlock_secret(secret_id); }); // Locking a secret - $('button.lock-secret').click(function (event) { + $('button.lock-secret').click(function() { var secret_id = $(this).attr('secret-id'); - var secret_div = $('#secret_' + secret_id); - - // Delete the plaintext from the DOM element. - secret_div.html('********'); - $(this).hide(); - $(this).siblings('button.unlock-secret').show(); + lock_secret(secret_id); }); // Retrieve a session key $('#request_session_key').click(function() { var private_key = $('#user_privkey').val(); - - // POST the user's private key to request a temporary session key. - console.log("Requesting a session key..."); get_session_key(private_key); }); @@ -43,23 +25,35 @@ $(document).ready(function() { type: 'GET', dataType: 'json', success: function (response, status) { - console.log("Secret retrieved successfully"); - $('#secret_' + secret_id).html(response.plaintext); - $('button.unlock-secret[secret-id=' + secret_id + ']').hide(); - $('button.lock-secret[secret-id=' + secret_id + ']').show(); + if (response.plaintext) { + console.log("Secret retrieved successfully"); + $('#secret_' + secret_id).html(response.plaintext); + $('button.unlock-secret[secret-id=' + secret_id + ']').hide(); + $('button.lock-secret[secret-id=' + secret_id + ']').show(); + } else { + console.log("Secret was not decrypted. Prompt user for private key."); + $('#privkey_modal').modal('show'); + } }, error: function (xhr, ajaxOptions, thrownError) { console.log("Error: " + xhr.responseText); if (xhr.status == 403) { alert("Permission denied"); } else { - var json = jQuery.parseJSON(xhr.responseText); - alert("Secret retrieval failed: " + json['error']); + alert(xhr.responseText); } } }); } + // Remove secret data from the DOM + function lock_secret(secret_id) { + var secret_div = $('#secret_' + secret_id); + secret_div.html('********'); + $('button.lock-secret[secret-id=' + secret_id + ']').hide(); + $('button.unlock-secret[secret-id=' + secret_id + ']').show(); + } + // Request a session key via the API function get_session_key(private_key) { var csrf_token = $('input[name=csrfmiddlewaretoken]').val(); @@ -74,7 +68,7 @@ $(document).ready(function() { xhr.setRequestHeader("X-CSRFToken", csrf_token); }, success: function (response, status) { - console.log("Received a new session key; valid until " + response.expiration_time); + console.log("Received a new session key"); alert('Session key received! You may now unlock secrets.'); }, error: function (xhr, ajaxOptions, thrownError) { diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index aaf94f998..abd0bb292 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -11,6 +11,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ViewSet, ModelViewSet from extras.api.renderers import FormlessBrowsableAPIRenderer, FreeRADIUSClientsRenderer +from secrets.exceptions import InvalidSessionKey from secrets.filters import SecretFilter from secrets.models import Secret, SecretRole, SessionKey, UserKey from utilities.api import WritableSerializerMixin @@ -53,42 +54,50 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): authentication_classes = [BasicAuthentication, SessionAuthentication] permission_classes = [IsAuthenticated] - def _get_master_key(self, request): + def _read_session_key(self, request): # Check for a session key provided as a cookie or header if 'session_key' in request.COOKIES: - session_key = base64.b64decode(request.COOKIES['session_key']) + return base64.b64decode(request.COOKIES['session_key']) elif 'HTTP_X_SESSION_KEY' in request.META: - session_key = base64.b64decode(request.META['HTTP_X_SESSION_KEY']) - else: - return None - - # Retrieve session key cipher (if any) for the current user - try: - sk = SessionKey.objects.get(user=request.user) - except SessionKey.DoesNotExist: - return None - - # Recover master key - # TODO: Exception handling - master_key = sk.get_master_key(session_key) - - return master_key + return base64.b64decode(request.META['HTTP_X_SESSION_KEY']) + return None def retrieve(self, request, *args, **kwargs): - master_key = self._get_master_key(request) - secret = self.get_object() - if master_key is not None: - secret.decrypt(master_key) + secret = self.get_object() + session_key = self._read_session_key(request) + + # Retrieve session key cipher (if any) for the current user + if session_key is not None: + try: + sk = SessionKey.objects.get(user=request.user) + master_key = sk.get_master_key(session_key) + secret.decrypt(master_key) + except SessionKey.DoesNotExist: + return HttpResponseBadRequest("No active session key for current user.") + except InvalidSessionKey: + return HttpResponseBadRequest("Invalid session key.") serializer = self.get_serializer(secret) return Response(serializer.data) def list(self, request, *args, **kwargs): - master_key = self._get_master_key(request) + queryset = self.filter_queryset(self.get_queryset()) + # Attempt to retrieve the master key for decryption + session_key = self._read_session_key(request) + master_key = None + if session_key is not None: + try: + sk = SessionKey.objects.get(user=request.user) + master_key = sk.get_master_key(session_key) + except SessionKey.DoesNotExist: + return HttpResponseBadRequest("No active session key for current user.") + except InvalidSessionKey: + return HttpResponseBadRequest("Invalid session key.") + # Pagination page = self.paginate_queryset(queryset) if page is not None: diff --git a/netbox/secrets/exceptions.py b/netbox/secrets/exceptions.py new file mode 100644 index 000000000..417d135de --- /dev/null +++ b/netbox/secrets/exceptions.py @@ -0,0 +1,5 @@ +class InvalidSessionKey(Exception): + """ + Raised when the a provided session key is invalid. + """ + pass diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index f51fdd664..91e2ad895 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -13,6 +13,7 @@ from django.utils.encoding import force_bytes, python_2_unicode_compatible from dcim.models import Device from utilities.models import CreatedUpdatedModel +from .exceptions import InvalidSessionKey from .hashers import SecretValidationHasher @@ -220,7 +221,7 @@ class SessionKey(models.Model): # Validate the provided session key if not check_password(session_key, self.hash): - raise Exception("Invalid session key") + raise InvalidSessionKey() # Decrypt master key using provided session key master_key = xor_keys(session_key, self.cipher) diff --git a/netbox/templates/secrets/inc/private_key_modal.html b/netbox/templates/secrets/inc/private_key_modal.html index 00d2455c1..77a214153 100644 --- a/netbox/templates/secrets/inc/private_key_modal.html +++ b/netbox/templates/secrets/inc/private_key_modal.html @@ -17,7 +17,7 @@
    -
    From b868de8d6793e999efc86a29f5716c42dfdc3af9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 14 Mar 2017 12:59:10 -0400 Subject: [PATCH 061/182] Updated user URLs --- netbox/templates/users/api_tokens.html | 6 +++--- netbox/users/views.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index 659b01a70..c0e5c55f9 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -10,8 +10,8 @@
    {{ token.key }} {% if token.is_expired %} @@ -49,7 +49,7 @@ {% empty %}

    You do not have any API tokens.

    {% endfor %} - + Add a token diff --git a/netbox/users/views.py b/netbox/users/views.py index ee4d0161f..bf5706617 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -164,7 +164,7 @@ class TokenEditView(LoginRequiredMixin, View): 'obj': token, 'obj_type': token._meta.verbose_name, 'form': form, - 'return_url': reverse('users:token_list'), + 'return_url': reverse('user:token_list'), }) def post(self, request, pk=None): @@ -183,7 +183,7 @@ class TokenEditView(LoginRequiredMixin, View): msg = "Token updated" if pk else "New token created" messages.success(request, msg) - return redirect('users:token_list') + return redirect('user:token_list') class TokenDeleteView(LoginRequiredMixin, View): @@ -192,7 +192,7 @@ class TokenDeleteView(LoginRequiredMixin, View): token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) initial_data = { - 'return_url': reverse('users:token_list'), + 'return_url': reverse('user:token_list'), } form = ConfirmationForm(initial=initial_data) @@ -200,7 +200,7 @@ class TokenDeleteView(LoginRequiredMixin, View): 'obj': token, 'obj_type': token._meta.verbose_name, 'form': form, - 'return_url': reverse('users:token_list'), + 'return_url': reverse('user:token_list'), }) def post(self, request, pk): @@ -210,11 +210,11 @@ class TokenDeleteView(LoginRequiredMixin, View): if form.is_valid(): token.delete() messages.success(request, "Token deleted") - return redirect('users:token_list') + return redirect('user:token_list') return render(request, 'utilities/obj_delete.html', { 'obj': token, 'obj_type': token._meta.verbose_name, 'form': form, - 'return_url': reverse('users:token_list'), + 'return_url': reverse('user:token_list'), }) From 4cb30f1ce45ec3c9749aa5fb0339a9b20e5d0c69 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 14 Mar 2017 13:32:07 -0400 Subject: [PATCH 062/182] Relate SessionKey to UserKey rather than User --- netbox/secrets/api/views.py | 6 ++-- ...eys.py => 0002_userkey_add_session_key.py} | 9 ++++-- netbox/secrets/models.py | 2 +- netbox/templates/users/userkey.html | 28 +++++++++++++------ 4 files changed, 30 insertions(+), 15 deletions(-) rename netbox/secrets/migrations/{0002_add_sessionkeys.py => 0002_userkey_add_session_key.py} (77%) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index abd0bb292..6a3593003 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -71,7 +71,7 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): # Retrieve session key cipher (if any) for the current user if session_key is not None: try: - sk = SessionKey.objects.get(user=request.user) + sk = SessionKey.objects.get(userkey__user=request.user) master_key = sk.get_master_key(session_key) secret.decrypt(master_key) except SessionKey.DoesNotExist: @@ -152,10 +152,10 @@ class GetSessionKeyViewSet(ViewSet): return HttpResponseBadRequest(ERR_PRIVKEY_INVALID) # Delete the existing SessionKey for this user if one exists - SessionKey.objects.filter(user=request.user).delete() + SessionKey.objects.filter(userkey__user=request.user).delete() # Create a new SessionKey - sk = SessionKey(user=request.user) + sk = SessionKey(userkey=user_key) sk.save(master_key=master_key) encoded_key = base64.b64encode(sk.key) diff --git a/netbox/secrets/migrations/0002_add_sessionkeys.py b/netbox/secrets/migrations/0002_userkey_add_session_key.py similarity index 77% rename from netbox/secrets/migrations/0002_add_sessionkeys.py rename to netbox/secrets/migrations/0002_userkey_add_session_key.py index 139b68331..04daf4d77 100644 --- a/netbox/secrets/migrations/0002_add_sessionkeys.py +++ b/netbox/secrets/migrations/0002_userkey_add_session_key.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.6 on 2017-03-14 14:46 +# Generated by Django 1.10.6 on 2017-03-14 17:19 from __future__ import unicode_literals from django.conf import settings @@ -10,7 +10,6 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('secrets', '0001_initial'), ] @@ -22,7 +21,6 @@ class Migration(migrations.Migration): ('cipher', models.BinaryField(max_length=512)), ('hash', models.CharField(editable=False, max_length=128)), ('created', models.DateTimeField(auto_now_add=True)), - ('user', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='session_key', to=settings.AUTH_USER_MODEL)), ], options={ 'ordering': ['user__username'], @@ -33,4 +31,9 @@ class Migration(migrations.Migration): name='user', field=models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='user_key', to=settings.AUTH_USER_MODEL), ), + migrations.AddField( + model_name='sessionkey', + name='userkey', + field=models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='session_key', to='secrets.UserKey'), + ), ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 91e2ad895..07e57796c 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -187,7 +187,7 @@ class SessionKey(models.Model): """ A SessionKey stores a User's temporary key to be used for the encryption and decryption of secrets. """ - user = models.OneToOneField(User, related_name='session_key', editable=False) + userkey = models.OneToOneField(UserKey, related_name='session_key', on_delete=models.CASCADE, editable=False) cipher = models.BinaryField(max_length=512, editable=False) hash = models.CharField(max_length=128, editable=False) created = models.DateTimeField(auto_now_add=True) diff --git a/netbox/templates/users/userkey.html b/netbox/templates/users/userkey.html index df5e55be9..eb2f7d2e5 100644 --- a/netbox/templates/users/userkey.html +++ b/netbox/templates/users/userkey.html @@ -4,6 +4,12 @@ {% block usercontent %} {% if userkey %} +

    Your user key is: {% if userkey.is_active %} @@ -12,15 +18,21 @@ Inactive {% endif %}

    -

    Your public key is below.

    -
    {{ userkey.public_key }}
    - {% include 'inc/created_updated.html' with obj=userkey %} +
    {{ userkey.public_key }}
    +
    + {% if userkey.session_key %} + +

    Session key: Active

    + Created {{ userkey.session_key.created }} + {% else %} +

    No active session key

    + {% endif %} {% else %}

    You don't have a user key on file.

    From 3dc15068b932c96b3e265b30fde7577043cfce40 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 14 Mar 2017 14:01:06 -0400 Subject: [PATCH 063/182] Allow user to delete session key --- netbox/project-static/js/secrets.js | 4 +- netbox/secrets/models.py | 2 +- netbox/templates/users/sessionkey_delete.html | 5 +++ netbox/templates/users/userkey.html | 2 +- netbox/users/urls.py | 2 +- netbox/users/views.py | 38 ++++++++++++++++++- 6 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 netbox/templates/users/sessionkey_delete.html diff --git a/netbox/project-static/js/secrets.js b/netbox/project-static/js/secrets.js index 895ea5695..82bb1790e 100644 --- a/netbox/project-static/js/secrets.js +++ b/netbox/project-static/js/secrets.js @@ -14,8 +14,10 @@ $(document).ready(function() { // Retrieve a session key $('#request_session_key').click(function() { - var private_key = $('#user_privkey').val(); + var private_key_field = $('#user_privkey'); + var private_key = private_key_field.val(); get_session_key(private_key); + private_key_field.val(""); }); // Retrieve a secret via the API diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 07e57796c..1761094fc 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -198,7 +198,7 @@ class SessionKey(models.Model): ordering = ['user__username'] def __str__(self): - return self.user.username + return self.userkey.user.username def save(self, master_key=None, *args, **kwargs): diff --git a/netbox/templates/users/sessionkey_delete.html b/netbox/templates/users/sessionkey_delete.html new file mode 100644 index 000000000..c91956b79 --- /dev/null +++ b/netbox/templates/users/sessionkey_delete.html @@ -0,0 +1,5 @@ +{% extends 'utilities/obj_delete.html' %} + +{% block message %} +

    Are you sure you want to delete your session key?

    +{% endblock %} diff --git a/netbox/templates/users/userkey.html b/netbox/templates/users/userkey.html index eb2f7d2e5..f34d8769b 100644 --- a/netbox/templates/users/userkey.html +++ b/netbox/templates/users/userkey.html @@ -23,7 +23,7 @@
    {% if userkey.session_key %}
    - + Delete session key diff --git a/netbox/users/urls.py b/netbox/users/urls.py index a56635f18..6ec8e21ba 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -5,7 +5,6 @@ from . import views urlpatterns = [ - # User profiles url(r'^profile/$', views.profile, name='profile'), url(r'^password/$', views.change_password, name='change_password'), url(r'^api-tokens/$', views.TokenListView.as_view(), name='token_list'), @@ -14,6 +13,7 @@ urlpatterns = [ url(r'^api-tokens/(?P\d+)/delete/$', views.TokenDeleteView.as_view(), name='token_delete'), url(r'^user-key/$', views.userkey, name='userkey'), url(r'^user-key/edit/$', views.userkey_edit, name='userkey_edit'), + url(r'^session-key/delete/$', views.SessionKeyDeleteView.as_view(), name='sessionkey_delete'), url(r'^recent-activity/$', views.recent_activity, name='recent_activity'), ] diff --git a/netbox/users/views.py b/netbox/users/views.py index bf5706617..41cecb96a 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -9,7 +9,7 @@ from django.utils.http import is_safe_url from django.views.generic import View from secrets.forms import UserKeyForm -from secrets.models import UserKey +from secrets.models import SessionKey, UserKey from utilities.forms import ConfirmationForm from .forms import LoginForm, PasswordChangeForm, TokenForm from .models import Token @@ -124,6 +124,42 @@ def userkey_edit(request): }) +class SessionKeyDeleteView(LoginRequiredMixin, View): + + def get(self, request): + + sessionkey = get_object_or_404(SessionKey, userkey__user=request.user) + form = ConfirmationForm() + + return render(request, 'users/sessionkey_delete.html', { + 'obj_type': sessionkey._meta.verbose_name, + 'form': form, + 'return_url': reverse('user:userkey'), + }) + + def post(self, request): + + sessionkey = get_object_or_404(SessionKey, userkey__user=request.user) + form = ConfirmationForm(request.POST) + if form.is_valid(): + + # Delete session key + sessionkey.delete() + messages.success(request, "Session key deleted") + + # Delete cookie + response = redirect('user:userkey') + response.delete_cookie('session_key', path=reverse('secrets-api:secret-list')) + + return response + + return render(request, 'users/sessionkey_delete.html', { + 'obj_type': sessionkey._meta.verbose_name, + 'form': form, + 'return_url': reverse('user:userkey'), + }) + + @login_required() def recent_activity(request): From 3d76a982aad25bafc68eb01360528766fafbaa1b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Mar 2017 13:15:09 -0400 Subject: [PATCH 064/182] Removed old API doc --- docs/api-integration.md | 19 ------------------- mkdocs.yml | 1 - 2 files changed, 20 deletions(-) delete mode 100644 docs/api-integration.md diff --git a/docs/api-integration.md b/docs/api-integration.md deleted file mode 100644 index 99185adf6..000000000 --- a/docs/api-integration.md +++ /dev/null @@ -1,19 +0,0 @@ -# API Integration - -NetBox features a read-only REST API which can be used to integrate it with -other applications. - -In the future, both read and write actions will be available via the API. - -## Clients - -The easiest way to start integrating your applications with NetBox is to make -use of an API client. If you build or discover an API client that is not part -of this list, please send a pull request! - -- **Go**: [github.com/digitalocean/go-netbox](https://github.com/digitalocean/go-netbox) - -## Documentation - -If you wish to build a new API client or simply explore the NetBox API, -Swagger documentation can be found at the URL `/api/docs/` on a NetBox server. diff --git a/mkdocs.yml b/mkdocs.yml index 07f319468..9833f8832 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,7 +21,6 @@ pages: - 'Extras': 'data-model/extras.md' - 'API': - 'Structure': 'api/structure.md' - - 'API Integration': 'api-integration.md' markdown_extensions: - admonition: From 07a2b136b8d6931502c99d18ad3d0849e89db13a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Mar 2017 13:48:09 -0400 Subject: [PATCH 065/182] Refactored SecretViewSet --- netbox/secrets/api/views.py | 65 +++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 6a3593003..1f683cd09 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -8,7 +8,7 @@ from rest_framework.authentication import BasicAuthentication, SessionAuthentica from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONRenderer from rest_framework.response import Response -from rest_framework.viewsets import ViewSet, ModelViewSet +from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet from extras.api.renderers import FormlessBrowsableAPIRenderer, FreeRADIUSClientsRenderer from secrets.exceptions import InvalidSessionKey @@ -50,34 +50,37 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): filter_class = SecretFilter # DRF's BrowsableAPIRenderer can't support passing the secret key as a header, so we disable it. renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer] - # Enabled BasicAuthentication for testing (until we have TokenAuthentication implemented) - authentication_classes = [BasicAuthentication, SessionAuthentication] - permission_classes = [IsAuthenticated] - def _read_session_key(self, request): + master_key = None - # Check for a session key provided as a cookie or header + def initial(self, request, *args, **kwargs): + + super(SecretViewSet, self).initial(request, *args, **kwargs) + + # Read session key from HTTP cookie or header if it has been provided. The session key must be provided in order + # to encrypt/decrypt secrets. if 'session_key' in request.COOKIES: - return base64.b64decode(request.COOKIES['session_key']) + session_key = base64.b64decode(request.COOKIES['session_key']) elif 'HTTP_X_SESSION_KEY' in request.META: - return base64.b64decode(request.META['HTTP_X_SESSION_KEY']) - return None + session_key = base64.b64decode(request.META['HTTP_X_SESSION_KEY']) + else: + session_key = None + + # Attempt to retrieve the master key for encryption/decryption if a session key has been provided. + if session_key is not None: + try: + sk = SessionKey.objects.get(userkey__user=request.user) + self.master_key = sk.get_master_key(session_key) + except (SessionKey.DoesNotExist, InvalidSessionKey): + return HttpResponseBadRequest("Invalid session key.") def retrieve(self, request, *args, **kwargs): secret = self.get_object() - session_key = self._read_session_key(request) - # Retrieve session key cipher (if any) for the current user - if session_key is not None: - try: - sk = SessionKey.objects.get(userkey__user=request.user) - master_key = sk.get_master_key(session_key) - secret.decrypt(master_key) - except SessionKey.DoesNotExist: - return HttpResponseBadRequest("No active session key for current user.") - except InvalidSessionKey: - return HttpResponseBadRequest("Invalid session key.") + # Attempt to decrypt the secret if the master key is known + if self.master_key is not None: + secret.decrypt(self.master_key) serializer = self.get_serializer(secret) return Response(serializer.data) @@ -86,29 +89,19 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): queryset = self.filter_queryset(self.get_queryset()) - # Attempt to retrieve the master key for decryption - session_key = self._read_session_key(request) - master_key = None - if session_key is not None: - try: - sk = SessionKey.objects.get(user=request.user) - master_key = sk.get_master_key(session_key) - except SessionKey.DoesNotExist: - return HttpResponseBadRequest("No active session key for current user.") - except InvalidSessionKey: - return HttpResponseBadRequest("Invalid session key.") - - # Pagination page = self.paginate_queryset(queryset) if page is not None: - secrets = [] - if master_key is not None: + + # Attempt to decrypt all secrets if the master key is known + if self.master_key is not None: + secrets = [] for secret in page: - secret.decrypt(master_key) + secret.decrypt(self.master_key) secrets.append(secret) serializer = self.get_serializer(secrets, many=True) else: serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) From e3ae013e427d6c23aaf6fc8b05057248d2441042 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Mar 2017 14:47:18 -0400 Subject: [PATCH 066/182] Implemented full read/write support for secrets --- netbox/secrets/api/views.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 1f683cd09..adab5e51d 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -4,13 +4,11 @@ from Crypto.PublicKey import RSA from django.core.urlresolvers import reverse from django.http import HttpResponseBadRequest -from rest_framework.authentication import BasicAuthentication, SessionAuthentication +from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated -from rest_framework.renderers import JSONRenderer from rest_framework.response import Response -from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet +from rest_framework.viewsets import ModelViewSet, ViewSet -from extras.api.renderers import FormlessBrowsableAPIRenderer, FreeRADIUSClientsRenderer from secrets.exceptions import InvalidSessionKey from secrets.filters import SecretFilter from secrets.models import Secret, SecretRole, SessionKey, UserKey @@ -38,7 +36,6 @@ class SecretRoleViewSet(ModelViewSet): # Secrets # -# TODO: Need to implement custom create() and update() methods to handle secret encryption. class SecretViewSet(WritableSerializerMixin, ModelViewSet): queryset = Secret.objects.select_related( 'device__primary_ip4', 'device__primary_ip6', 'role', @@ -48,11 +45,21 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): serializer_class = serializers.SecretSerializer write_serializer_class = serializers.WritableSecretSerializer filter_class = SecretFilter - # DRF's BrowsableAPIRenderer can't support passing the secret key as a header, so we disable it. - renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer] master_key = None + def _get_encrypted_fields(self, serializer): + """ + Since we can't call encrypt() on the serializer like we can on the Secret model, we need to calculate the + ciphertext and hash values by encrypting a dummy copy. These can be passed to the serializer's save() method. + """ + s = Secret(plaintext=serializer.validated_data['plaintext']) + s.encrypt(self.master_key) + return ({ + 'ciphertext': s.ciphertext, + 'hash': s.hash, + }) + def initial(self, request, *args, **kwargs): super(SecretViewSet, self).initial(request, *args, **kwargs) @@ -66,13 +73,18 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): else: session_key = None + # We can't encrypt secret plaintext without a session key. + # assert False, self.action + if self.action in ['create', 'update'] and session_key is None: + raise ValidationError("A session key must be provided when creating or updating secrets.") + # Attempt to retrieve the master key for encryption/decryption if a session key has been provided. if session_key is not None: try: sk = SessionKey.objects.get(userkey__user=request.user) self.master_key = sk.get_master_key(session_key) except (SessionKey.DoesNotExist, InvalidSessionKey): - return HttpResponseBadRequest("Invalid session key.") + raise ValidationError("Invalid session key.") def retrieve(self, request, *args, **kwargs): @@ -107,6 +119,12 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + def perform_create(self, serializer): + serializer.save(**self._get_encrypted_fields(serializer)) + + def perform_update(self, serializer): + serializer.save(**self._get_encrypted_fields(serializer)) + class GetSessionKeyViewSet(ViewSet): """ From 0312016f89fef56ae719766e37c90c87eb1185d3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Mar 2017 12:46:52 -0400 Subject: [PATCH 067/182] Wrote tests for circuits API --- netbox/circuits/tests/__init__.py | 0 netbox/circuits/tests/test_api.py | 285 ++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 netbox/circuits/tests/__init__.py create mode 100644 netbox/circuits/tests/test_api.py diff --git a/netbox/circuits/tests/__init__.py b/netbox/circuits/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py new file mode 100644 index 000000000..7f68e7814 --- /dev/null +++ b/netbox/circuits/tests/test_api.py @@ -0,0 +1,285 @@ +from rest_framework import status +from rest_framework.test import APITestCase + +from django.contrib.auth.models import User +from django.urls import reverse + +from dcim.models import Site +from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z +from users.models import Token + + +class ProviderTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1') + self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2') + self.provider3 = Provider.objects.create(name='Test Provider 3', slug='test-provider-3') + + def test_get_provider(self): + + url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.provider1.name) + + def test_list_providers(self): + + url = reverse('circuits-api:provider-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_provider(self): + + data = { + 'name': 'Test Provider 4', + 'slug': 'test-provider-4', + } + + url = reverse('circuits-api:provider-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Provider.objects.count(), 4) + + def test_update_provider(self): + + data = { + 'name': 'Test Provider X', + 'slug': 'test-provider-x', + } + + url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Provider.objects.count(), 3) + self.assertEqual(Provider.objects.get(pk=self.provider1.pk).name, data['name']) + self.assertEqual(Provider.objects.get(pk=self.provider1.pk).slug, data['slug']) + + def test_delete_provider(self): + + url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Provider.objects.count(), 2) + + +class CircuitTypeTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1') + self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2') + self.circuittype3 = CircuitType.objects.create(name='Test Circuit Type 3', slug='test-circuit-type-3') + + def test_get_circuittype(self): + + url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.circuittype1.name) + + def test_list_circuittypes(self): + + url = reverse('circuits-api:circuittype-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_circuittype(self): + + data = { + 'name': 'Test Circuit Type 4', + 'slug': 'test-circuit-type-4', + } + + url = reverse('circuits-api:circuittype-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(CircuitType.objects.count(), 4) + + def test_update_circuittype(self): + + data = { + 'name': 'Test Circuit Type X', + 'slug': 'test-circuit-type-x', + } + + url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(CircuitType.objects.count(), 3) + self.assertEqual(CircuitType.objects.get(pk=self.circuittype1.pk).name, data['name']) + self.assertEqual(CircuitType.objects.get(pk=self.circuittype1.pk).slug, data['slug']) + + def test_delete_circuittype(self): + + url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(CircuitType.objects.count(), 2) + + +class CircuitTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1') + self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2') + self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1') + self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2') + self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=self.provider1, type=self.circuittype1) + self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=self.provider1, type=self.circuittype1) + self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=self.provider1, type=self.circuittype1) + + def test_get_circuit(self): + + url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['cid'], self.circuit1.cid) + + def test_list_circuits(self): + + url = reverse('circuits-api:circuit-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_circuit(self): + + data = { + 'cid': 'TEST0004', + 'provider': self.provider1.pk, + 'type': self.circuittype1.pk, + } + + url = reverse('circuits-api:circuit-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Circuit.objects.count(), 4) + + def test_update_circuit(self): + + data = { + 'cid': 'TEST000X', + 'provider': self.provider2.pk, + 'type': self.circuittype2.pk, + } + + url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Circuit.objects.count(), 3) + self.assertEqual(Circuit.objects.get(pk=self.circuit1.pk).cid, data['cid']) + self.assertEqual(Circuit.objects.get(pk=self.circuit1.pk).provider_id, data['provider']) + self.assertEqual(Circuit.objects.get(pk=self.circuit1.pk).type_id, data['type']) + + def test_delete_circuit(self): + + url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Circuit.objects.count(), 2) + + +class CircuitTerminationTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + provider = Provider.objects.create(name='Test Provider', slug='test-provider') + circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type') + self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=provider, type=circuittype) + self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype) + self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype) + self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') + self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') + self.circuittermination1 = CircuitTermination.objects.create( + circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 + ) + self.circuittermination2 = CircuitTermination.objects.create( + circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 + ) + self.circuittermination3 = CircuitTermination.objects.create( + circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 + ) + + def test_get_circuittermination(self): + + url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['id'], self.circuittermination1.pk) + + def test_list_circuitterminations(self): + + url = reverse('circuits-api:circuittermination-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_circuittermination(self): + + data = { + 'circuit': self.circuit1.pk, + 'term_side': TERM_SIDE_Z, + 'site': self.site2.pk, + 'port_speed': 1000000, + } + + url = reverse('circuits-api:circuittermination-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(CircuitTermination.objects.count(), 4) + + def test_update_circuittermination(self): + + data = { + 'circuit': self.circuit1.pk, + 'term_side': TERM_SIDE_Z, + 'site': self.site2.pk, + 'port_speed': 1000000, + } + + url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(CircuitTermination.objects.count(), 3) + self.assertEqual(CircuitTermination.objects.get(pk=self.circuittermination1.pk).site_id, data['site']) + self.assertEqual(CircuitTermination.objects.get(pk=self.circuittermination1.pk).term_side, data['term_side']) + + def test_delete_circuittermination(self): + + url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(CircuitTermination.objects.count(), 2) From d58f9031d1ae2e2692fa87f621003d2dae1b0164 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Mar 2017 13:29:55 -0400 Subject: [PATCH 068/182] Wrote tests for tenancy API --- netbox/tenancy/tests/__init__.py | 0 netbox/tenancy/tests/test_api.py | 134 +++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 netbox/tenancy/tests/__init__.py create mode 100644 netbox/tenancy/tests/test_api.py diff --git a/netbox/tenancy/tests/__init__.py b/netbox/tenancy/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py new file mode 100644 index 000000000..fb630caee --- /dev/null +++ b/netbox/tenancy/tests/test_api.py @@ -0,0 +1,134 @@ +from rest_framework import status +from rest_framework.test import APITestCase + +from django.contrib.auth.models import User +from django.urls import reverse + +from tenancy.models import Tenant, TenantGroup +from users.models import Token + + +class TenantGroupTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1') + self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2') + self.tenantgroup3 = TenantGroup.objects.create(name='Test Tenant Group 3', slug='test-tenant-group-3') + + def test_get_tenantgroup(self): + + url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.tenantgroup1.name) + + def test_list_tenantgroups(self): + + url = reverse('tenancy-api:tenantgroup-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_tenantgroup(self): + + data = { + 'name': 'Test Tenant Group 4', + 'slug': 'test-tenant-group-4', + } + + url = reverse('tenancy-api:tenantgroup-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(TenantGroup.objects.count(), 4) + + def test_update_tenantgroup(self): + + data = { + 'name': 'Test Tenant Group X', + 'slug': 'test-tenant-group-x', + } + + url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(TenantGroup.objects.count(), 3) + self.assertEqual(TenantGroup.objects.get(pk=self.tenantgroup1.pk).name, data['name']) + self.assertEqual(TenantGroup.objects.get(pk=self.tenantgroup1.pk).slug, data['slug']) + + def test_delete_tenantgroup(self): + + url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(TenantGroup.objects.count(), 2) + + +class TenantTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1') + self.tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2') + self.tenant3 = Tenant.objects.create(name='Test Tenant 3', slug='test-tenant-3') + + def test_get_tenant(self): + + url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.tenant1.name) + + def test_list_tenants(self): + + url = reverse('tenancy-api:tenant-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_tenant(self): + + data = { + 'name': 'Test Tenant 4', + 'slug': 'test-tenant-4', + } + + url = reverse('tenancy-api:tenant-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Tenant.objects.count(), 4) + + def test_update_tenant(self): + + data = { + 'name': 'Test Tenant X', + 'slug': 'test-tenant-x', + } + + url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Tenant.objects.count(), 3) + self.assertEqual(Tenant.objects.get(pk=self.tenant1.pk).name, data['name']) + self.assertEqual(Tenant.objects.get(pk=self.tenant1.pk).slug, data['slug']) + + def test_delete_tenant(self): + + url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Tenant.objects.count(), 2) From bbc355df07f49f8c1ff6bcaf3bc720e400b4d61c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Mar 2017 14:17:14 -0400 Subject: [PATCH 069/182] Improved create/update validation --- netbox/circuits/tests/test_api.py | 39 ++++++++++++++++++++++++------- netbox/tenancy/tests/test_api.py | 28 ++++++++++++++++------ 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 7f68e7814..cd456d86c 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -47,6 +47,9 @@ class ProviderTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Provider.objects.count(), 4) + provider4 = Provider.objects.get(pk=response.data['id']) + self.assertEqual(provider4.name, data['name']) + self.assertEqual(provider4.slug, data['slug']) def test_update_provider(self): @@ -60,8 +63,9 @@ class ProviderTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(Provider.objects.count(), 3) - self.assertEqual(Provider.objects.get(pk=self.provider1.pk).name, data['name']) - self.assertEqual(Provider.objects.get(pk=self.provider1.pk).slug, data['slug']) + provider1 = Provider.objects.get(pk=response.data['id']) + self.assertEqual(provider1.name, data['name']) + self.assertEqual(provider1.slug, data['slug']) def test_delete_provider(self): @@ -110,6 +114,9 @@ class CircuitTypeTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(CircuitType.objects.count(), 4) + circuittype4 = CircuitType.objects.get(pk=response.data['id']) + self.assertEqual(circuittype4.name, data['name']) + self.assertEqual(circuittype4.slug, data['slug']) def test_update_circuittype(self): @@ -123,8 +130,9 @@ class CircuitTypeTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(CircuitType.objects.count(), 3) - self.assertEqual(CircuitType.objects.get(pk=self.circuittype1.pk).name, data['name']) - self.assertEqual(CircuitType.objects.get(pk=self.circuittype1.pk).slug, data['slug']) + circuittype1 = CircuitType.objects.get(pk=response.data['id']) + self.assertEqual(circuittype1.name, data['name']) + self.assertEqual(circuittype1.slug, data['slug']) def test_delete_circuittype(self): @@ -178,6 +186,10 @@ class CircuitTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Circuit.objects.count(), 4) + circuit4 = Circuit.objects.get(pk=response.data['id']) + self.assertEqual(circuit4.cid, data['cid']) + self.assertEqual(circuit4.provider_id, data['provider']) + self.assertEqual(circuit4.type_id, data['type']) def test_update_circuit(self): @@ -192,9 +204,10 @@ class CircuitTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(Circuit.objects.count(), 3) - self.assertEqual(Circuit.objects.get(pk=self.circuit1.pk).cid, data['cid']) - self.assertEqual(Circuit.objects.get(pk=self.circuit1.pk).provider_id, data['provider']) - self.assertEqual(Circuit.objects.get(pk=self.circuit1.pk).type_id, data['type']) + circuit1 = Circuit.objects.get(pk=response.data['id']) + self.assertEqual(circuit1.cid, data['cid']) + self.assertEqual(circuit1.provider_id, data['provider']) + self.assertEqual(circuit1.type_id, data['type']) def test_delete_circuit(self): @@ -258,6 +271,11 @@ class CircuitTerminationTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(CircuitTermination.objects.count(), 4) + circuittermination4 = CircuitTermination.objects.get(pk=response.data['id']) + self.assertEqual(circuittermination4.circuit_id, data['circuit']) + self.assertEqual(circuittermination4.term_side, data['term_side']) + self.assertEqual(circuittermination4.site_id, data['site']) + self.assertEqual(circuittermination4.port_speed, data['port_speed']) def test_update_circuittermination(self): @@ -273,8 +291,11 @@ class CircuitTerminationTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(CircuitTermination.objects.count(), 3) - self.assertEqual(CircuitTermination.objects.get(pk=self.circuittermination1.pk).site_id, data['site']) - self.assertEqual(CircuitTermination.objects.get(pk=self.circuittermination1.pk).term_side, data['term_side']) + circuittermination1 = CircuitTermination.objects.get(pk=response.data['id']) + self.assertEqual(circuittermination1.circuit_id, data['circuit']) + self.assertEqual(circuittermination1.term_side, data['term_side']) + self.assertEqual(circuittermination1.site_id, data['site']) + self.assertEqual(circuittermination1.port_speed, data['port_speed']) def test_delete_circuittermination(self): diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index fb630caee..9d0f925c0 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -46,6 +46,9 @@ class TenantGroupTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(TenantGroup.objects.count(), 4) + tenantgroup4 = TenantGroup.objects.get(pk=response.data['id']) + self.assertEqual(tenantgroup4.name, data['name']) + self.assertEqual(tenantgroup4.slug, data['slug']) def test_update_tenantgroup(self): @@ -59,8 +62,9 @@ class TenantGroupTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(TenantGroup.objects.count(), 3) - self.assertEqual(TenantGroup.objects.get(pk=self.tenantgroup1.pk).name, data['name']) - self.assertEqual(TenantGroup.objects.get(pk=self.tenantgroup1.pk).slug, data['slug']) + tenantgroup1 = TenantGroup.objects.get(pk=response.data['id']) + self.assertEqual(tenantgroup1.name, data['name']) + self.assertEqual(tenantgroup1.slug, data['slug']) def test_delete_tenantgroup(self): @@ -79,9 +83,11 @@ class TenantTest(APITestCase): token = Token.objects.create(user=user) self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} - self.tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1') - self.tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2') - self.tenant3 = Tenant.objects.create(name='Test Tenant 3', slug='test-tenant-3') + self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1') + self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2') + self.tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1', group=self.tenantgroup1) + self.tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2', group=self.tenantgroup1) + self.tenant3 = Tenant.objects.create(name='Test Tenant 3', slug='test-tenant-3', group=self.tenantgroup1) def test_get_tenant(self): @@ -102,6 +108,7 @@ class TenantTest(APITestCase): data = { 'name': 'Test Tenant 4', 'slug': 'test-tenant-4', + 'group': self.tenantgroup1.pk, } url = reverse('tenancy-api:tenant-list') @@ -109,12 +116,17 @@ class TenantTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Tenant.objects.count(), 4) + tenant4 = Tenant.objects.get(pk=response.data['id']) + self.assertEqual(tenant4.name, data['name']) + self.assertEqual(tenant4.slug, data['slug']) + self.assertEqual(tenant4.group_id, data['group']) def test_update_tenant(self): data = { 'name': 'Test Tenant X', 'slug': 'test-tenant-x', + 'group': self.tenantgroup2.pk, } url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk}) @@ -122,8 +134,10 @@ class TenantTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(Tenant.objects.count(), 3) - self.assertEqual(Tenant.objects.get(pk=self.tenant1.pk).name, data['name']) - self.assertEqual(Tenant.objects.get(pk=self.tenant1.pk).slug, data['slug']) + tenant1 = Tenant.objects.get(pk=response.data['id']) + self.assertEqual(tenant1.name, data['name']) + self.assertEqual(tenant1.slug, data['slug']) + self.assertEqual(tenant1.group_id, data['group']) def test_delete_tenant(self): From f33269e50bd847d9e6e892a8980850f48d911505 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Mar 2017 16:50:18 -0400 Subject: [PATCH 070/182] First batch of DCIM API tests --- netbox/dcim/api/serializers.py | 11 +- netbox/dcim/api/views.py | 5 +- netbox/dcim/tests/test_api.py | 1066 ++++++++++++++++++++++++++++++++ netbox/dcim/tests/test_apis.py | 661 -------------------- netbox/utilities/api.py | 9 +- 5 files changed, 1086 insertions(+), 666 deletions(-) create mode 100644 netbox/dcim/tests/test_api.py delete mode 100644 netbox/dcim/tests/test_apis.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index e513a64de..0f3ff85ab 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -70,8 +70,8 @@ class WritableSiteSerializer(serializers.ModelSerializer): class Meta: model = Site fields = [ - 'id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name', - 'contact_phone', 'contact_email', 'comments', + 'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', + 'contact_name', 'contact_phone', 'contact_email', 'comments', ] @@ -171,6 +171,13 @@ class RackReservationSerializer(serializers.ModelSerializer): fields = ['id', 'rack', 'units', 'created', 'user', 'description'] +class WritableRackReservationSerializer(serializers.ModelSerializer): + + class Meta: + model = RackReservation + fields = ['id', 'rack', 'units', 'user', 'description'] + + # # Manufacturers # diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 96be490ef..9bb576cb9 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -114,9 +114,10 @@ class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # Rack reservations # -class RackReservationViewSet(ModelViewSet): - queryset = RackReservation.objects.all() +class RackReservationViewSet(WritableSerializerMixin, ModelViewSet): + queryset = RackReservation.objects.select_related('rack') serializer_class = serializers.RackReservationSerializer + write_serializer_class = serializers.WritableRackReservationSerializer filter_class = filters.RackReservationFilter diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py new file mode 100644 index 000000000..d2140d0fa --- /dev/null +++ b/netbox/dcim/tests/test_api.py @@ -0,0 +1,1066 @@ +from rest_framework import status +from rest_framework.test import APITestCase + +from django.contrib.auth.models import User +from django.urls import reverse + +from dcim.models import ( + ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, InterfaceTemplate, Manufacturer, + PowerPortTemplate, PowerOutletTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, +) +from users.models import Token + + +class RegionTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') + self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') + self.region3 = Region.objects.create(name='Test Region 3', slug='test-region-3') + + def test_get_region(self): + + url = reverse('dcim-api:region-detail', kwargs={'pk': self.region1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.region1.name) + + def test_list_regions(self): + + url = reverse('dcim-api:region-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_region(self): + + data = { + 'name': 'Test Region 4', + 'slug': 'test-region-4', + } + + url = reverse('dcim-api:region-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Region.objects.count(), 4) + region4 = Region.objects.get(pk=response.data['id']) + self.assertEqual(region4.name, data['name']) + self.assertEqual(region4.slug, data['slug']) + + def test_update_region(self): + + data = { + 'name': 'Test Region X', + 'slug': 'test-region-x', + } + + url = reverse('dcim-api:region-detail', kwargs={'pk': self.region1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Region.objects.count(), 3) + region1 = Region.objects.get(pk=response.data['id']) + self.assertEqual(region1.name, data['name']) + self.assertEqual(region1.slug, data['slug']) + + def test_delete_region(self): + + url = reverse('dcim-api:region-detail', kwargs={'pk': self.region1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Region.objects.count(), 2) + + +class SiteTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') + self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') + self.site1 = Site.objects.create(region=self.region1, name='Test Site 1', slug='test-site-1') + self.site2 = Site.objects.create(region=self.region1, name='Test Site 2', slug='test-site-2') + self.site3 = Site.objects.create(region=self.region1, name='Test Site 3', slug='test-site-3') + + def test_get_site(self): + + url = reverse('dcim-api:site-detail', kwargs={'pk': self.site1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.site1.name) + + def test_list_sites(self): + + url = reverse('dcim-api:site-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_site(self): + + data = { + 'name': 'Test Site 4', + 'slug': 'test-site-4', + 'region': self.region1.pk, + } + + url = reverse('dcim-api:site-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Site.objects.count(), 4) + site4 = Site.objects.get(pk=response.data['id']) + self.assertEqual(site4.name, data['name']) + self.assertEqual(site4.slug, data['slug']) + self.assertEqual(site4.region_id, data['region']) + + def test_update_site(self): + + data = { + 'name': 'Test Site X', + 'slug': 'test-site-x', + 'region': self.region2.pk, + } + + url = reverse('dcim-api:site-detail', kwargs={'pk': self.site1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Site.objects.count(), 3) + site1 = Site.objects.get(pk=response.data['id']) + self.assertEqual(site1.name, data['name']) + self.assertEqual(site1.slug, data['slug']) + self.assertEqual(site1.region_id, data['region']) + + def test_delete_site(self): + + url = reverse('dcim-api:site-detail', kwargs={'pk': self.site1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Site.objects.count(), 2) + + +class RackGroupTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') + self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') + self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1') + self.rackgroup2 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 2', slug='test-rack-group-2') + self.rackgroup3 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 3', slug='test-rack-group-3') + + def test_get_rackgroup(self): + + url = reverse('dcim-api:rackgroup-detail', kwargs={'pk': self.rackgroup1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.rackgroup1.name) + + def test_list_rackgroups(self): + + url = reverse('dcim-api:rackgroup-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_rackgroup(self): + + data = { + 'name': 'Test Rack Group 4', + 'slug': 'test-rack-group-4', + 'site': self.site1.pk, + } + + url = reverse('dcim-api:rackgroup-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(RackGroup.objects.count(), 4) + rackgroup4 = RackGroup.objects.get(pk=response.data['id']) + self.assertEqual(rackgroup4.name, data['name']) + self.assertEqual(rackgroup4.slug, data['slug']) + self.assertEqual(rackgroup4.site_id, data['site']) + + def test_update_rackgroup(self): + + data = { + 'name': 'Test Rack Group X', + 'slug': 'test-rack-group-x', + 'site': self.site2.pk, + } + + url = reverse('dcim-api:rackgroup-detail', kwargs={'pk': self.rackgroup1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(RackGroup.objects.count(), 3) + rackgroup1 = RackGroup.objects.get(pk=response.data['id']) + self.assertEqual(rackgroup1.name, data['name']) + self.assertEqual(rackgroup1.slug, data['slug']) + self.assertEqual(rackgroup1.site_id, data['site']) + + def test_delete_rackgroup(self): + + url = reverse('dcim-api:rackgroup-detail', kwargs={'pk': self.rackgroup1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(RackGroup.objects.count(), 2) + + +class RackRoleTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000') + self.rackrole2 = RackRole.objects.create(name='Test Rack Role 2', slug='test-rack-role-2', color='00ff00') + self.rackrole3 = RackRole.objects.create(name='Test Rack Role 3', slug='test-rack-role-3', color='0000ff') + + def test_get_rackrole(self): + + url = reverse('dcim-api:rackrole-detail', kwargs={'pk': self.rackrole1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.rackrole1.name) + + def test_list_rackroles(self): + + url = reverse('dcim-api:rackrole-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_rackrole(self): + + data = { + 'name': 'Test Rack Role 4', + 'slug': 'test-rack-role-4', + 'color': 'ffff00', + } + + url = reverse('dcim-api:rackrole-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(RackRole.objects.count(), 4) + rackrole1 = RackRole.objects.get(pk=response.data['id']) + self.assertEqual(rackrole1.name, data['name']) + self.assertEqual(rackrole1.slug, data['slug']) + self.assertEqual(rackrole1.color, data['color']) + + def test_update_rackrole(self): + + data = { + 'name': 'Test Rack Role X', + 'slug': 'test-rack-role-x', + 'color': 'ffff00', + } + + url = reverse('dcim-api:rackrole-detail', kwargs={'pk': self.rackrole1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(RackRole.objects.count(), 3) + rackrole1 = RackRole.objects.get(pk=response.data['id']) + self.assertEqual(rackrole1.name, data['name']) + self.assertEqual(rackrole1.slug, data['slug']) + self.assertEqual(rackrole1.color, data['color']) + + def test_delete_rackrole(self): + + url = reverse('dcim-api:rackrole-detail', kwargs={'pk': self.rackrole1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(RackRole.objects.count(), 2) + + +class RackTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') + self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') + self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1') + self.rackgroup2 = RackGroup.objects.create(site=self.site2, name='Test Rack Group 2', slug='test-rack-group-2') + self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000') + self.rackrole2 = RackRole.objects.create(name='Test Rack Role 2', slug='test-rack-role-2', color='00ff00') + self.rack1 = Rack.objects.create( + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1' + ) + self.rack2 = Rack.objects.create( + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2' + ) + self.rack3 = Rack.objects.create( + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 3' + ) + + def test_get_rack(self): + + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.rack1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.rack1.name) + + def test_list_racks(self): + + url = reverse('dcim-api:rack-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_rack(self): + + data = { + 'name': 'Test Rack 4', + 'site': self.site1.pk, + 'group': self.rackgroup1.pk, + 'role': self.rackrole1.pk, + } + + url = reverse('dcim-api:rack-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Rack.objects.count(), 4) + rack4 = Rack.objects.get(pk=response.data['id']) + self.assertEqual(rack4.name, data['name']) + self.assertEqual(rack4.site_id, data['site']) + self.assertEqual(rack4.group_id, data['group']) + self.assertEqual(rack4.role_id, data['role']) + + def test_update_rack(self): + + data = { + 'name': 'Test Rack X', + 'site': self.site2.pk, + 'group': self.rackgroup2.pk, + 'role': self.rackrole2.pk, + } + + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.rack1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Rack.objects.count(), 3) + rack1 = Rack.objects.get(pk=response.data['id']) + self.assertEqual(rack1.name, data['name']) + self.assertEqual(rack1.site_id, data['site']) + self.assertEqual(rack1.group_id, data['group']) + self.assertEqual(rack1.role_id, data['role']) + + def test_delete_rack(self): + + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.rack1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Rack.objects.count(), 2) + + +class RackReservationTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.user1 = user + self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') + self.rack1 = Rack.objects.create(site=self.site1, name='Test Rack 1') + self.rackreservation1 = RackReservation.objects.create( + rack=self.rack1, units=[1, 2, 3], user=user, description='First reservation', + ) + self.rackreservation2 = RackReservation.objects.create( + rack=self.rack1, units=[4, 5, 6], user=user, description='Second reservation', + ) + self.rackreservation3 = RackReservation.objects.create( + rack=self.rack1, units=[7, 8, 9], user=user, description='Third reservation', + ) + + def test_get_rackreservation(self): + + url = reverse('dcim-api:rackreservation-detail', kwargs={'pk': self.rackreservation1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['id'], self.rackreservation1.pk) + + def test_list_rackreservations(self): + + url = reverse('dcim-api:rackreservation-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_rackreservation(self): + + data = { + 'rack': self.rack1.pk, + 'units': [10, 11, 12], + 'user': self.user1.pk, + 'description': 'Fourth reservation', + } + + url = reverse('dcim-api:rackreservation-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(RackReservation.objects.count(), 4) + rackreservation4 = RackReservation.objects.get(pk=response.data['id']) + self.assertEqual(rackreservation4.rack_id, data['rack']) + self.assertEqual(rackreservation4.units, data['units']) + self.assertEqual(rackreservation4.user_id, data['user']) + self.assertEqual(rackreservation4.description, data['description']) + + def test_update_rackreservation(self): + + data = { + 'rack': self.rack1.pk, + 'units': [10, 11, 12], + 'user': self.user1.pk, + 'description': 'Modified reservation', + } + + url = reverse('dcim-api:rackreservation-detail', kwargs={'pk': self.rackreservation1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(RackReservation.objects.count(), 3) + rackreservation1 = RackReservation.objects.get(pk=response.data['id']) + self.assertEqual(rackreservation1.units, data['units']) + self.assertEqual(rackreservation1.description, data['description']) + + def test_delete_rackreservation(self): + + url = reverse('dcim-api:rackreservation-detail', kwargs={'pk': self.rackreservation1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(RackReservation.objects.count(), 2) + + +class ManufacturerTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.manufacturer1 = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.manufacturer2 = Manufacturer.objects.create(name='Test Manufacturer 2', slug='test-manufacturer-2') + self.manufacturer3 = Manufacturer.objects.create(name='Test Manufacturer 3', slug='test-manufacturer-3') + + def test_get_manufacturer(self): + + url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': self.manufacturer1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.manufacturer1.name) + + def test_list_manufacturers(self): + + url = reverse('dcim-api:manufacturer-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_manufacturer(self): + + data = { + 'name': 'Test Manufacturer 4', + 'slug': 'test-manufacturer-4', + } + + url = reverse('dcim-api:manufacturer-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Manufacturer.objects.count(), 4) + manufacturer4 = Manufacturer.objects.get(pk=response.data['id']) + self.assertEqual(manufacturer4.name, data['name']) + self.assertEqual(manufacturer4.slug, data['slug']) + + def test_update_manufacturer(self): + + data = { + 'name': 'Test Manufacturer X', + 'slug': 'test-manufacturer-x', + } + + url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': self.manufacturer1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Manufacturer.objects.count(), 3) + manufacturer1 = Manufacturer.objects.get(pk=response.data['id']) + self.assertEqual(manufacturer1.name, data['name']) + self.assertEqual(manufacturer1.slug, data['slug']) + + def test_delete_manufacturer(self): + + url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': self.manufacturer1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Manufacturer.objects.count(), 2) + + +class DeviceTypeTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.manufacturer1 = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.manufacturer2 = Manufacturer.objects.create(name='Test Manufacturer 2', slug='test-manufacturer-2') + self.devicetype1 = DeviceType.objects.create( + manufacturer=self.manufacturer1, model='Test Device Type 1', slug='test-device-type-1' + ) + self.devicetype2 = DeviceType.objects.create( + manufacturer=self.manufacturer1, model='Test Device Type 2', slug='test-device-type-2' + ) + self.devicetype3 = DeviceType.objects.create( + manufacturer=self.manufacturer1, model='Test Device Type 3', slug='test-device-type-3' + ) + + def test_get_devicetype(self): + + url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.devicetype1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['model'], self.devicetype1.model) + + def test_list_devicetypes(self): + + url = reverse('dcim-api:devicetype-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_devicetype(self): + + data = { + 'manufacturer': self.manufacturer1.pk, + 'model': 'Test Device Type 4', + 'slug': 'test-device-type-4', + } + + url = reverse('dcim-api:devicetype-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(DeviceType.objects.count(), 4) + devicetype4 = DeviceType.objects.get(pk=response.data['id']) + self.assertEqual(devicetype4.manufacturer_id, data['manufacturer']) + self.assertEqual(devicetype4.model, data['model']) + self.assertEqual(devicetype4.slug, data['slug']) + + def test_update_devicetype(self): + + data = { + 'manufacturer': self.manufacturer2.pk, + 'model': 'Test Device Type X', + 'slug': 'test-device-type-x', + } + + url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.devicetype1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(DeviceType.objects.count(), 3) + devicetype1 = DeviceType.objects.get(pk=response.data['id']) + self.assertEqual(devicetype1.manufacturer_id, data['manufacturer']) + self.assertEqual(devicetype1.model, data['model']) + self.assertEqual(devicetype1.slug, data['slug']) + + def test_delete_devicetype(self): + + url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.devicetype1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(DeviceType.objects.count(), 2) + + +class ConsolePortTemplateTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.consoleporttemplate1 = ConsolePortTemplate.objects.create( + device_type=self.devicetype, name='Test CP Template 1' + ) + self.consoleporttemplate2 = ConsolePortTemplate.objects.create( + device_type=self.devicetype, name='Test CP Template 2' + ) + self.consoleporttemplate3 = ConsolePortTemplate.objects.create( + device_type=self.devicetype, name='Test CP Template 3' + ) + + def test_get_consoleporttemplate(self): + + url = reverse('dcim-api:consoleporttemplate-detail', kwargs={'pk': self.consoleporttemplate1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.consoleporttemplate1.name) + + def test_list_consoleporttemplates(self): + + url = reverse('dcim-api:consoleporttemplate-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_consoleporttemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test CP Template 4', + } + + url = reverse('dcim-api:consoleporttemplate-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(ConsolePortTemplate.objects.count(), 4) + consoleporttemplate4 = ConsolePortTemplate.objects.get(pk=response.data['id']) + self.assertEqual(consoleporttemplate4.device_type_id, data['device_type']) + self.assertEqual(consoleporttemplate4.name, data['name']) + + def test_update_consoleporttemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test CP Template X', + } + + url = reverse('dcim-api:consoleporttemplate-detail', kwargs={'pk': self.consoleporttemplate1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(ConsolePortTemplate.objects.count(), 3) + consoleporttemplate1 = ConsolePortTemplate.objects.get(pk=response.data['id']) + self.assertEqual(consoleporttemplate1.name, data['name']) + + def test_delete_consoleporttemplate(self): + + url = reverse('dcim-api:consoleporttemplate-detail', kwargs={'pk': self.consoleporttemplate1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(ConsolePortTemplate.objects.count(), 2) + + +class ConsoleServerPortTemplateTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.consoleserverporttemplate1 = ConsoleServerPortTemplate.objects.create( + device_type=self.devicetype, name='Test CSP Template 1' + ) + self.consoleserverporttemplate2 = ConsoleServerPortTemplate.objects.create( + device_type=self.devicetype, name='Test CSP Template 2' + ) + self.consoleserverporttemplate3 = ConsoleServerPortTemplate.objects.create( + device_type=self.devicetype, name='Test CSP Template 3' + ) + + def test_get_consoleserverporttemplate(self): + + url = reverse('dcim-api:consoleserverporttemplate-detail', kwargs={'pk': self.consoleserverporttemplate1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.consoleserverporttemplate1.name) + + def test_list_consoleserverporttemplates(self): + + url = reverse('dcim-api:consoleserverporttemplate-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_consoleserverporttemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test CSP Template 4', + } + + url = reverse('dcim-api:consoleserverporttemplate-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(ConsoleServerPortTemplate.objects.count(), 4) + consoleserverporttemplate4 = ConsoleServerPortTemplate.objects.get(pk=response.data['id']) + self.assertEqual(consoleserverporttemplate4.device_type_id, data['device_type']) + self.assertEqual(consoleserverporttemplate4.name, data['name']) + + def test_update_consoleserverporttemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test CSP Template X', + } + + url = reverse('dcim-api:consoleserverporttemplate-detail', kwargs={'pk': self.consoleserverporttemplate1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(ConsoleServerPortTemplate.objects.count(), 3) + consoleserverporttemplate1 = ConsoleServerPortTemplate.objects.get(pk=response.data['id']) + self.assertEqual(consoleserverporttemplate1.name, data['name']) + + def test_delete_consoleserverporttemplate(self): + + url = reverse('dcim-api:consoleserverporttemplate-detail', kwargs={'pk': self.consoleserverporttemplate1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(ConsoleServerPortTemplate.objects.count(), 2) + + +class PowerPortTemplateTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.powerporttemplate1 = PowerPortTemplate.objects.create( + device_type=self.devicetype, name='Test PP Template 1' + ) + self.powerporttemplate2 = PowerPortTemplate.objects.create( + device_type=self.devicetype, name='Test PP Template 2' + ) + self.powerporttemplate3 = PowerPortTemplate.objects.create( + device_type=self.devicetype, name='Test PP Template 3' + ) + + def test_get_powerporttemplate(self): + + url = reverse('dcim-api:powerporttemplate-detail', kwargs={'pk': self.powerporttemplate1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.powerporttemplate1.name) + + def test_list_powerporttemplates(self): + + url = reverse('dcim-api:powerporttemplate-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_powerporttemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test PP Template 4', + } + + url = reverse('dcim-api:powerporttemplate-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(PowerPortTemplate.objects.count(), 4) + powerporttemplate4 = PowerPortTemplate.objects.get(pk=response.data['id']) + self.assertEqual(powerporttemplate4.device_type_id, data['device_type']) + self.assertEqual(powerporttemplate4.name, data['name']) + + def test_update_powerporttemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test PP Template X', + } + + url = reverse('dcim-api:powerporttemplate-detail', kwargs={'pk': self.powerporttemplate1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(PowerPortTemplate.objects.count(), 3) + powerporttemplate1 = PowerPortTemplate.objects.get(pk=response.data['id']) + self.assertEqual(powerporttemplate1.name, data['name']) + + def test_delete_powerporttemplate(self): + + url = reverse('dcim-api:powerporttemplate-detail', kwargs={'pk': self.powerporttemplate1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(PowerPortTemplate.objects.count(), 2) + + +class PowerOutletTemplateTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.poweroutlettemplate1 = PowerOutletTemplate.objects.create( + device_type=self.devicetype, name='Test PO Template 1' + ) + self.poweroutlettemplate2 = PowerOutletTemplate.objects.create( + device_type=self.devicetype, name='Test PO Template 2' + ) + self.poweroutlettemplate3 = PowerOutletTemplate.objects.create( + device_type=self.devicetype, name='Test PO Template 3' + ) + + def test_get_poweroutlettemplate(self): + + url = reverse('dcim-api:poweroutlettemplate-detail', kwargs={'pk': self.poweroutlettemplate1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.poweroutlettemplate1.name) + + def test_list_poweroutlettemplates(self): + + url = reverse('dcim-api:poweroutlettemplate-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_poweroutlettemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test PO Template 4', + } + + url = reverse('dcim-api:poweroutlettemplate-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(PowerOutletTemplate.objects.count(), 4) + poweroutlettemplate4 = PowerOutletTemplate.objects.get(pk=response.data['id']) + self.assertEqual(poweroutlettemplate4.device_type_id, data['device_type']) + self.assertEqual(poweroutlettemplate4.name, data['name']) + + def test_update_poweroutlettemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test PO Template X', + } + + url = reverse('dcim-api:poweroutlettemplate-detail', kwargs={'pk': self.poweroutlettemplate1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(PowerOutletTemplate.objects.count(), 3) + poweroutlettemplate1 = PowerOutletTemplate.objects.get(pk=response.data['id']) + self.assertEqual(poweroutlettemplate1.name, data['name']) + + def test_delete_poweroutlettemplate(self): + + url = reverse('dcim-api:poweroutlettemplate-detail', kwargs={'pk': self.poweroutlettemplate1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(PowerOutletTemplate.objects.count(), 2) + + +class InterfaceTemplateTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.interfacetemplate1 = InterfaceTemplate.objects.create( + device_type=self.devicetype, name='Test Interface Template 1' + ) + self.interfacetemplate2 = InterfaceTemplate.objects.create( + device_type=self.devicetype, name='Test Interface Template 2' + ) + self.interfacetemplate3 = InterfaceTemplate.objects.create( + device_type=self.devicetype, name='Test Interface Template 3' + ) + + def test_get_interfacetemplate(self): + + url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.interfacetemplate1.name) + + def test_list_interfacetemplates(self): + + url = reverse('dcim-api:interfacetemplate-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_interfacetemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test Interface Template 4', + } + + url = reverse('dcim-api:interfacetemplate-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(InterfaceTemplate.objects.count(), 4) + interfacetemplate4 = InterfaceTemplate.objects.get(pk=response.data['id']) + self.assertEqual(interfacetemplate4.device_type_id, data['device_type']) + self.assertEqual(interfacetemplate4.name, data['name']) + + def test_update_interfacetemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test Interface Template X', + } + + url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(InterfaceTemplate.objects.count(), 3) + interfacetemplate1 = InterfaceTemplate.objects.get(pk=response.data['id']) + self.assertEqual(interfacetemplate1.name, data['name']) + + def test_delete_interfacetemplate(self): + + url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(InterfaceTemplate.objects.count(), 2) + + +class DeviceBayTemplateTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.devicebaytemplate1 = DeviceBayTemplate.objects.create( + device_type=self.devicetype, name='Test Device Bay Template 1' + ) + self.devicebaytemplate2 = DeviceBayTemplate.objects.create( + device_type=self.devicetype, name='Test Device Bay Template 2' + ) + self.devicebaytemplate3 = DeviceBayTemplate.objects.create( + device_type=self.devicetype, name='Test Device Bay Template 3' + ) + + def test_get_devicebaytemplate(self): + + url = reverse('dcim-api:devicebaytemplate-detail', kwargs={'pk': self.devicebaytemplate1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.devicebaytemplate1.name) + + def test_list_devicebaytemplates(self): + + url = reverse('dcim-api:devicebaytemplate-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_devicebaytemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test Device Bay Template 4', + } + + url = reverse('dcim-api:devicebaytemplate-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(DeviceBayTemplate.objects.count(), 4) + devicebaytemplate4 = DeviceBayTemplate.objects.get(pk=response.data['id']) + self.assertEqual(devicebaytemplate4.device_type_id, data['device_type']) + self.assertEqual(devicebaytemplate4.name, data['name']) + + def test_update_devicebaytemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test Device Bay Template X', + } + + url = reverse('dcim-api:devicebaytemplate-detail', kwargs={'pk': self.devicebaytemplate1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(DeviceBayTemplate.objects.count(), 3) + devicebaytemplate1 = DeviceBayTemplate.objects.get(pk=response.data['id']) + self.assertEqual(devicebaytemplate1.name, data['name']) + + def test_delete_devicebaytemplate(self): + + url = reverse('dcim-api:devicebaytemplate-detail', kwargs={'pk': self.devicebaytemplate1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(DeviceBayTemplate.objects.count(), 2) diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py deleted file mode 100644 index f4d03c633..000000000 --- a/netbox/dcim/tests/test_apis.py +++ /dev/null @@ -1,661 +0,0 @@ -import json -from rest_framework import status -from rest_framework.test import APITestCase - -from django.conf import settings - - -class SiteTest(APITestCase): - - fixtures = [ - 'dcim', - 'ipam', - 'extras', - ] - - standard_fields = [ - 'id', - 'name', - 'slug', - 'region', - 'tenant', - 'facility', - 'asn', - 'physical_address', - 'shipping_address', - 'contact_name', - 'contact_phone', - 'contact_email', - 'comments', - 'custom_fields', - 'count_prefixes', - 'count_vlans', - 'count_racks', - 'count_devices', - 'count_circuits' - ] - - nested_fields = [ - 'id', - 'name', - 'slug' - ] - - rack_fields = [ - 'id', - 'name', - 'facility_id', - 'display_name', - 'site', - 'group', - 'tenant', - 'role', - 'type', - 'width', - 'u_height', - 'desc_units', - 'comments', - 'custom_fields', - ] - - graph_fields = [ - 'name', - 'embed_url', - 'embed_link', - ] - - def test_get_list(self, endpoint='/{}api/dcim/sites/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for i in content: - self.assertEqual( - sorted(i.keys()), - sorted(self.standard_fields), - ) - - def test_get_detail(self, endpoint='/{}api/dcim/sites/1/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - sorted(content.keys()), - sorted(self.standard_fields), - ) - - def test_get_site_list_graphs(self, endpoint='/{}api/dcim/sites/1/graphs/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for i in json.loads(response.content.decode('utf-8')): - self.assertEqual( - sorted(i.keys()), - sorted(self.graph_fields), - ) - - -class RackTest(APITestCase): - fixtures = [ - 'dcim', - 'ipam' - ] - - nested_fields = [ - 'id', - 'name', - 'facility_id', - 'display_name' - ] - - standard_fields = [ - 'id', - 'name', - 'facility_id', - 'display_name', - 'site', - 'group', - 'tenant', - 'role', - 'type', - 'width', - 'u_height', - 'desc_units', - 'comments', - 'custom_fields', - ] - - detail_fields = [ - 'id', - 'name', - 'facility_id', - 'display_name', - 'site', - 'group', - 'tenant', - 'role', - 'type', - 'width', - 'u_height', - 'desc_units', - 'reservations', - 'comments', - 'custom_fields', - 'front_units', - 'rear_units' - ] - - def test_get_list(self, endpoint='/{}api/dcim/racks/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for i in content: - self.assertEqual( - sorted(i.keys()), - sorted(self.standard_fields), - ) - self.assertEqual( - sorted(i.get('site').keys()), - sorted(SiteTest.nested_fields), - ) - - def test_get_detail(self, endpoint='/{}api/dcim/racks/1/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - sorted(content.keys()), - sorted(self.detail_fields), - ) - self.assertEqual( - sorted(content.get('site').keys()), - sorted(SiteTest.nested_fields), - ) - - -class ManufacturersTest(APITestCase): - - fixtures = [ - 'dcim', - 'ipam' - ] - - standard_fields = [ - 'id', - 'name', - 'slug', - ] - - nested_fields = standard_fields - - def test_get_list(self, endpoint='/{}api/dcim/manufacturers/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for i in content: - self.assertEqual( - sorted(i.keys()), - sorted(self.standard_fields), - ) - - def test_get_detail(self, endpoint='/{}api/dcim/manufacturers/1/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - sorted(content.keys()), - sorted(self.standard_fields), - ) - - -class DeviceTypeTest(APITestCase): - - fixtures = ['dcim', 'ipam'] - - standard_fields = [ - 'id', - 'manufacturer', - 'model', - 'slug', - 'part_number', - 'u_height', - 'is_full_depth', - 'interface_ordering', - 'is_console_server', - 'is_pdu', - 'is_network_device', - 'subdevice_role', - 'comments', - 'custom_fields', - 'instance_count', - ] - - nested_fields = [ - 'id', - 'manufacturer', - 'model', - 'slug' - ] - - def test_get_list(self, endpoint='/{}api/dcim/device-types/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for i in content: - self.assertEqual( - sorted(i.keys()), - sorted(self.standard_fields), - ) - - def test_detail_list(self, endpoint='/{}api/dcim/device-types/1/'.format(settings.BASE_PATH)): - # TODO: details returns list view. - # response = self.client.get(endpoint) - # content = json.loads(response.content.decode('utf-8')) - # self.assertEqual(response.status_code, status.HTTP_200_OK) - # self.assertEqual( - # sorted(content.keys()), - # sorted(self.standard_fields), - # ) - # self.assertEqual( - # sorted(content.get('manufacturer').keys()), - # sorted(ManufacturersTest.nested_fields), - # ) - pass - - -class DeviceRolesTest(APITestCase): - - fixtures = ['dcim', 'ipam'] - - standard_fields = ['id', 'name', 'slug', 'color'] - - nested_fields = ['id', 'name', 'slug'] - - def test_get_list(self, endpoint='/{}api/dcim/device-roles/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for i in content: - self.assertEqual( - sorted(i.keys()), - sorted(self.standard_fields), - ) - - def test_get_detail(self, endpoint='/{}api/dcim/device-roles/1/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - sorted(content.keys()), - sorted(self.standard_fields), - ) - - -class PlatformsTest(APITestCase): - - fixtures = ['dcim', 'ipam'] - - standard_fields = ['id', 'name', 'slug', 'rpc_client'] - - nested_fields = ['id', 'name', 'slug'] - - def test_get_list(self, endpoint='/{}api/dcim/platforms/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for i in content: - self.assertEqual( - sorted(i.keys()), - sorted(self.standard_fields), - ) - - def test_get_detail(self, endpoint='/{}api/dcim/platforms/1/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - sorted(content.keys()), - sorted(self.standard_fields), - ) - - -class DeviceTest(APITestCase): - - fixtures = ['dcim', 'ipam'] - - standard_fields = [ - 'id', - 'name', - 'display_name', - 'device_type', - 'device_role', - 'tenant', - 'platform', - 'serial', - 'asset_tag', - 'site', - 'rack', - 'position', - 'face', - 'parent_device', - 'status', - 'primary_ip', - 'primary_ip4', - 'primary_ip6', - 'comments', - 'custom_fields', - ] - - nested_fields = ['id', 'name', 'display_name'] - - def test_get_list(self, endpoint='/{}api/dcim/devices/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for device in content: - self.assertEqual( - sorted(device.keys()), - sorted(self.standard_fields), - ) - self.assertEqual( - sorted(device.get('device_type')), - sorted(DeviceTypeTest.nested_fields), - ) - self.assertEqual( - sorted(device.get('device_role')), - sorted(DeviceRolesTest.nested_fields), - ) - if device.get('platform'): - self.assertEqual( - sorted(device.get('platform')), - sorted(PlatformsTest.nested_fields), - ) - self.assertEqual( - sorted(device.get('rack')), - sorted(RackTest.nested_fields), - ) - - def test_get_list_flat(self, endpoint='/{}api/dcim/devices/?format=json_flat'.format(settings.BASE_PATH)): - - flat_fields = [ - 'asset_tag', - 'comments', - 'device_role_id', - 'device_role_name', - 'device_role_slug', - 'device_type_id', - 'device_type_manufacturer_id', - 'device_type_manufacturer_name', - 'device_type_manufacturer_slug', - 'device_type_model', - 'device_type_slug', - 'display_name', - 'face', - 'id', - 'name', - 'parent_device', - 'platform_id', - 'platform_name', - 'platform_slug', - 'position', - 'primary_ip_address', - 'primary_ip_family', - 'primary_ip_id', - 'primary_ip4_address', - 'primary_ip4_family', - 'primary_ip4_id', - 'primary_ip6', - 'site_id', - 'site_name', - 'site_slug', - 'rack_display_name', - 'rack_facility_id', - 'rack_id', - 'rack_name', - 'serial', - 'status', - 'tenant', - ] - - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - device = content[0] - self.assertEqual( - sorted(device.keys()), - sorted(flat_fields), - ) - - def test_get_detail(self, endpoint='/{}api/dcim/devices/1/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - sorted(content.keys()), - sorted(self.standard_fields), - ) - - -class ConsoleServerPortsTest(APITestCase): - - fixtures = ['dcim', 'ipam'] - - standard_fields = ['id', 'device', 'name', 'connected_console'] - - nested_fields = ['id', 'device', 'name'] - - def test_get_list(self, endpoint='/{}api/dcim/devices/9/console-server-ports/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for console_port in content: - self.assertEqual( - sorted(console_port.keys()), - sorted(self.standard_fields), - ) - self.assertEqual( - sorted(console_port.get('device')), - sorted(DeviceTest.nested_fields), - ) - - -class ConsolePortsTest(APITestCase): - fixtures = ['dcim', 'ipam'] - - standard_fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] - - nested_fields = ['id', 'device', 'name'] - - def test_get_list(self, endpoint='/{}api/dcim/devices/1/console-ports/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for console_port in content: - self.assertEqual( - sorted(console_port.keys()), - sorted(self.standard_fields), - ) - self.assertEqual( - sorted(console_port.get('device')), - sorted(DeviceTest.nested_fields), - ) - self.assertEqual( - sorted(console_port.get('cs_port')), - sorted(ConsoleServerPortsTest.nested_fields), - ) - - def test_get_detail(self, endpoint='/{}api/dcim/console-ports/1/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - sorted(content.keys()), - sorted(self.standard_fields), - ) - self.assertEqual( - sorted(content.get('device')), - sorted(DeviceTest.nested_fields), - ) - - -class PowerPortsTest(APITestCase): - fixtures = ['dcim', 'ipam'] - - standard_fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] - - nested_fields = ['id', 'device', 'name'] - - def test_get_list(self, endpoint='/{}api/dcim/devices/1/power-ports/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for i in content: - self.assertEqual( - sorted(i.keys()), - sorted(self.standard_fields), - ) - self.assertEqual( - sorted(i.get('device')), - sorted(DeviceTest.nested_fields), - ) - - def test_get_detail(self, endpoint='/{}api/dcim/power-ports/1/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - sorted(content.keys()), - sorted(self.standard_fields), - ) - self.assertEqual( - sorted(content.get('device')), - sorted(DeviceTest.nested_fields), - ) - - -class PowerOutletsTest(APITestCase): - fixtures = ['dcim', 'ipam'] - - standard_fields = ['id', 'device', 'name', 'connected_port'] - - nested_fields = ['id', 'device', 'name'] - - def test_get_list(self, endpoint='/{}api/dcim/devices/11/power-outlets/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for i in content: - self.assertEqual( - sorted(i.keys()), - sorted(self.standard_fields), - ) - self.assertEqual( - sorted(i.get('device')), - sorted(DeviceTest.nested_fields), - ) - - -class InterfaceTest(APITestCase): - fixtures = ['dcim', 'ipam', 'extras'] - - standard_fields = [ - 'id', - 'device', - 'name', - 'form_factor', - 'lag', - 'mac_address', - 'mgmt_only', - 'description', - 'is_connected' - ] - - nested_fields = ['id', 'device', 'name'] - - detail_fields = [ - 'id', - 'device', - 'name', - 'form_factor', - 'lag', - 'mac_address', - 'mgmt_only', - 'description', - 'is_connected', - 'connected_interface' - ] - - connection_fields = [ - 'id', - 'interface_a', - 'interface_b', - 'connection_status', - ] - - def test_get_list(self, endpoint='/{}api/dcim/devices/1/interfaces/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for i in content: - self.assertEqual( - sorted(i.keys()), - sorted(self.standard_fields), - ) - self.assertEqual( - sorted(i.get('device')), - sorted(DeviceTest.nested_fields), - ) - - def test_get_detail(self, endpoint='/{}api/dcim/interfaces/1/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - sorted(content.keys()), - sorted(self.detail_fields), - ) - self.assertEqual( - sorted(content.get('device')), - sorted(DeviceTest.nested_fields), - ) - - def test_get_graph_list(self, endpoint='/{}api/dcim/interfaces/1/graphs/'.format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for i in content: - self.assertEqual( - sorted(i.keys()), - sorted(SiteTest.graph_fields), - ) - - def test_get_interface_connections(self, endpoint='/{}api/dcim/interface-connections/4/' - .format(settings.BASE_PATH)): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - sorted(content.keys()), - sorted(self.connection_fields), - ) - - -class RelatedConnectionsTest(APITestCase): - - fixtures = ['dcim', 'ipam'] - - standard_fields = [ - 'device', - 'console-ports', - 'power-ports', - 'interfaces', - ] - - def test_get_list(self, endpoint=('/{}api/dcim/related-connections/?peer-device=test1-edge1&peer-interface=xe-0/0/3' - .format(settings.BASE_PATH))): - response = self.client.get(endpoint) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - sorted(content.keys()), - sorted(self.standard_fields), - ) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index dc305c5d8..b4da922c5 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -62,7 +62,14 @@ class ChoiceFieldSerializer(Field): Represent a ChoiceField as (value, label). """ def __init__(self, choices, **kwargs): - self._choices = {k: v for k, v in choices} + self._choices = dict() + for k, v in choices: + # Unpack grouped choices + if type(v) in [list, tuple]: + for k2, v2 in v: + self._choices[k2] = v2 + else: + self._choices[k] = v super(ChoiceFieldSerializer, self).__init__(**kwargs) def to_representation(self, obj): From be2faaa110fc0cafcfa20e57e50e5e4979eee69b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Mar 2017 17:25:34 -0400 Subject: [PATCH 071/182] Fixed bug interpreting facility_id as a required field --- netbox/dcim/api/serializers.py | 16 ++++++++++++++++ netbox/dcim/tests/test_api.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 0f3ff85ab..1abac4e12 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from rest_framework.validators import UniqueTogetherValidator from ipam.models import IPAddress from dcim.models import ( @@ -157,6 +158,21 @@ class WritableRackSerializer(serializers.ModelSerializer): 'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', 'comments', ] + # Omit the UniqueTogetherValidator that would be automatically added to validate facility_id. This prevents + # facility_id from being interpreted as a required field. + validators = [ + UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('site', 'name')) + ] + + def validate(self, data): + + # Validate uniqueness of facility_id (if set) since we omitted the automatically-created validator from Meta. + if data.get('facility_id', None): + validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('site', 'facility_id')) + validator.set_context(self) + validator(data) + + return data # diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index d2140d0fa..93eeab25c 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -310,7 +310,7 @@ class RackTest(APITestCase): self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000') self.rackrole2 = RackRole.objects.create(name='Test Rack Role 2', slug='test-rack-role-2', color='00ff00') self.rack1 = Rack.objects.create( - site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1' + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', ) self.rack2 = Rack.objects.create( site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2' From abdfc5c5972606e6ea6f8b0480b654113ab797c6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Mar 2017 12:16:24 -0400 Subject: [PATCH 072/182] Finished DCIM API model tests --- netbox/dcim/api/serializers.py | 29 +- netbox/dcim/tests/test_api.py | 917 ++++++++++++++++++++++++++++++++- 2 files changed, 936 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1abac4e12..9e19d0bb7 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -158,15 +158,15 @@ class WritableRackSerializer(serializers.ModelSerializer): 'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', 'comments', ] - # Omit the UniqueTogetherValidator that would be automatically added to validate facility_id. This prevents - # facility_id from being interpreted as a required field. + # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This + # prevents facility_id from being interpreted as a required field. validators = [ UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('site', 'name')) ] def validate(self, data): - # Validate uniqueness of facility_id (if set) since we omitted the automatically-created validator from Meta. + # Validate uniqueness of (site, facility_id) since we omitted the automatically-created validator from Meta. if data.get('facility_id', None): validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('site', 'facility_id')) validator.set_context(self) @@ -467,9 +467,20 @@ class WritableDeviceSerializer(serializers.ModelSerializer): class Meta: model = Device fields = [ - 'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'rack', 'position', - 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', + 'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', + 'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', ] + validators = [] + + def validate(self, data): + + # Validate uniqueness of (rack, position, face) since we omitted the automatically-created validator from Meta. + if data.get('rack') and data.get('position') and data.get('face'): + validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('rack', 'position', 'face')) + validator.set_context(self) + validator(data) + + return data # @@ -482,13 +493,14 @@ class ConsoleServerPortSerializer(serializers.ModelSerializer): class Meta: model = ConsoleServerPort fields = ['id', 'device', 'name', 'connected_console'] + read_only_fields = ['connected_console'] class WritableConsoleServerPortSerializer(serializers.ModelSerializer): class Meta: model = ConsoleServerPort - fields = ['id', 'device', 'name', 'connected_console'] + fields = ['id', 'device', 'name'] # @@ -521,13 +533,14 @@ class PowerOutletSerializer(serializers.ModelSerializer): class Meta: model = PowerOutlet fields = ['id', 'device', 'name', 'connected_port'] + read_only_fields = ['connected_port'] class WritablePowerOutletSerializer(serializers.ModelSerializer): class Meta: model = PowerOutlet - fields = ['id', 'device', 'name', 'connected_port'] + fields = ['id', 'device', 'name'] # @@ -611,7 +624,7 @@ class WritableDeviceBaySerializer(serializers.ModelSerializer): class Meta: model = DeviceBay - fields = ['id', 'device', 'name'] + fields = ['id', 'device', 'name', 'installed_device'] # diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 93eeab25c..67ecdbe3f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -5,8 +5,10 @@ from django.contrib.auth.models import User from django.urls import reverse from dcim.models import ( - ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, InterfaceTemplate, Manufacturer, - PowerPortTemplate, PowerOutletTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, + ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, InterfaceTemplate, + Manufacturer, Module, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, + RackReservation, RackRole, Region, Site, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, ) from users.models import Token @@ -1064,3 +1066,914 @@ class DeviceBayTemplateTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(DeviceBayTemplate.objects.count(), 2) + + +class DeviceRoleTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.devicerole1 = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.devicerole2 = DeviceRole.objects.create( + name='Test Device Role 2', slug='test-device-role-2', color='00ff00' + ) + self.devicerole3 = DeviceRole.objects.create( + name='Test Device Role 3', slug='test-device-role-3', color='0000ff' + ) + + def test_get_devicerole(self): + + url = reverse('dcim-api:devicerole-detail', kwargs={'pk': self.devicerole1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.devicerole1.name) + + def test_list_deviceroles(self): + + url = reverse('dcim-api:devicerole-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_devicerole(self): + + data = { + 'name': 'Test Device Role 4', + 'slug': 'test-device-role-4', + 'color': 'ffff00', + } + + url = reverse('dcim-api:devicerole-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(DeviceRole.objects.count(), 4) + devicerole4 = DeviceRole.objects.get(pk=response.data['id']) + self.assertEqual(devicerole4.name, data['name']) + self.assertEqual(devicerole4.slug, data['slug']) + self.assertEqual(devicerole4.color, data['color']) + + def test_update_devicerole(self): + + data = { + 'name': 'Test Device Role X', + 'slug': 'test-device-role-x', + 'color': '00ffff', + } + + url = reverse('dcim-api:devicerole-detail', kwargs={'pk': self.devicerole1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(DeviceRole.objects.count(), 3) + devicerole1 = DeviceRole.objects.get(pk=response.data['id']) + self.assertEqual(devicerole1.name, data['name']) + self.assertEqual(devicerole1.slug, data['slug']) + self.assertEqual(devicerole1.color, data['color']) + + def test_delete_devicerole(self): + + url = reverse('dcim-api:devicerole-detail', kwargs={'pk': self.devicerole1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(DeviceRole.objects.count(), 2) + + +class PlatformTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1') + self.platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2') + self.platform3 = Platform.objects.create(name='Test Platform 3', slug='test-platform-3') + + def test_get_platform(self): + + url = reverse('dcim-api:platform-detail', kwargs={'pk': self.platform1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.platform1.name) + + def test_list_platforms(self): + + url = reverse('dcim-api:platform-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_platform(self): + + data = { + 'name': 'Test Platform 4', + 'slug': 'test-platform-4', + } + + url = reverse('dcim-api:platform-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Platform.objects.count(), 4) + platform4 = Platform.objects.get(pk=response.data['id']) + self.assertEqual(platform4.name, data['name']) + self.assertEqual(platform4.slug, data['slug']) + + def test_update_platform(self): + + data = { + 'name': 'Test Platform X', + 'slug': 'test-platform-x', + } + + url = reverse('dcim-api:platform-detail', kwargs={'pk': self.platform1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Platform.objects.count(), 3) + platform1 = Platform.objects.get(pk=response.data['id']) + self.assertEqual(platform1.name, data['name']) + self.assertEqual(platform1.slug, data['slug']) + + def test_delete_platform(self): + + url = reverse('dcim-api:platform-detail', kwargs={'pk': self.platform1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Platform.objects.count(), 2) + + +class DeviceTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') + self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype1 = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.devicetype2 = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 2', slug='test-device-type-2' + ) + self.devicerole1 = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.devicerole2 = DeviceRole.objects.create( + name='Test Device Role 2', slug='test-device-role-2', color='00ff00' + ) + self.device1 = Device.objects.create( + device_type=self.devicetype1, device_role=self.devicerole1, name='Test Device 1', site=self.site1 + ) + self.device2 = Device.objects.create( + device_type=self.devicetype1, device_role=self.devicerole1, name='Test Device 2', site=self.site1 + ) + self.device3 = Device.objects.create( + device_type=self.devicetype1, device_role=self.devicerole1, name='Test Device 3', site=self.site1 + ) + + def test_get_device(self): + + url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.device1.name) + + def test_list_devices(self): + + url = reverse('dcim-api:device-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_device(self): + + data = { + 'device_type': self.devicetype1.pk, + 'device_role': self.devicerole1.pk, + 'name': 'Test Device 4', + 'site': self.site1.pk, + } + + url = reverse('dcim-api:device-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Device.objects.count(), 4) + device4 = Device.objects.get(pk=response.data['id']) + self.assertEqual(device4.device_type_id, data['device_type']) + self.assertEqual(device4.device_role_id, data['device_role']) + self.assertEqual(device4.name, data['name']) + self.assertEqual(device4.site_id, data['site']) + + def test_update_device(self): + + data = { + 'device_type': self.devicetype2.pk, + 'device_role': self.devicerole2.pk, + 'name': 'Test Device X', + 'site': self.site2.pk, + } + + url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Device.objects.count(), 3) + device1 = Device.objects.get(pk=response.data['id']) + self.assertEqual(device1.device_type_id, data['device_type']) + self.assertEqual(device1.device_role_id, data['device_role']) + self.assertEqual(device1.name, data['name']) + self.assertEqual(device1.site_id, data['site']) + + def test_delete_device(self): + + url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Device.objects.count(), 2) + + +class ConsolePortTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.consoleport1 = ConsolePort.objects.create(device=self.device, name='Test Console Port 1') + self.consoleport2 = ConsolePort.objects.create(device=self.device, name='Test Console Port 2') + self.consoleport3 = ConsolePort.objects.create(device=self.device, name='Test Console Port 3') + + def test_get_consoleport(self): + + url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.consoleport1.name) + + def test_list_consoleports(self): + + url = reverse('dcim-api:consoleport-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_consoleport(self): + + data = { + 'device': self.device.pk, + 'name': 'Test Console Port 4', + } + + url = reverse('dcim-api:consoleport-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(ConsolePort.objects.count(), 4) + consoleport4 = ConsolePort.objects.get(pk=response.data['id']) + self.assertEqual(consoleport4.device_id, data['device']) + self.assertEqual(consoleport4.name, data['name']) + + def test_update_consoleport(self): + + consoleserverport = ConsoleServerPort.objects.create(device=self.device, name='Test CS Port 1') + + data = { + 'device': self.device.pk, + 'name': 'Test Console Port X', + 'cs_port': consoleserverport.pk, + } + + url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(ConsolePort.objects.count(), 3) + consoleport1 = ConsolePort.objects.get(pk=response.data['id']) + self.assertEqual(consoleport1.name, data['name']) + self.assertEqual(consoleport1.cs_port_id, data['cs_port']) + + def test_delete_consoleport(self): + + url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(ConsolePort.objects.count(), 2) + + +class ConsoleServerPortTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.consoleserverport1 = ConsoleServerPort.objects.create(device=self.device, name='Test CS Port 1') + self.consoleserverport2 = ConsoleServerPort.objects.create(device=self.device, name='Test CS Port 2') + self.consoleserverport3 = ConsoleServerPort.objects.create(device=self.device, name='Test CS Port 3') + + def test_get_consoleserverport(self): + + url = reverse('dcim-api:consoleserverport-detail', kwargs={'pk': self.consoleserverport1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.consoleserverport1.name) + + def test_list_consoleserverports(self): + + url = reverse('dcim-api:consoleserverport-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_consoleserverport(self): + + data = { + 'device': self.device.pk, + 'name': 'Test CS Port 4', + } + + url = reverse('dcim-api:consoleserverport-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(ConsoleServerPort.objects.count(), 4) + consoleserverport4 = ConsoleServerPort.objects.get(pk=response.data['id']) + self.assertEqual(consoleserverport4.device_id, data['device']) + self.assertEqual(consoleserverport4.name, data['name']) + + def test_update_consoleserverport(self): + + data = { + 'device': self.device.pk, + 'name': 'Test CS Port X', + } + + url = reverse('dcim-api:consoleserverport-detail', kwargs={'pk': self.consoleserverport1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(ConsoleServerPort.objects.count(), 3) + consoleserverport1 = ConsoleServerPort.objects.get(pk=response.data['id']) + self.assertEqual(consoleserverport1.name, data['name']) + + def test_delete_consoleserverport(self): + + url = reverse('dcim-api:consoleserverport-detail', kwargs={'pk': self.consoleserverport1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(ConsoleServerPort.objects.count(), 2) + + +class PowerPortTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.powerport1 = PowerPort.objects.create(device=self.device, name='Test Power Port 1') + self.powerport2 = PowerPort.objects.create(device=self.device, name='Test Power Port 2') + self.powerport3 = PowerPort.objects.create(device=self.device, name='Test Power Port 3') + + def test_get_powerport(self): + + url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.powerport1.name) + + def test_list_powerports(self): + + url = reverse('dcim-api:powerport-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_powerport(self): + + data = { + 'device': self.device.pk, + 'name': 'Test Power Port 4', + } + + url = reverse('dcim-api:powerport-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(PowerPort.objects.count(), 4) + powerport4 = PowerPort.objects.get(pk=response.data['id']) + self.assertEqual(powerport4.device_id, data['device']) + self.assertEqual(powerport4.name, data['name']) + + def test_update_powerport(self): + + poweroutlet = PowerOutlet.objects.create(device=self.device, name='Test Power Outlet 1') + + data = { + 'device': self.device.pk, + 'name': 'Test Power Port X', + 'power_outlet': poweroutlet.pk, + } + + url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(PowerPort.objects.count(), 3) + powerport1 = PowerPort.objects.get(pk=response.data['id']) + self.assertEqual(powerport1.name, data['name']) + self.assertEqual(powerport1.power_outlet_id, data['power_outlet']) + + def test_delete_powerport(self): + + url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(PowerPort.objects.count(), 2) + + +class PowerOutletTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.poweroutlet1 = PowerOutlet.objects.create(device=self.device, name='Test Power Outlet 1') + self.poweroutlet2 = PowerOutlet.objects.create(device=self.device, name='Test Power Outlet 2') + self.poweroutlet3 = PowerOutlet.objects.create(device=self.device, name='Test Power Outlet 3') + + def test_get_poweroutlet(self): + + url = reverse('dcim-api:poweroutlet-detail', kwargs={'pk': self.poweroutlet1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.poweroutlet1.name) + + def test_list_poweroutlets(self): + + url = reverse('dcim-api:poweroutlet-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_poweroutlet(self): + + data = { + 'device': self.device.pk, + 'name': 'Test Power Outlet 4', + } + + url = reverse('dcim-api:poweroutlet-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(PowerOutlet.objects.count(), 4) + poweroutlet4 = PowerOutlet.objects.get(pk=response.data['id']) + self.assertEqual(poweroutlet4.device_id, data['device']) + self.assertEqual(poweroutlet4.name, data['name']) + + def test_update_poweroutlet(self): + + data = { + 'device': self.device.pk, + 'name': 'Test Power Outlet X', + } + + url = reverse('dcim-api:poweroutlet-detail', kwargs={'pk': self.poweroutlet1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(PowerOutlet.objects.count(), 3) + poweroutlet1 = PowerOutlet.objects.get(pk=response.data['id']) + self.assertEqual(poweroutlet1.name, data['name']) + + def test_delete_poweroutlet(self): + + url = reverse('dcim-api:poweroutlet-detail', kwargs={'pk': self.poweroutlet1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(PowerOutlet.objects.count(), 2) + + +class InterfaceTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.interface1 = Interface.objects.create(device=self.device, name='Test Interface 1') + self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2') + self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3') + + def test_get_interface(self): + + url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.interface1.name) + + def test_list_interfaces(self): + + url = reverse('dcim-api:interface-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_interface(self): + + data = { + 'device': self.device.pk, + 'name': 'Test Interface 4', + } + + url = reverse('dcim-api:interface-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Interface.objects.count(), 4) + interface4 = Interface.objects.get(pk=response.data['id']) + self.assertEqual(interface4.device_id, data['device']) + self.assertEqual(interface4.name, data['name']) + + def test_update_interface(self): + + lag_interface = Interface.objects.create( + device=self.device, name='Test LAG Interface', form_factor=IFACE_FF_LAG + ) + + data = { + 'device': self.device.pk, + 'name': 'Test Interface X', + 'lag': lag_interface.pk, + } + + url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Interface.objects.count(), 4) + interface1 = Interface.objects.get(pk=response.data['id']) + self.assertEqual(interface1.name, data['name']) + self.assertEqual(interface1.lag_id, data['lag']) + + def test_delete_interface(self): + + url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Interface.objects.count(), 2) + + +class DeviceBayTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype1 = DeviceType.objects.create( + manufacturer=manufacturer, model='Parent Device Type', slug='parent-device-type', + subdevice_role=SUBDEVICE_ROLE_PARENT + ) + self.devicetype2 = DeviceType.objects.create( + manufacturer=manufacturer, model='Child Device Type', slug='child-device-type', + subdevice_role=SUBDEVICE_ROLE_CHILD + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.parent_device = Device.objects.create( + device_type=self.devicetype1, device_role=devicerole, name='Parent Device 1', site=site + ) + self.child_device = Device.objects.create( + device_type=self.devicetype2, device_role=devicerole, name='Child Device 1', site=site + ) + self.devicebay1 = DeviceBay.objects.create(device=self.parent_device, name='Test Device Bay 1') + self.devicebay2 = DeviceBay.objects.create(device=self.parent_device, name='Test Device Bay 2') + self.devicebay3 = DeviceBay.objects.create(device=self.parent_device, name='Test Device Bay 3') + + def test_get_devicebay(self): + + url = reverse('dcim-api:devicebay-detail', kwargs={'pk': self.devicebay1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.devicebay1.name) + + def test_list_devicebays(self): + + url = reverse('dcim-api:devicebay-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_devicebay(self): + + data = { + 'device': self.parent_device.pk, + 'name': 'Test Device Bay 4', + 'installed_device': self.child_device.pk, + } + + url = reverse('dcim-api:devicebay-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(DeviceBay.objects.count(), 4) + devicebay4 = DeviceBay.objects.get(pk=response.data['id']) + self.assertEqual(devicebay4.device_id, data['device']) + self.assertEqual(devicebay4.name, data['name']) + self.assertEqual(devicebay4.installed_device_id, data['installed_device']) + + def test_update_devicebay(self): + + data = { + 'device': self.parent_device.pk, + 'name': 'Test Device Bay X', + 'installed_device': self.child_device.pk, + } + + url = reverse('dcim-api:devicebay-detail', kwargs={'pk': self.devicebay1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(DeviceBay.objects.count(), 3) + devicebay1 = DeviceBay.objects.get(pk=response.data['id']) + self.assertEqual(devicebay1.name, data['name']) + self.assertEqual(devicebay1.installed_device_id, data['installed_device']) + + def test_delete_devicebay(self): + + url = reverse('dcim-api:devicebay-detail', kwargs={'pk': self.devicebay1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(DeviceBay.objects.count(), 2) + + +class ModuleTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.module1 = Module.objects.create(device=self.device, name='Test Module 1') + self.module2 = Module.objects.create(device=self.device, name='Test Module 2') + self.module3 = Module.objects.create(device=self.device, name='Test Module 3') + + def test_get_module(self): + + url = reverse('dcim-api:module-detail', kwargs={'pk': self.module1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.module1.name) + + def test_list_modules(self): + + url = reverse('dcim-api:module-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_module(self): + + data = { + 'device': self.device.pk, + 'parent': self.module1.pk, + 'name': 'Test Module 4', + 'manufacturer': self.manufacturer.pk, + } + + url = reverse('dcim-api:module-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Module.objects.count(), 4) + module4 = Module.objects.get(pk=response.data['id']) + self.assertEqual(module4.device_id, data['device']) + self.assertEqual(module4.parent_id, data['parent']) + self.assertEqual(module4.name, data['name']) + self.assertEqual(module4.manufacturer_id, data['manufacturer']) + + def test_update_module(self): + + data = { + 'device': self.device.pk, + 'parent': self.module1.pk, + 'name': 'Test Module X', + 'manufacturer': self.manufacturer.pk, + } + + url = reverse('dcim-api:module-detail', kwargs={'pk': self.module1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Module.objects.count(), 3) + module1 = Module.objects.get(pk=response.data['id']) + self.assertEqual(module1.device_id, data['device']) + self.assertEqual(module1.parent_id, data['parent']) + self.assertEqual(module1.name, data['name']) + self.assertEqual(module1.manufacturer_id, data['manufacturer']) + + def test_delete_module(self): + + url = reverse('dcim-api:module-detail', kwargs={'pk': self.module1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Module.objects.count(), 2) + + +class InterfaceConnectionTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.interface1 = Interface.objects.create(device=self.device, name='Test Interface 1') + self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2') + self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3') + self.interface4 = Interface.objects.create(device=self.device, name='Test Interface 4') + self.interface5 = Interface.objects.create(device=self.device, name='Test Interface 5') + self.interface6 = Interface.objects.create(device=self.device, name='Test Interface 6') + self.interface7 = Interface.objects.create(device=self.device, name='Test Interface 7') + self.interface8 = Interface.objects.create(device=self.device, name='Test Interface 8') + self.interfaceconnection1 = InterfaceConnection.objects.create( + interface_a=self.interface1, interface_b=self.interface2 + ) + self.interfaceconnection2 = InterfaceConnection.objects.create( + interface_a=self.interface3, interface_b=self.interface4 + ) + self.interfaceconnection3 = InterfaceConnection.objects.create( + interface_a=self.interface5, interface_b=self.interface6 + ) + + def test_get_interfaceconnection(self): + + url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['interface_a']['id'], self.interfaceconnection1.interface_a_id) + self.assertEqual(response.data['interface_b']['id'], self.interfaceconnection1.interface_b_id) + + def test_list_interfaceconnections(self): + + url = reverse('dcim-api:interfaceconnection-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_interfaceconnection(self): + + data = { + 'interface_a': self.interface7.pk, + 'interface_b': self.interface8.pk, + } + + url = reverse('dcim-api:interfaceconnection-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(InterfaceConnection.objects.count(), 4) + interfaceconnection4 = InterfaceConnection.objects.get(pk=response.data['id']) + self.assertEqual(interfaceconnection4.interface_a_id, data['interface_a']) + self.assertEqual(interfaceconnection4.interface_b_id, data['interface_b']) + + def test_update_interfaceconnection(self): + + new_connection_status = not self.interfaceconnection1.connection_status + + data = { + 'interface_a': self.interface7.pk, + 'interface_b': self.interface8.pk, + 'connection_status': new_connection_status, + } + + url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(InterfaceConnection.objects.count(), 3) + interfaceconnection1 = InterfaceConnection.objects.get(pk=response.data['id']) + self.assertEqual(interfaceconnection1.interface_a_id, data['interface_a']) + self.assertEqual(interfaceconnection1.interface_b_id, data['interface_b']) + self.assertEqual(interfaceconnection1.connection_status, data['connection_status']) + + def test_delete_interfaceconnection(self): + + url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(InterfaceConnection.objects.count(), 2) From 8825a03033ce004ff67fe9533031625f250c0cc4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Mar 2017 12:23:23 -0400 Subject: [PATCH 073/182] Removed unneeded services endpoint from DCIM API --- netbox/dcim/api/urls.py | 2 -- netbox/dcim/api/views.py | 3 --- 2 files changed, 5 deletions(-) diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index cde304afb..9f7aa3717 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -1,6 +1,5 @@ from rest_framework import routers -from ipam.api.views import ServiceViewSet from . import views @@ -41,7 +40,6 @@ router.register(r'power-outlets', views.PowerOutletViewSet) router.register(r'interfaces', views.InterfaceViewSet) router.register(r'device-bays', views.DeviceBayViewSet) router.register(r'modules', views.ModuleViewSet) -router.register(r'services', ServiceViewSet) # Interface connections router.register(r'interface-connections', views.InterfaceConnectionViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 9bb576cb9..8afd78cb0 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -2,12 +2,9 @@ from rest_framework.decorators import detail_route from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.settings import api_settings -from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet, ViewSet from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.http import Http404 from django.shortcuts import get_object_or_404 from dcim.models import ( From a6f4de58177269967e8b476553caa8a61eb94879 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Mar 2017 14:36:59 -0400 Subject: [PATCH 074/182] Wrote tests for IPAM API --- netbox/ipam/api/serializers.py | 31 +- netbox/ipam/tests/test_api.py | 659 +++++++++++++++++++++++++++++++++ 2 files changed, 687 insertions(+), 3 deletions(-) create mode 100644 netbox/ipam/tests/test_api.py diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index c33694d88..8189a8fb3 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from rest_framework.validators import UniqueTogetherValidator from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer from extras.api.customfields import CustomFieldModelSerializer @@ -99,7 +100,7 @@ class WritableAggregateSerializer(serializers.ModelSerializer): class Meta: model = Aggregate - fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description'] + fields = ['id', 'prefix', 'rir', 'date_added', 'description'] # @@ -127,6 +128,18 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer): class Meta: model = VLANGroup fields = ['id', 'name', 'slug', 'site'] + validators = [] + + def validate(self, data): + + # Validate uniqueness of name and slug if a site has been assigned. + if data.get('site', None): + for field in ['name', 'slug']: + validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('site', field)) + validator.set_context(self) + validator(data) + + return data # @@ -163,6 +176,18 @@ class WritableVLANSerializer(serializers.ModelSerializer): fields = [ 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', ] + validators = [] + + def validate(self, data): + + # Validate uniqueness of vid and name if a group has been assigned. + if data.get('group', None): + for field in ['vid', 'name']: + validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('group', field)) + validator.set_context(self) + validator(data) + + return data # @@ -198,7 +223,7 @@ class WritablePrefixSerializer(serializers.ModelSerializer): class Meta: model = Prefix fields = [ - 'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', + 'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', ] @@ -235,7 +260,7 @@ class WritableIPAddressSerializer(serializers.ModelSerializer): class Meta: model = IPAddress - fields = ['id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside'] + fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside'] # diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py new file mode 100644 index 000000000..64d8c98a1 --- /dev/null +++ b/netbox/ipam/tests/test_api.py @@ -0,0 +1,659 @@ +from netaddr import IPNetwork + +from rest_framework import status +from rest_framework.test import APITestCase + +from django.contrib.auth.models import User +from django.urls import reverse + +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site +from ipam.models import ( + Aggregate, IPAddress, IP_PROTOCOL_TCP, IP_PROTOCOL_UDP, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF, +) +from users.models import Token + + +class VRFTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1') + self.vrf2 = VRF.objects.create(name='Test VRF 2', rd='65000:2') + self.vrf3 = VRF.objects.create(name='Test VRF 3', rd='65000:3') + + def test_get_vrf(self): + + url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.vrf1.name) + + def test_list_vrfs(self): + + url = reverse('ipam-api:vrf-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_vrf(self): + + data = { + 'name': 'Test VRF 4', + 'rd': '65000:4', + } + + url = reverse('ipam-api:vrf-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(VRF.objects.count(), 4) + vrf4 = VRF.objects.get(pk=response.data['id']) + self.assertEqual(vrf4.name, data['name']) + self.assertEqual(vrf4.rd, data['rd']) + + def test_update_vrf(self): + + data = { + 'name': 'Test VRF X', + 'rd': '65000:99', + } + + url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(VRF.objects.count(), 3) + vrf1 = VRF.objects.get(pk=response.data['id']) + self.assertEqual(vrf1.name, data['name']) + self.assertEqual(vrf1.rd, data['rd']) + + def test_delete_vrf(self): + + url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(VRF.objects.count(), 2) + + +class RIRTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.rir1 = RIR.objects.create(name='Test RIR 1', slug='test-rir-1') + self.rir2 = RIR.objects.create(name='Test RIR 2', slug='test-rir-2') + self.rir3 = RIR.objects.create(name='Test RIR 3', slug='test-rir-3') + + def test_get_rir(self): + + url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.rir1.name) + + def test_list_rirs(self): + + url = reverse('ipam-api:rir-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_rir(self): + + data = { + 'name': 'Test RIR 4', + 'slug': 'test-rir-4', + } + + url = reverse('ipam-api:rir-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(RIR.objects.count(), 4) + rir4 = RIR.objects.get(pk=response.data['id']) + self.assertEqual(rir4.name, data['name']) + self.assertEqual(rir4.slug, data['slug']) + + def test_update_rir(self): + + data = { + 'name': 'Test RIR X', + 'slug': 'test-rir-x', + } + + url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(RIR.objects.count(), 3) + rir1 = RIR.objects.get(pk=response.data['id']) + self.assertEqual(rir1.name, data['name']) + self.assertEqual(rir1.slug, data['slug']) + + def test_delete_rir(self): + + url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(RIR.objects.count(), 2) + + +class AggregateTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.rir1 = RIR.objects.create(name='Test RIR 1', slug='test-rir-1') + self.rir2 = RIR.objects.create(name='Test RIR 2', slug='test-rir-2') + self.aggregate1 = Aggregate.objects.create(prefix=IPNetwork('10.0.0.0/8'), rir=self.rir1) + self.aggregate2 = Aggregate.objects.create(prefix=IPNetwork('172.16.0.0/12'), rir=self.rir1) + self.aggregate3 = Aggregate.objects.create(prefix=IPNetwork('192.168.0.0/16'), rir=self.rir1) + + def test_get_aggregate(self): + + url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['prefix'], str(self.aggregate1.prefix)) + + def test_list_aggregates(self): + + url = reverse('ipam-api:aggregate-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_aggregate(self): + + data = { + 'prefix': '192.0.2.0/24', + 'rir': self.rir1.pk, + } + + url = reverse('ipam-api:aggregate-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Aggregate.objects.count(), 4) + aggregate4 = Aggregate.objects.get(pk=response.data['id']) + self.assertEqual(str(aggregate4.prefix), data['prefix']) + self.assertEqual(aggregate4.rir_id, data['rir']) + + def test_update_aggregate(self): + + data = { + 'prefix': '11.0.0.0/8', + 'rir': self.rir2.pk, + } + + url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Aggregate.objects.count(), 3) + aggregate1 = Aggregate.objects.get(pk=response.data['id']) + self.assertEqual(str(aggregate1.prefix), data['prefix']) + self.assertEqual(aggregate1.rir_id, data['rir']) + + def test_delete_aggregate(self): + + url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Aggregate.objects.count(), 2) + + +class RoleTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.role1 = Role.objects.create(name='Test Role 1', slug='test-role-1') + self.role2 = Role.objects.create(name='Test Role 2', slug='test-role-2') + self.role3 = Role.objects.create(name='Test Role 3', slug='test-role-3') + + def test_get_role(self): + + url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.role1.name) + + def test_list_roles(self): + + url = reverse('ipam-api:role-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_role(self): + + data = { + 'name': 'Test Role 4', + 'slug': 'test-role-4', + } + + url = reverse('ipam-api:role-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Role.objects.count(), 4) + role4 = Role.objects.get(pk=response.data['id']) + self.assertEqual(role4.name, data['name']) + self.assertEqual(role4.slug, data['slug']) + + def test_update_role(self): + + data = { + 'name': 'Test Role X', + 'slug': 'test-role-x', + } + + url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Role.objects.count(), 3) + role1 = Role.objects.get(pk=response.data['id']) + self.assertEqual(role1.name, data['name']) + self.assertEqual(role1.slug, data['slug']) + + def test_delete_role(self): + + url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Role.objects.count(), 2) + + +class PrefixTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') + self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1') + self.vlan1 = VLAN.objects.create(vid=1, name='Test VLAN 1') + self.role1 = Role.objects.create(name='Test Role 1', slug='test-role-1') + self.prefix1 = Prefix.objects.create(prefix=IPNetwork('192.168.1.0/24')) + self.prefix2 = Prefix.objects.create(prefix=IPNetwork('192.168.2.0/24')) + self.prefix3 = Prefix.objects.create(prefix=IPNetwork('192.168.3.0/24')) + + def test_get_prefix(self): + + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['prefix'], str(self.prefix1.prefix)) + + def test_list_prefixs(self): + + url = reverse('ipam-api:prefix-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_prefix(self): + + data = { + 'prefix': '192.168.4.0/24', + 'site': self.site1.pk, + 'vrf': self.vrf1.pk, + 'vlan': self.vlan1.pk, + 'role': self.role1.pk, + } + + url = reverse('ipam-api:prefix-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Prefix.objects.count(), 4) + prefix4 = Prefix.objects.get(pk=response.data['id']) + self.assertEqual(str(prefix4.prefix), data['prefix']) + self.assertEqual(prefix4.site_id, data['site']) + self.assertEqual(prefix4.vrf_id, data['vrf']) + self.assertEqual(prefix4.vlan_id, data['vlan']) + self.assertEqual(prefix4.role_id, data['role']) + + def test_update_prefix(self): + + data = { + 'prefix': '192.168.99.0/24', + 'site': self.site1.pk, + 'vrf': self.vrf1.pk, + 'vlan': self.vlan1.pk, + 'role': self.role1.pk, + } + + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Prefix.objects.count(), 3) + prefix1 = Prefix.objects.get(pk=response.data['id']) + self.assertEqual(str(prefix1.prefix), data['prefix']) + self.assertEqual(prefix1.site_id, data['site']) + self.assertEqual(prefix1.vrf_id, data['vrf']) + self.assertEqual(prefix1.vlan_id, data['vlan']) + self.assertEqual(prefix1.role_id, data['role']) + + def test_delete_prefix(self): + + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Prefix.objects.count(), 2) + + +class IPAddressTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1') + self.ipaddress1 = IPAddress.objects.create(address=IPNetwork('192.168.0.1/24')) + self.ipaddress2 = IPAddress.objects.create(address=IPNetwork('192.168.0.2/24')) + self.ipaddress3 = IPAddress.objects.create(address=IPNetwork('192.168.0.3/24')) + + def test_get_ipaddress(self): + + url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['address'], str(self.ipaddress1.address)) + + def test_list_ipaddresss(self): + + url = reverse('ipam-api:ipaddress-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_ipaddress(self): + + data = { + 'address': '192.168.0.4/24', + 'vrf': self.vrf1.pk, + } + + url = reverse('ipam-api:ipaddress-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(IPAddress.objects.count(), 4) + ipaddress4 = IPAddress.objects.get(pk=response.data['id']) + self.assertEqual(str(ipaddress4.address), data['address']) + self.assertEqual(ipaddress4.vrf_id, data['vrf']) + + def test_update_ipaddress(self): + + data = { + 'address': '192.168.0.99/24', + 'vrf': self.vrf1.pk, + } + + url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(IPAddress.objects.count(), 3) + ipaddress1 = IPAddress.objects.get(pk=response.data['id']) + self.assertEqual(str(ipaddress1.address), data['address']) + self.assertEqual(ipaddress1.vrf_id, data['vrf']) + + def test_delete_ipaddress(self): + + url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(IPAddress.objects.count(), 2) + + +class VLANGroupTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.vlangroup1 = VLANGroup.objects.create(name='Test VLAN Group 1', slug='test-vlan-group-1') + self.vlangroup2 = VLANGroup.objects.create(name='Test VLAN Group 2', slug='test-vlan-group-2') + self.vlangroup3 = VLANGroup.objects.create(name='Test VLAN Group 3', slug='test-vlan-group-3') + + def test_get_vlangroup(self): + + url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.vlangroup1.name) + + def test_list_vlangroups(self): + + url = reverse('ipam-api:vlangroup-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_vlangroup(self): + + data = { + 'name': 'Test VLAN Group 4', + 'slug': 'test-vlan-group-4', + } + + url = reverse('ipam-api:vlangroup-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(VLANGroup.objects.count(), 4) + vlangroup4 = VLANGroup.objects.get(pk=response.data['id']) + self.assertEqual(vlangroup4.name, data['name']) + self.assertEqual(vlangroup4.slug, data['slug']) + + def test_update_vlangroup(self): + + data = { + 'name': 'Test VLAN Group X', + 'slug': 'test-vlan-group-x', + } + + url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(VLANGroup.objects.count(), 3) + vlangroup1 = VLANGroup.objects.get(pk=response.data['id']) + self.assertEqual(vlangroup1.name, data['name']) + self.assertEqual(vlangroup1.slug, data['slug']) + + def test_delete_vlangroup(self): + + url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(VLANGroup.objects.count(), 2) + + +class VLANTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.vlan1 = VLAN.objects.create(vid=1, name='Test VLAN 1') + self.vlan2 = VLAN.objects.create(vid=2, name='Test VLAN 2') + self.vlan3 = VLAN.objects.create(vid=3, name='Test VLAN 3') + + def test_get_vlan(self): + + url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.vlan1.name) + + def test_list_vlans(self): + + url = reverse('ipam-api:vlan-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_vlan(self): + + data = { + 'vid': 4, + 'name': 'Test VLAN 4', + } + + url = reverse('ipam-api:vlan-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(VLAN.objects.count(), 4) + vlan4 = VLAN.objects.get(pk=response.data['id']) + self.assertEqual(vlan4.vid, data['vid']) + self.assertEqual(vlan4.name, data['name']) + + def test_update_vlan(self): + + data = { + 'vid': 99, + 'name': 'Test VLAN X', + } + + url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(VLAN.objects.count(), 3) + vlan1 = VLAN.objects.get(pk=response.data['id']) + self.assertEqual(vlan1.vid, data['vid']) + self.assertEqual(vlan1.name, data['name']) + + def test_delete_vlan(self): + + url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(VLAN.objects.count(), 2) + + +class ServiceTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device Type 1') + devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1') + self.device1 = Device.objects.create( + name='Test Device 1', site=site, device_type=devicetype, device_role=devicerole + ) + self.device2 = Device.objects.create( + name='Test Device 2', site=site, device_type=devicetype, device_role=devicerole + ) + self.service1 = Service.objects.create( + device=self.device1, name='Test Service 1', protocol=IP_PROTOCOL_TCP, port=1 + ) + self.service1 = Service.objects.create( + device=self.device1, name='Test Service 2', protocol=IP_PROTOCOL_TCP, port=2 + ) + self.service1 = Service.objects.create( + device=self.device1, name='Test Service 3', protocol=IP_PROTOCOL_TCP, port=3 + ) + + def test_get_service(self): + + url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.service1.name) + + def test_list_services(self): + + url = reverse('ipam-api:service-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_service(self): + + data = { + 'device': self.device1.pk, + 'name': 'Test Service 4', + 'protocol': IP_PROTOCOL_TCP, + 'port': 4, + } + + url = reverse('ipam-api:service-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Service.objects.count(), 4) + service4 = Service.objects.get(pk=response.data['id']) + self.assertEqual(service4.device_id, data['device']) + self.assertEqual(service4.name, data['name']) + self.assertEqual(service4.protocol, data['protocol']) + self.assertEqual(service4.port, data['port']) + + def test_update_service(self): + + data = { + 'device': self.device2.pk, + 'name': 'Test Service X', + 'protocol': IP_PROTOCOL_UDP, + 'port': 99, + } + + url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Service.objects.count(), 3) + service1 = Service.objects.get(pk=response.data['id']) + self.assertEqual(service1.device_id, data['device']) + self.assertEqual(service1.name, data['name']) + self.assertEqual(service1.protocol, data['protocol']) + self.assertEqual(service1.port, data['port']) + + def test_delete_service(self): + + url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Service.objects.count(), 2) From c5308d51f45c2832fdd0d377f06fb246584d2c59 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Mar 2017 14:40:11 -0400 Subject: [PATCH 075/182] Make RackReservation.rack editble for API compatability --- .../0033_rackreservation_rack_editable.py | 21 +++++++++++++++++++ netbox/dcim/models.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 netbox/dcim/migrations/0033_rackreservation_rack_editable.py diff --git a/netbox/dcim/migrations/0033_rackreservation_rack_editable.py b/netbox/dcim/migrations/0033_rackreservation_rack_editable.py new file mode 100644 index 000000000..b327bad12 --- /dev/null +++ b/netbox/dcim/migrations/0033_rackreservation_rack_editable.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.6 on 2017-03-17 18:39 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0032_device_increase_name_length'), + ] + + operations = [ + migrations.AlterField( + model_name='rackreservation', + name='rack', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='dcim.Rack'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 73678ae1c..351d0a2b2 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -533,7 +533,7 @@ class RackReservation(models.Model): """ One or more reserved units within a Rack. """ - rack = models.ForeignKey('Rack', related_name='reservations', editable=False, on_delete=models.CASCADE) + rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE) units = ArrayField(models.PositiveSmallIntegerField()) created = models.DateTimeField(auto_now_add=True) user = models.ForeignKey(User, editable=False, on_delete=models.PROTECT) From 79a9ac3bc825f002cbb57b3154947e45a2c75a44 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Mar 2017 14:45:14 -0400 Subject: [PATCH 076/182] Assign RackReservation user from request context --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/api/views.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9e19d0bb7..6b285620f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -191,7 +191,7 @@ class WritableRackReservationSerializer(serializers.ModelSerializer): class Meta: model = RackReservation - fields = ['id', 'rack', 'units', 'user', 'description'] + fields = ['id', 'rack', 'units', 'description'] # diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 8afd78cb0..0e1923ce6 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -117,6 +117,10 @@ class RackReservationViewSet(WritableSerializerMixin, ModelViewSet): write_serializer_class = serializers.WritableRackReservationSerializer filter_class = filters.RackReservationFilter + # Assign user from request + def perform_create(self, serializer): + serializer.save(user=self.request.user) + # # Manufacturers From 130ff27f26258796960c36c9180159c73044dd33 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Mar 2017 16:01:57 -0400 Subject: [PATCH 077/182] Wrote tests for secrets API --- netbox/secrets/api/serializers.py | 12 ++ netbox/secrets/tests/test_api.py | 227 ++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 netbox/secrets/tests/test_api.py diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index d94881811..1dc52388a 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from rest_framework.validators import UniqueTogetherValidator from dcim.api.serializers import NestedDeviceSerializer from secrets.models import Secret, SecretRole @@ -42,3 +43,14 @@ class WritableSecretSerializer(serializers.ModelSerializer): class Meta: model = Secret fields = ['id', 'device', 'role', 'name', 'plaintext'] + validators = [] + + def validate(self, data): + + # Validate uniqueness of name if one has been provided. + if data.get('name', None): + validator = UniqueTogetherValidator(queryset=Secret.objects.all(), fields=('device', 'role', 'name')) + validator.set_context(self) + validator(data) + + return data diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py new file mode 100644 index 000000000..99fbc0aaf --- /dev/null +++ b/netbox/secrets/tests/test_api.py @@ -0,0 +1,227 @@ +import base64 +from rest_framework import status +from rest_framework.test import APITestCase + +from django.contrib.auth.models import User +from django.urls import reverse + +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site +from secrets.models import Secret, SecretRole, SessionKey, UserKey +from users.models import Token + + +# Dummy RSA key pair for testing use only +PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA97wPWxpq5cClRu8Ssq609ZLfyx6E8ln/v/PdFZ7fxxmA4k+z +1Q/Rn9/897PWy+1x2ZKlHjmaw1z7dS3PlGqdd453d1eY95xYVbFrIHs7yJy8lcDR +2criwGEI68VP1FwcOkkwhicjtQZQS5fkkBIbRjA2wmt2PVT26YbOX2qCMItV1+me +o/Ogh+uI1oNePJ8VYuGXbGNggf1qMY8fGhhhGY2b4PKuSTcsYjbg8adOGzFL9RXL +I1X4PHNCzD/Y1vdM3jJXv+luk3TU+JIbzJeN5ZEEz+sIdlMPCAACaZAY/t9Kd/Lx +Hr0o4K/6gqkZIukxFCK6sN53gibAXfaKc4xlqQIDAQABAoIBAQC4pDQVxNTTtQf6 +nImlH83EEto1++M+9pFFsi6fxLApJvsGsjzomke1Dy7uN93qVGk8rq3enzSYU58f +sSs8BVKkH00vZ9ydAKxeAkREC1V9qkRsoTBHUY47sJcDkyZyssxfLNm7w0Q70h7a +mLVEJBqr75eAxLN19vOpDk6Wkz3Bi0Dj27HLeme3hH5jLVQIIswWZnUDP3r/sdM/ +WA2GjoycPbug0r1FVZnxkFCrQ5yMfH3VzKBelj7356+5sc/TUXedDFN/DV2b90Ll ++au7EEXecFYZwmX3SX2hpe6IWEpUW3B0fvm+Ipm8h7x68i7J0oi9EUXW2+UQYfOx +dDLxTLvhAoGBAPtJJox4XcpzipSAzKxyV8K9ikUZCG2wJU7VHyZ5zpSXwl/husls +brAzHQcnWayhxxuWeiQ6pLnRFPFXjlOH2FZqHXSLnfpDaymEksDPvo9GqRE3Q+F+ +lDRn72H1NLIj3Y3t5SwWRB34Dhy+gd5Ht9L3dCTH8cYvJGnmS4sH/z0NAoGBAPxh +2rhS1B0S9mqqvpduUPxqUIWaztXaHC6ZikloOFcgVMdh9MRrpa2sa+bqcygyqrbH +GZIIeGcWpmzeitWgSUNLMSIpdl/VoBSvZUMggdJyOHXayo/EhfFddGHdkfz0B0GW +LzH8ow4JcYdhkTl4+xQstXJNVRJyw5ezFy35FHwNAoGAGZzjKP470R7lyS03r3wY +Jelb5p8elM+XfemLO0i/HbY6QbuoZk9/GMac9tWz9jynJtC3smmn0KjXEaJzB2CZ +VHWMewygFZo5mgnBS5XhPoldQjv310wnnw/Y/osXy/CL7KOK8Gt0lflqttNUOWvl ++MLwO6+FnUXA2Gp42Lr/8SECgYANf2pEK2HewDHfmIwi6yp3pXPzAUmIlGanc1y6 ++lDxD/CYzTta+erdc/g9XFKWVsdciR9r+Pn/gW2bKve/3xer+qyBCDilfXZXRN4k +jeuDhspQO0hUEg2b0AS2azQwlBiDQHX7tWg/CvBAbk5nBXpgJNf7aflfyDV/untF +4SlgTQKBgGmcyU02lyM6ogGbzWqSsHgR1ZhYyTV9DekQx9GysLG1wT2QzgjxOw4K +5PnVkOXr/ORqt+vJsYrtqBZQihmPPREKEwr2n8BRw0364z02wjvP04hDBHp4S5Ej +PQeC5qErboVGMMpM2SamqGEfr+HJ/uRF6mEmm+xjI57aOvAwPW0B +-----END RSA PRIVATE KEY-----""" + +PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA97wPWxpq5cClRu8Ssq60 +9ZLfyx6E8ln/v/PdFZ7fxxmA4k+z1Q/Rn9/897PWy+1x2ZKlHjmaw1z7dS3PlGqd +d453d1eY95xYVbFrIHs7yJy8lcDR2criwGEI68VP1FwcOkkwhicjtQZQS5fkkBIb +RjA2wmt2PVT26YbOX2qCMItV1+meo/Ogh+uI1oNePJ8VYuGXbGNggf1qMY8fGhhh +GY2b4PKuSTcsYjbg8adOGzFL9RXLI1X4PHNCzD/Y1vdM3jJXv+luk3TU+JIbzJeN +5ZEEz+sIdlMPCAACaZAY/t9Kd/LxHr0o4K/6gqkZIukxFCK6sN53gibAXfaKc4xl +qQIDAQAB +-----END PUBLIC KEY-----""" + + +class SecretRoleTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1') + self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2') + self.secretrole3 = SecretRole.objects.create(name='Test Secret Role 3', slug='test-secret-role-3') + + def test_get_secretrole(self): + + url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.secretrole1.name) + + def test_list_secretroles(self): + + url = reverse('secrets-api:secretrole-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_secretrole(self): + + data = { + 'name': 'Test SecretRole 4', + 'slug': 'test-secretrole-4', + } + + url = reverse('secrets-api:secretrole-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(SecretRole.objects.count(), 4) + secretrole4 = SecretRole.objects.get(pk=response.data['id']) + self.assertEqual(secretrole4.name, data['name']) + self.assertEqual(secretrole4.slug, data['slug']) + + def test_update_secretrole(self): + + data = { + 'name': 'Test SecretRole X', + 'slug': 'test-secretrole-x', + } + + url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(SecretRole.objects.count(), 3) + secretrole1 = SecretRole.objects.get(pk=response.data['id']) + self.assertEqual(secretrole1.name, data['name']) + self.assertEqual(secretrole1.slug, data['slug']) + + def test_delete_secretrole(self): + + url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(SecretRole.objects.count(), 2) + + +class SecretTest(APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + + userkey = UserKey(user=user, public_key=PUBLIC_KEY) + userkey.save() + self.master_key = userkey.get_master_key(PRIVATE_KEY) + session_key = SessionKey(userkey=userkey) + session_key.save(self.master_key) + + self.header = { + 'HTTP_AUTHORIZATION': 'Token {}'.format(token.key), + 'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key), + } + + self.plaintext = { + 'secret1': 'Secret#1Plaintext', + 'secret2': 'Secret#2Plaintext', + 'secret3': 'Secret#3Plaintext', + } + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device Type 1') + devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1') + self.device = Device.objects.create( + name='Test Device 1', site=site, device_type=devicetype, device_role=devicerole + ) + self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1') + self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2') + self.secret1 = Secret( + device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintext['secret1'] + ) + self.secret1.encrypt(self.master_key) + self.secret1.save() + self.secret2 = Secret( + device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintext['secret2'] + ) + self.secret2.encrypt(self.master_key) + self.secret2.save() + self.secret3 = Secret( + device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintext['secret3'] + ) + self.secret3.encrypt(self.master_key) + self.secret3.save() + + def test_get_secret(self): + + url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['plaintext'], self.plaintext['secret1']) + + def test_list_secrets(self): + + url = reverse('secrets-api:secret-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_secret(self): + + data = { + 'device': self.device.pk, + 'role': self.secretrole1.pk, + 'plaintext': 'Secret#4Plaintext', + } + + url = reverse('secrets-api:secret-list') + response = self.client.post(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data) + self.assertEqual(response.data['plaintext'], data['plaintext']) + self.assertEqual(Secret.objects.count(), 4) + secret4 = Secret.objects.get(pk=response.data['id']) + secret4.decrypt(self.master_key) + self.assertEqual(secret4.role_id, data['role']) + self.assertEqual(secret4.plaintext, data['plaintext']) + + def test_update_secret(self): + + data = { + 'device': self.device.pk, + 'role': self.secretrole2.pk, + 'plaintext': 'NewPlaintext', + } + + url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + self.assertEqual(response.data['plaintext'], data['plaintext']) + self.assertEqual(Secret.objects.count(), 3) + secret1 = Secret.objects.get(pk=response.data['id']) + secret1.decrypt(self.master_key) + self.assertEqual(secret1.role_id, data['role']) + self.assertEqual(secret1.plaintext, data['plaintext']) + + def test_delete_secret(self): + + url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) + response = self.client.delete(url, **self.header) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Secret.objects.count(), 2) From e544f1fa1eafc92323bd05013359ac17ead3e0f7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Mar 2017 16:20:34 -0400 Subject: [PATCH 078/182] Removed extraneous 'id' field from all WritableSerializers --- netbox/circuits/api/serializers.py | 10 +++---- netbox/dcim/api/serializers.py | 42 +++++++++++++++--------------- netbox/ipam/api/serializers.py | 18 +++++-------- netbox/secrets/api/serializers.py | 2 +- netbox/tenancy/api/serializers.py | 2 +- 5 files changed, 33 insertions(+), 41 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 26f57d000..1529af6d3 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -32,9 +32,7 @@ class WritableProviderSerializer(serializers.ModelSerializer): class Meta: model = Provider - fields = [ - 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - ] + fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] # @@ -85,9 +83,7 @@ class WritableCircuitSerializer(serializers.ModelSerializer): class Meta: model = Circuit - fields = [ - 'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', - ] + fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments'] # @@ -111,5 +107,5 @@ class WritableCircuitTerminationSerializer(serializers.ModelSerializer): class Meta: model = CircuitTermination fields = [ - 'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', + 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', ] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 6b285620f..6c511e824 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -38,7 +38,7 @@ class WritableRegionSerializer(serializers.ModelSerializer): class Meta: model = Region - fields = ['id', 'name', 'slug', 'parent'] + fields = ['name', 'slug', 'parent'] # @@ -71,7 +71,7 @@ class WritableSiteSerializer(serializers.ModelSerializer): class Meta: model = Site fields = [ - 'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', + 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', ] @@ -100,7 +100,7 @@ class WritableRackGroupSerializer(serializers.ModelSerializer): class Meta: model = RackGroup - fields = ['id', 'name', 'slug', 'site'] + fields = ['name', 'slug', 'site'] # @@ -155,7 +155,7 @@ class WritableRackSerializer(serializers.ModelSerializer): class Meta: model = Rack fields = [ - 'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', 'comments', ] # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This @@ -191,7 +191,7 @@ class WritableRackReservationSerializer(serializers.ModelSerializer): class Meta: model = RackReservation - fields = ['id', 'rack', 'units', 'description'] + fields = ['rack', 'units', 'description'] # @@ -246,7 +246,7 @@ class WritableDeviceTypeSerializer(serializers.ModelSerializer): class Meta: model = DeviceType fields = [ - 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', ] @@ -267,7 +267,7 @@ class WritableConsolePortTemplateSerializer(serializers.ModelSerializer): class Meta: model = ConsolePortTemplate - fields = ['id', 'device_type', 'name'] + fields = ['device_type', 'name'] # @@ -286,7 +286,7 @@ class WritableConsoleServerPortTemplateSerializer(serializers.ModelSerializer): class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'device_type', 'name'] + fields = ['device_type', 'name'] # @@ -305,7 +305,7 @@ class WritablePowerPortTemplateSerializer(serializers.ModelSerializer): class Meta: model = PowerPortTemplate - fields = ['id', 'device_type', 'name'] + fields = ['device_type', 'name'] # @@ -324,7 +324,7 @@ class WritablePowerOutletTemplateSerializer(serializers.ModelSerializer): class Meta: model = PowerOutletTemplate - fields = ['id', 'device_type', 'name'] + fields = ['device_type', 'name'] # @@ -344,7 +344,7 @@ class WritableInterfaceTemplateSerializer(serializers.ModelSerializer): class Meta: model = InterfaceTemplate - fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only'] + fields = ['device_type', 'name', 'form_factor', 'mgmt_only'] # @@ -363,7 +363,7 @@ class WritableDeviceBayTemplateSerializer(serializers.ModelSerializer): class Meta: model = DeviceBayTemplate - fields = ['id', 'device_type', 'name'] + fields = ['device_type', 'name'] # @@ -467,7 +467,7 @@ class WritableDeviceSerializer(serializers.ModelSerializer): class Meta: model = Device fields = [ - 'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', + 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', ] validators = [] @@ -500,7 +500,7 @@ class WritableConsoleServerPortSerializer(serializers.ModelSerializer): class Meta: model = ConsoleServerPort - fields = ['id', 'device', 'name'] + fields = ['device', 'name'] # @@ -520,7 +520,7 @@ class WritableConsolePortSerializer(serializers.ModelSerializer): class Meta: model = ConsolePort - fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] + fields = ['device', 'name', 'cs_port', 'connection_status'] # @@ -540,7 +540,7 @@ class WritablePowerOutletSerializer(serializers.ModelSerializer): class Meta: model = PowerOutlet - fields = ['id', 'device', 'name'] + fields = ['device', 'name'] # @@ -560,7 +560,7 @@ class WritablePowerPortSerializer(serializers.ModelSerializer): class Meta: model = PowerPort - fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] + fields = ['device', 'name', 'power_outlet', 'connection_status'] # @@ -604,7 +604,7 @@ class WritableInterfaceSerializer(serializers.ModelSerializer): class Meta: model = Interface - fields = ['id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description'] + fields = ['device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description'] # @@ -624,7 +624,7 @@ class WritableDeviceBaySerializer(serializers.ModelSerializer): class Meta: model = DeviceBay - fields = ['id', 'device', 'name', 'installed_device'] + fields = ['device', 'name', 'installed_device'] # @@ -644,7 +644,7 @@ class WritableModuleSerializer(serializers.ModelSerializer): class Meta: model = Module - fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] + fields = ['device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] # @@ -673,4 +673,4 @@ class WritableInterfaceConnectionSerializer(serializers.ModelSerializer): class Meta: model = InterfaceConnection - fields = ['id', 'interface_a', 'interface_b', 'connection_status'] + fields = ['interface_a', 'interface_b', 'connection_status'] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 8189a8fb3..48f01d4fa 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -35,7 +35,7 @@ class WritableVRFSerializer(serializers.ModelSerializer): class Meta: model = VRF - fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description'] + fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] # @@ -100,7 +100,7 @@ class WritableAggregateSerializer(serializers.ModelSerializer): class Meta: model = Aggregate - fields = ['id', 'prefix', 'rir', 'date_added', 'description'] + fields = ['prefix', 'rir', 'date_added', 'description'] # @@ -127,7 +127,7 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer): class Meta: model = VLANGroup - fields = ['id', 'name', 'slug', 'site'] + fields = ['name', 'slug', 'site'] validators = [] def validate(self, data): @@ -173,9 +173,7 @@ class WritableVLANSerializer(serializers.ModelSerializer): class Meta: model = VLAN - fields = [ - 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', - ] + fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] validators = [] def validate(self, data): @@ -222,9 +220,7 @@ class WritablePrefixSerializer(serializers.ModelSerializer): class Meta: model = Prefix - fields = [ - 'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', - ] + fields = ['prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description'] # @@ -260,7 +256,7 @@ class WritableIPAddressSerializer(serializers.ModelSerializer): class Meta: model = IPAddress - fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside'] + fields = ['address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside'] # @@ -281,4 +277,4 @@ class WritableServiceSerializer(serializers.ModelSerializer): class Meta: model = Service - fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] + fields = ['device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 1dc52388a..f2a9a4aa3 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -42,7 +42,7 @@ class WritableSecretSerializer(serializers.ModelSerializer): class Meta: model = Secret - fields = ['id', 'device', 'role', 'name', 'plaintext'] + fields = ['device', 'role', 'name', 'plaintext'] validators = [] def validate(self, data): diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 67231fe67..d4b9e18be 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -47,4 +47,4 @@ class WritableTenantSerializer(serializers.ModelSerializer): class Meta: model = Tenant - fields = ['id', 'name', 'slug', 'group', 'description', 'comments'] + fields = ['name', 'slug', 'group', 'description', 'comments'] From c08fae8bceb213ef3a8d0994b714d6f09f916772 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Mar 2017 17:32:43 -0400 Subject: [PATCH 079/182] Restore not-so-extraneous 'id' field to all WritableSerializers --- netbox/circuits/api/serializers.py | 6 ++--- netbox/dcim/api/serializers.py | 42 +++++++++++++++--------------- netbox/extras/api/serializers.py | 2 +- netbox/ipam/api/serializers.py | 14 +++++----- netbox/secrets/api/serializers.py | 2 +- netbox/tenancy/api/serializers.py | 2 +- 6 files changed, 34 insertions(+), 34 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 1529af6d3..4703458a6 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -32,7 +32,7 @@ class WritableProviderSerializer(serializers.ModelSerializer): class Meta: model = Provider - fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] + fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] # @@ -83,7 +83,7 @@ class WritableCircuitSerializer(serializers.ModelSerializer): class Meta: model = Circuit - fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments'] + fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments'] # @@ -107,5 +107,5 @@ class WritableCircuitTerminationSerializer(serializers.ModelSerializer): class Meta: model = CircuitTermination fields = [ - 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', + 'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', ] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 6c511e824..6b285620f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -38,7 +38,7 @@ class WritableRegionSerializer(serializers.ModelSerializer): class Meta: model = Region - fields = ['name', 'slug', 'parent'] + fields = ['id', 'name', 'slug', 'parent'] # @@ -71,7 +71,7 @@ class WritableSiteSerializer(serializers.ModelSerializer): class Meta: model = Site fields = [ - 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', + 'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', ] @@ -100,7 +100,7 @@ class WritableRackGroupSerializer(serializers.ModelSerializer): class Meta: model = RackGroup - fields = ['name', 'slug', 'site'] + fields = ['id', 'name', 'slug', 'site'] # @@ -155,7 +155,7 @@ class WritableRackSerializer(serializers.ModelSerializer): class Meta: model = Rack fields = [ - 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + 'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', 'comments', ] # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This @@ -191,7 +191,7 @@ class WritableRackReservationSerializer(serializers.ModelSerializer): class Meta: model = RackReservation - fields = ['rack', 'units', 'description'] + fields = ['id', 'rack', 'units', 'description'] # @@ -246,7 +246,7 @@ class WritableDeviceTypeSerializer(serializers.ModelSerializer): class Meta: model = DeviceType fields = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', + 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', ] @@ -267,7 +267,7 @@ class WritableConsolePortTemplateSerializer(serializers.ModelSerializer): class Meta: model = ConsolePortTemplate - fields = ['device_type', 'name'] + fields = ['id', 'device_type', 'name'] # @@ -286,7 +286,7 @@ class WritableConsoleServerPortTemplateSerializer(serializers.ModelSerializer): class Meta: model = ConsoleServerPortTemplate - fields = ['device_type', 'name'] + fields = ['id', 'device_type', 'name'] # @@ -305,7 +305,7 @@ class WritablePowerPortTemplateSerializer(serializers.ModelSerializer): class Meta: model = PowerPortTemplate - fields = ['device_type', 'name'] + fields = ['id', 'device_type', 'name'] # @@ -324,7 +324,7 @@ class WritablePowerOutletTemplateSerializer(serializers.ModelSerializer): class Meta: model = PowerOutletTemplate - fields = ['device_type', 'name'] + fields = ['id', 'device_type', 'name'] # @@ -344,7 +344,7 @@ class WritableInterfaceTemplateSerializer(serializers.ModelSerializer): class Meta: model = InterfaceTemplate - fields = ['device_type', 'name', 'form_factor', 'mgmt_only'] + fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only'] # @@ -363,7 +363,7 @@ class WritableDeviceBayTemplateSerializer(serializers.ModelSerializer): class Meta: model = DeviceBayTemplate - fields = ['device_type', 'name'] + fields = ['id', 'device_type', 'name'] # @@ -467,7 +467,7 @@ class WritableDeviceSerializer(serializers.ModelSerializer): class Meta: model = Device fields = [ - 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', + 'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', ] validators = [] @@ -500,7 +500,7 @@ class WritableConsoleServerPortSerializer(serializers.ModelSerializer): class Meta: model = ConsoleServerPort - fields = ['device', 'name'] + fields = ['id', 'device', 'name'] # @@ -520,7 +520,7 @@ class WritableConsolePortSerializer(serializers.ModelSerializer): class Meta: model = ConsolePort - fields = ['device', 'name', 'cs_port', 'connection_status'] + fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] # @@ -540,7 +540,7 @@ class WritablePowerOutletSerializer(serializers.ModelSerializer): class Meta: model = PowerOutlet - fields = ['device', 'name'] + fields = ['id', 'device', 'name'] # @@ -560,7 +560,7 @@ class WritablePowerPortSerializer(serializers.ModelSerializer): class Meta: model = PowerPort - fields = ['device', 'name', 'power_outlet', 'connection_status'] + fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] # @@ -604,7 +604,7 @@ class WritableInterfaceSerializer(serializers.ModelSerializer): class Meta: model = Interface - fields = ['device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description'] + fields = ['id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description'] # @@ -624,7 +624,7 @@ class WritableDeviceBaySerializer(serializers.ModelSerializer): class Meta: model = DeviceBay - fields = ['device', 'name', 'installed_device'] + fields = ['id', 'device', 'name', 'installed_device'] # @@ -644,7 +644,7 @@ class WritableModuleSerializer(serializers.ModelSerializer): class Meta: model = Module - fields = ['device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] + fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] # @@ -673,4 +673,4 @@ class WritableInterfaceConnectionSerializer(serializers.ModelSerializer): class Meta: model = InterfaceConnection - fields = ['interface_a', 'interface_b', 'connection_status'] + fields = ['id', 'interface_a', 'interface_b', 'connection_status'] diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index ec72910c9..5348dcc10 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -41,7 +41,7 @@ class WritableTopologyMapSerializer(serializers.ModelSerializer): class Meta: model = TopologyMap - fields = ['name', 'slug', 'site', 'device_patterns', 'description'] + fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 48f01d4fa..5a350acb7 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -35,7 +35,7 @@ class WritableVRFSerializer(serializers.ModelSerializer): class Meta: model = VRF - fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] + fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description'] # @@ -100,7 +100,7 @@ class WritableAggregateSerializer(serializers.ModelSerializer): class Meta: model = Aggregate - fields = ['prefix', 'rir', 'date_added', 'description'] + fields = ['id', 'prefix', 'rir', 'date_added', 'description'] # @@ -127,7 +127,7 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer): class Meta: model = VLANGroup - fields = ['name', 'slug', 'site'] + fields = ['id', 'name', 'slug', 'site'] validators = [] def validate(self, data): @@ -173,7 +173,7 @@ class WritableVLANSerializer(serializers.ModelSerializer): class Meta: model = VLAN - fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] + fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] validators = [] def validate(self, data): @@ -220,7 +220,7 @@ class WritablePrefixSerializer(serializers.ModelSerializer): class Meta: model = Prefix - fields = ['prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description'] + fields = ['id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description'] # @@ -256,7 +256,7 @@ class WritableIPAddressSerializer(serializers.ModelSerializer): class Meta: model = IPAddress - fields = ['address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside'] + fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside'] # @@ -277,4 +277,4 @@ class WritableServiceSerializer(serializers.ModelSerializer): class Meta: model = Service - fields = ['device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] + fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index f2a9a4aa3..1dc52388a 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -42,7 +42,7 @@ class WritableSecretSerializer(serializers.ModelSerializer): class Meta: model = Secret - fields = ['device', 'role', 'name', 'plaintext'] + fields = ['id', 'device', 'role', 'name', 'plaintext'] validators = [] def validate(self, data): diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index d4b9e18be..67231fe67 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -47,4 +47,4 @@ class WritableTenantSerializer(serializers.ModelSerializer): class Meta: model = Tenant - fields = ['name', 'slug', 'group', 'description', 'comments'] + fields = ['id', 'name', 'slug', 'group', 'description', 'comments'] From 97710a45761ff6220b94df4d518151e31eb8ef5d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Mar 2017 17:39:56 -0400 Subject: [PATCH 080/182] Make CI happy --- netbox/extras/api/customfields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 5aa737da0..e55aad3a7 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -45,4 +45,4 @@ class CustomFieldChoiceSerializer(serializers.ModelSerializer): class Meta: model = CustomFieldChoice - fields = ['id', 'value'] \ No newline at end of file + fields = ['id', 'value'] From 671d53877a8df380a7651d370d0b82863762785e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Mar 2017 21:39:29 -0400 Subject: [PATCH 081/182] Python3 fixes --- netbox/secrets/models.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 1761094fc..7d7b1cd50 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -23,7 +23,7 @@ def generate_random_key(bits=256): """ if bits % 32: raise Exception("Invalid key size ({}). Key sizes must be in increments of 32 bits.".format(bits)) - return os.urandom(bits / 8) + return os.urandom(int(bits / 8)) def encrypt_master_key(master_key, public_key): @@ -224,7 +224,7 @@ class SessionKey(models.Model): raise InvalidSessionKey() # Decrypt master key using provided session key - master_key = xor_keys(session_key, self.cipher) + master_key = xor_keys(session_key, bytes(self.cipher)) return master_key @@ -361,9 +361,10 @@ class Secret(CreatedUpdatedModel): raise Exception("Must define ciphertext before unlocking.") # Decrypt ciphertext and remove padding - iv = self.ciphertext[0:16] + iv = bytes(self.ciphertext[0:16]) + ciphertext = bytes(self.ciphertext[16:]) aes = AES.new(secret_key, AES.MODE_CFB, iv) - plaintext = self._unpad(aes.decrypt(self.ciphertext[16:])) + plaintext = self._unpad(aes.decrypt(ciphertext)) # Verify decrypted plaintext against hash if not self.validate(plaintext): From 36bbcc855933dfbf8a4dc5d91949df5dd1d50e85 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Mar 2017 10:06:25 -0400 Subject: [PATCH 082/182] Fix API JS to read response.results for new API --- netbox/project-static/js/forms.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index e421f6283..c72f85fad 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -1,7 +1,7 @@ $(document).ready(function() { // "Toggle all" checkbox (table header) - $('#toggle_all').click(function (event) { + $('#toggle_all').click(function() { $('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked')); if ($(this).is(':checked')) { $('#select_all_box').removeClass('hidden'); @@ -10,7 +10,7 @@ $(document).ready(function() { } }); // Enable hidden buttons when "select all" is checked - $('#select_all').click(function (event) { + $('#select_all').click(function() { if ($(this).is(':checked')) { $('#select_all_box').find('button').prop('disabled', ''); } else { @@ -25,7 +25,7 @@ $(document).ready(function() { }); // Simple "Toggle all" button (panel) - $('button.toggle').click(function (event) { + $('button.toggle').click(function() { var selected = $(this).attr('selected'); $(this).closest('form').find('input:checkbox[name=pk]').prop('checked', !selected); $(this).attr('selected', !selected); @@ -55,12 +55,12 @@ $(document).ready(function() { } // Bulk edit nullification - $('input:checkbox[name=_nullify]').click(function (event) { + $('input:checkbox[name=_nullify]').click(function() { $('#id_' + this.value).toggle('disabled'); }); // Set formaction and submit using a link - $('a.formaction').click(function (event) { + $('a.formaction').click(function(event) { event.preventDefault(); var form = $(this).closest('form'); form.attr('action', $(this).attr('href')); @@ -103,8 +103,8 @@ $(document).ready(function() { $.ajax({ url: api_url, dataType: 'json', - success: function (response, status) { - $.each(response, function (index, choice) { + success: function(response, status) { + $.each(response.results, function(index, choice) { var option = $("").attr("value", choice.id).text(choice[display_field]); if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) { option.attr("disabled", "disabled"); From 1f78462f586d067593dcaf594e14c30fbec55b9d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Mar 2017 10:38:09 -0400 Subject: [PATCH 083/182] Updated RackViewSet() to be compatible with paginated API --- netbox/dcim/api/serializers.py | 30 ++++++++++++++++++++++-------- netbox/dcim/api/views.py | 13 ++++--------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 6b285620f..d4e403598 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -175,6 +175,28 @@ class WritableRackSerializer(serializers.ModelSerializer): return data +# +# Rack units +# + +class NestedDeviceSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') + + class Meta: + model = Device + fields = ['id', 'url', 'name', 'display_name'] + + +class RackUnitSerializer(serializers.Serializer): + """ + A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database. + """ + id = serializers.IntegerField(read_only=True) + name = serializers.CharField(read_only=True) + face = serializers.IntegerField(read_only=True) + device = NestedDeviceSerializer(read_only=True) + + # # Rack reservations # @@ -454,14 +476,6 @@ class DeviceSerializer(CustomFieldModelSerializer): } -class NestedDeviceSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') - - class Meta: - model = Device - fields = ['id', 'url', 'name', 'display_name'] - - class WritableDeviceSerializer(serializers.ModelSerializer): class Meta: diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 0e1923ce6..ac4c4cb56 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -96,15 +96,10 @@ class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet): exclude_pk = None elevation = rack.get_rack_units(face, exclude_pk) - # Serialize Devices within the rack elevation - for u in elevation: - if u['device']: - u['device'] = serializers.NestedDeviceSerializer( - instance=u['device'], - context={'request': request}, - ).data - - return Response(elevation) + page = self.paginate_queryset(elevation) + if page is not None: + rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request}) + return self.get_paginated_response(rack_units.data) # From 517eaa8b80ab0a801915fe8e5fd80d9e6ed8212c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Mar 2017 12:18:18 -0400 Subject: [PATCH 084/182] Expanded API documentation --- docs/api/authentication.md | 48 +++++++++ docs/api/examples.md | 138 +++++++++++++++++++++++++ docs/api/{structure.md => overview.md} | 61 ++++++++++- mkdocs.yml | 4 +- 4 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 docs/api/authentication.md create mode 100644 docs/api/examples.md rename docs/api/{structure.md => overview.md} (57%) diff --git a/docs/api/authentication.md b/docs/api/authentication.md new file mode 100644 index 000000000..a2a8648a3 --- /dev/null +++ b/docs/api/authentication.md @@ -0,0 +1,48 @@ +The NetBox API employs token-based authentication. For convenience, cookie authentication can also be used when navigating the browsable API. + +# Tokens + +A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`. + +Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. + +By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. + +Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. + +# Authenticating to the API + +By default, read operations will be available without authentication. In this case, a token may be included in the request, but is not necessary. + +``` +$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/ +{ + "count": 10, + "next": null, + "previous": null, + "results": [...] +} +``` + +However, if the `[LOGIN_REQUIRED](../configuration/optional-settings/#login_required)` configuration setting has been set to `True`, all requests must be authenticated. + +``` +$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/ +{ + "detail": "Authentication credentials were not provided." +} +``` + +To authenticate to the API, set the HTTP `Authorization` header to the string `Token ` (note the trailing space) followed by the token key. + +``` +$ curl -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/ +{ + "count": 10, + "next": null, + "previous": null, + "results": [...] +} +``` + +Additionally, the browsable interface to the API (which can be seen by navigating to the API root `/api/` in a web browser) will attempt to authenticate requests using the same cookie that the normal NetBox front end uses. Thus, if you have logged into NetBox, you will be logged into the browsable API as well. diff --git a/docs/api/examples.md b/docs/api/examples.md new file mode 100644 index 000000000..5082534bc --- /dev/null +++ b/docs/api/examples.md @@ -0,0 +1,138 @@ +# API Examples + +Supported HTTP methods: + +* `GET`: Retrieve an object or list of objects +* `POST`: Create a new object +* `PUT`: Update an existing object +* `DELETE`: Delete an existing object + +To authenticate a request, attach your token in an `Authorization` header: + +``` +curl -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" +``` + +### Retrieving a list of sites + +Send a `GET` request to the object list endpoint. The response contains a paginated list of JSON objects. + +``` +$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/ +{ + "count": 14, + "next": null, + "previous": null, + "results": [ + { + "id": 6, + "name": "Corporate HQ", + "slug": "corporate-hq", + "region": null, + "tenant": null, + "facility": "", + "asn": null, + "physical_address": "742 Evergreen Terrace, Springfield, USA", + "shipping_address": "", + "contact_name": "", + "contact_phone": "", + "contact_email": "", + "comments": "", + "custom_fields": {}, + "count_prefixes": 108, + "count_vlans": 46, + "count_racks": 8, + "count_devices": 254, + "count_circuits": 6 + }, + ... + ] +} +``` + +### Retrieving a single site by ID + +Send a `GET` request to the object detail endpoint. The response contains a single JSON object. + +``` +$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/6/ +{ + "id": 6, + "name": "Corporate HQ", + "slug": "corporate-hq", + "region": null, + "tenant": null, + "facility": "", + "asn": null, + "physical_address": "742 Evergreen Terrace, Springfield, USA", + "shipping_address": "", + "contact_name": "", + "contact_phone": "", + "contact_email": "", + "comments": "", + "custom_fields": {}, + "count_prefixes": 108, + "count_vlans": 46, + "count_racks": 8, + "count_devices": 254, + "count_circuits": 6 +} +``` + +### Creating a new site + +Send a `POST` request to the site list endpoint with token authentication and JSON-formatted data. Only mandatory fields are required. + +``` +$ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/ --data '{"name": "My New Site", "slug": "my-new-site"}' +{ + "id": 16, + "name": "My New Site", + "slug": "my-new-site", + "region": null, + "tenant": null, + "facility": "", + "asn": null, + "physical_address": "", + "shipping_address": "", + "contact_name": "", + "contact_phone": "", + "contact_email": "", + "comments": "" +} +``` + +### Modify an existing site + +Make an authenticated `PUT` request to the site detail endpoint. As with a create (POST) request, all mandatory fields must be included. + +``` +$ curl -X PUT -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/ --data '{"name": "Renamed Site", "slug": "renamed-site"}' +``` + +### Delete an existing site + +Send an authenticated `DELETE` request to the site detail endpoint. + +``` +$ curl -v X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/ +* Connected to localhost (127.0.0.1) port 8000 (#0) +> DELETE /api/dcim/sites/16/ HTTP/1.1 +> User-Agent: curl/7.35.0 +> Host: localhost:8000 +> Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0 +> Content-Type: application/json +> Accept: application/json; indent=4 +> +* HTTP 1.0, assume close after body +< HTTP/1.0 204 No Content +< Date: Mon, 20 Mar 2017 16:13:08 GMT +< Server: WSGIServer/0.1 Python/2.7.6 +< Vary: Accept, Cookie +< X-Frame-Options: SAMEORIGIN +< Allow: GET, PUT, PATCH, DELETE, OPTIONS +< +* Closing connection 0 +``` + +The response to a successfull `DELETE` request will have code 204 (No Content); the body of the response will be empty. diff --git a/docs/api/structure.md b/docs/api/overview.md similarity index 57% rename from docs/api/structure.md rename to docs/api/overview.md index f61082b38..c020d49c4 100644 --- a/docs/api/structure.md +++ b/docs/api/overview.md @@ -1,14 +1,21 @@ +NetBox v2.0 and later includes a full-featured REST API that allows its data model to be read and manipulated externally. + # URL Hierarchy -NetBox's entire REST API is housed under the API root, `/api/`. The API's URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, secrets, and tenancy. Within each application, each model has its own path. For example: +NetBox's entire REST API is housed under the API root, `/api/`. The API's URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, secrets, and tenancy. Within each application, each model has its own path. For example, the provider and circuit objects are located under the "circuits" application: * /api/circuits/providers/ * /api/circuits/circuits/ + +Likewise, the site, rack, and device objects are located under the "DCIM" application: + * /api/dcim/sites/ * /api/dcim/racks/ * /api/dcim/devices/ -Each model generally has two URLs associated with it: a list URL and a detail URL. The list URL is used to request a list of multiple objects or to create a new object. The detail URL is used to retrieve, update, or delete an existing object. +The full hierarchy of available endpoints can be viewed by navigating to the API root (e.g. /api/) in a web browser. + +Each model generally has two URLs associated with it: a list URL and a detail URL. The list URL is used to request a list of multiple objects or to create a new object. The detail URL is used to retrieve, update, or delete an existing object. All objects are referenced by their numeric primary key (ID). * /api/dcim/devices/ - List devices or create a new device * /api/dcim/devices/123/ - Retrieve, update, or delete the device with ID 123 @@ -19,7 +26,7 @@ Lists of objects can be filtered using a set of query parameters. For example, t GET /api/dcim/interfaces/?device_id=123 ``` -# Serializers +# Serialization The NetBox API employs three types of serializers to represent model data: @@ -29,6 +36,54 @@ The NetBox API employs three types of serializers to represent model data: The base serializer is used to represent the default view of a model. This includes all database table fields which comprise the model, and may include additional metadata. A base serializer includes relationships to parent objects, but **does not** include child objects. For example, the `VLANSerializer` includes a nested representation its parent VLANGroup (if any), but does not include any assigned Prefixes. +``` +{ + "id": 1048, + "site": { + "id": 7, + "url": "http://localhost:8000/api/dcim/sites/7/", + "name": "Corporate HQ", + "slug": "corporate-hq" + }, + "group": { + "id": 4, + "url": "http://localhost:8000/api/ipam/vlan-groups/4/", + "name": "Production", + "slug": "production" + }, + "vid": 101, + "name": "Users-Floor1", + "tenant": null, + "status": [ + 1, + "Active" + ], + "role": { + "id": 9, + "url": "http://localhost:8000/api/ipam/roles/9/", + "name": "User Access", + "slug": "user-access" + }, + "description": "", + "display_name": "101 (Users-Floor1)", + "custom_fields": {} +} +``` + Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object. + +``` +{ + "id": 1201, + "site": 7, + "group": 4, + "vid": 102, + "name": "Users-Floor2", + "tenant": null, + "status": 1, + "role": 9, + "description": "" +} +``` diff --git a/mkdocs.yml b/mkdocs.yml index 9833f8832..c0ee34356 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -20,7 +20,9 @@ pages: - 'Tenancy': 'data-model/tenancy.md' - 'Extras': 'data-model/extras.md' - 'API': - - 'Structure': 'api/structure.md' + - 'Overview': 'api/overview.md' + - 'Authentication': 'api/authentication.md' + - 'Examples': 'api/examples.md' markdown_extensions: - admonition: From 1988c02b7f6953259a0634e3ade21166311251e3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Mar 2017 12:33:42 -0400 Subject: [PATCH 085/182] Enforce API versioning --- netbox/netbox/settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 454e4ccd5..c79175f45 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -184,6 +184,7 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH) SECRETS_MIN_PUBKEY_SIZE = 2048 # Django REST framework (API) +REST_FRAMEWORK_VERSION = VERSION.rsplit('.', 1)[0], # Use major.minor as API version REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.SessionAuthentication', @@ -196,7 +197,8 @@ REST_FRAMEWORK = { 'DEFAULT_PERMISSION_CLASSES': ( 'utilities.api.TokenPermissions', ), - 'DEFAULT_VERSION': VERSION.rsplit('.', 1)[0], # Use major.minor as API version + 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, + 'ALLOWED_VERSIONS': REST_FRAMEWORK_VERSION, 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', 'PAGE_SIZE': PAGINATE_COUNT, } From 42fd14f5c0b052e8ef1084346821978f819d379a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Mar 2017 13:46:47 -0400 Subject: [PATCH 086/182] Introduced HttpStatusMixin to provide more detail on HTTP response status test failures --- netbox/circuits/tests/test_api.py | 33 ++--- netbox/dcim/tests/test_api.py | 201 +++++++++++++++--------------- netbox/ipam/tests/test_api.py | 73 +++++------ netbox/secrets/tests/test_api.py | 17 +-- netbox/tenancy/tests/test_api.py | 17 +-- netbox/utilities/tests.py | 10 ++ 6 files changed, 183 insertions(+), 168 deletions(-) create mode 100644 netbox/utilities/tests.py diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index cd456d86c..a6e5fbd57 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -7,9 +7,10 @@ from django.urls import reverse from dcim.models import Site from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z from users.models import Token +from utilities.tests import HttpStatusMixin -class ProviderTest(APITestCase): +class ProviderTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -45,7 +46,7 @@ class ProviderTest(APITestCase): url = reverse('circuits-api:provider-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Provider.objects.count(), 4) provider4 = Provider.objects.get(pk=response.data['id']) self.assertEqual(provider4.name, data['name']) @@ -61,7 +62,7 @@ class ProviderTest(APITestCase): url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Provider.objects.count(), 3) provider1 = Provider.objects.get(pk=response.data['id']) self.assertEqual(provider1.name, data['name']) @@ -72,11 +73,11 @@ class ProviderTest(APITestCase): url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Provider.objects.count(), 2) -class CircuitTypeTest(APITestCase): +class CircuitTypeTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -112,7 +113,7 @@ class CircuitTypeTest(APITestCase): url = reverse('circuits-api:circuittype-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(CircuitType.objects.count(), 4) circuittype4 = CircuitType.objects.get(pk=response.data['id']) self.assertEqual(circuittype4.name, data['name']) @@ -128,7 +129,7 @@ class CircuitTypeTest(APITestCase): url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(CircuitType.objects.count(), 3) circuittype1 = CircuitType.objects.get(pk=response.data['id']) self.assertEqual(circuittype1.name, data['name']) @@ -139,11 +140,11 @@ class CircuitTypeTest(APITestCase): url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(CircuitType.objects.count(), 2) -class CircuitTest(APITestCase): +class CircuitTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -184,7 +185,7 @@ class CircuitTest(APITestCase): url = reverse('circuits-api:circuit-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Circuit.objects.count(), 4) circuit4 = Circuit.objects.get(pk=response.data['id']) self.assertEqual(circuit4.cid, data['cid']) @@ -202,7 +203,7 @@ class CircuitTest(APITestCase): url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Circuit.objects.count(), 3) circuit1 = Circuit.objects.get(pk=response.data['id']) self.assertEqual(circuit1.cid, data['cid']) @@ -214,11 +215,11 @@ class CircuitTest(APITestCase): url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Circuit.objects.count(), 2) -class CircuitTerminationTest(APITestCase): +class CircuitTerminationTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -269,7 +270,7 @@ class CircuitTerminationTest(APITestCase): url = reverse('circuits-api:circuittermination-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(CircuitTermination.objects.count(), 4) circuittermination4 = CircuitTermination.objects.get(pk=response.data['id']) self.assertEqual(circuittermination4.circuit_id, data['circuit']) @@ -289,7 +290,7 @@ class CircuitTerminationTest(APITestCase): url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(CircuitTermination.objects.count(), 3) circuittermination1 = CircuitTermination.objects.get(pk=response.data['id']) self.assertEqual(circuittermination1.circuit_id, data['circuit']) @@ -302,5 +303,5 @@ class CircuitTerminationTest(APITestCase): url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(CircuitTermination.objects.count(), 2) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 67ecdbe3f..74751d8be 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -11,9 +11,10 @@ from dcim.models import ( RackReservation, RackRole, Region, Site, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, ) from users.models import Token +from utilities.tests import HttpStatusMixin -class RegionTest(APITestCase): +class RegionTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -49,7 +50,7 @@ class RegionTest(APITestCase): url = reverse('dcim-api:region-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Region.objects.count(), 4) region4 = Region.objects.get(pk=response.data['id']) self.assertEqual(region4.name, data['name']) @@ -65,7 +66,7 @@ class RegionTest(APITestCase): url = reverse('dcim-api:region-detail', kwargs={'pk': self.region1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Region.objects.count(), 3) region1 = Region.objects.get(pk=response.data['id']) self.assertEqual(region1.name, data['name']) @@ -76,11 +77,11 @@ class RegionTest(APITestCase): url = reverse('dcim-api:region-detail', kwargs={'pk': self.region1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Region.objects.count(), 2) -class SiteTest(APITestCase): +class SiteTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -119,7 +120,7 @@ class SiteTest(APITestCase): url = reverse('dcim-api:site-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Site.objects.count(), 4) site4 = Site.objects.get(pk=response.data['id']) self.assertEqual(site4.name, data['name']) @@ -137,7 +138,7 @@ class SiteTest(APITestCase): url = reverse('dcim-api:site-detail', kwargs={'pk': self.site1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Site.objects.count(), 3) site1 = Site.objects.get(pk=response.data['id']) self.assertEqual(site1.name, data['name']) @@ -149,11 +150,11 @@ class SiteTest(APITestCase): url = reverse('dcim-api:site-detail', kwargs={'pk': self.site1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Site.objects.count(), 2) -class RackGroupTest(APITestCase): +class RackGroupTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -192,7 +193,7 @@ class RackGroupTest(APITestCase): url = reverse('dcim-api:rackgroup-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(RackGroup.objects.count(), 4) rackgroup4 = RackGroup.objects.get(pk=response.data['id']) self.assertEqual(rackgroup4.name, data['name']) @@ -210,7 +211,7 @@ class RackGroupTest(APITestCase): url = reverse('dcim-api:rackgroup-detail', kwargs={'pk': self.rackgroup1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(RackGroup.objects.count(), 3) rackgroup1 = RackGroup.objects.get(pk=response.data['id']) self.assertEqual(rackgroup1.name, data['name']) @@ -222,11 +223,11 @@ class RackGroupTest(APITestCase): url = reverse('dcim-api:rackgroup-detail', kwargs={'pk': self.rackgroup1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(RackGroup.objects.count(), 2) -class RackRoleTest(APITestCase): +class RackRoleTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -263,7 +264,7 @@ class RackRoleTest(APITestCase): url = reverse('dcim-api:rackrole-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(RackRole.objects.count(), 4) rackrole1 = RackRole.objects.get(pk=response.data['id']) self.assertEqual(rackrole1.name, data['name']) @@ -281,7 +282,7 @@ class RackRoleTest(APITestCase): url = reverse('dcim-api:rackrole-detail', kwargs={'pk': self.rackrole1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(RackRole.objects.count(), 3) rackrole1 = RackRole.objects.get(pk=response.data['id']) self.assertEqual(rackrole1.name, data['name']) @@ -293,11 +294,11 @@ class RackRoleTest(APITestCase): url = reverse('dcim-api:rackrole-detail', kwargs={'pk': self.rackrole1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(RackRole.objects.count(), 2) -class RackTest(APITestCase): +class RackTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -347,7 +348,7 @@ class RackTest(APITestCase): url = reverse('dcim-api:rack-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Rack.objects.count(), 4) rack4 = Rack.objects.get(pk=response.data['id']) self.assertEqual(rack4.name, data['name']) @@ -367,7 +368,7 @@ class RackTest(APITestCase): url = reverse('dcim-api:rack-detail', kwargs={'pk': self.rack1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Rack.objects.count(), 3) rack1 = Rack.objects.get(pk=response.data['id']) self.assertEqual(rack1.name, data['name']) @@ -380,11 +381,11 @@ class RackTest(APITestCase): url = reverse('dcim-api:rack-detail', kwargs={'pk': self.rack1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Rack.objects.count(), 2) -class RackReservationTest(APITestCase): +class RackReservationTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -431,7 +432,7 @@ class RackReservationTest(APITestCase): url = reverse('dcim-api:rackreservation-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(RackReservation.objects.count(), 4) rackreservation4 = RackReservation.objects.get(pk=response.data['id']) self.assertEqual(rackreservation4.rack_id, data['rack']) @@ -451,7 +452,7 @@ class RackReservationTest(APITestCase): url = reverse('dcim-api:rackreservation-detail', kwargs={'pk': self.rackreservation1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(RackReservation.objects.count(), 3) rackreservation1 = RackReservation.objects.get(pk=response.data['id']) self.assertEqual(rackreservation1.units, data['units']) @@ -462,11 +463,11 @@ class RackReservationTest(APITestCase): url = reverse('dcim-api:rackreservation-detail', kwargs={'pk': self.rackreservation1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(RackReservation.objects.count(), 2) -class ManufacturerTest(APITestCase): +class ManufacturerTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -502,7 +503,7 @@ class ManufacturerTest(APITestCase): url = reverse('dcim-api:manufacturer-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Manufacturer.objects.count(), 4) manufacturer4 = Manufacturer.objects.get(pk=response.data['id']) self.assertEqual(manufacturer4.name, data['name']) @@ -518,7 +519,7 @@ class ManufacturerTest(APITestCase): url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': self.manufacturer1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Manufacturer.objects.count(), 3) manufacturer1 = Manufacturer.objects.get(pk=response.data['id']) self.assertEqual(manufacturer1.name, data['name']) @@ -529,11 +530,11 @@ class ManufacturerTest(APITestCase): url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': self.manufacturer1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Manufacturer.objects.count(), 2) -class DeviceTypeTest(APITestCase): +class DeviceTypeTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -578,7 +579,7 @@ class DeviceTypeTest(APITestCase): url = reverse('dcim-api:devicetype-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(DeviceType.objects.count(), 4) devicetype4 = DeviceType.objects.get(pk=response.data['id']) self.assertEqual(devicetype4.manufacturer_id, data['manufacturer']) @@ -596,7 +597,7 @@ class DeviceTypeTest(APITestCase): url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.devicetype1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(DeviceType.objects.count(), 3) devicetype1 = DeviceType.objects.get(pk=response.data['id']) self.assertEqual(devicetype1.manufacturer_id, data['manufacturer']) @@ -608,11 +609,11 @@ class DeviceTypeTest(APITestCase): url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.devicetype1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(DeviceType.objects.count(), 2) -class ConsolePortTemplateTest(APITestCase): +class ConsolePortTemplateTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -658,7 +659,7 @@ class ConsolePortTemplateTest(APITestCase): url = reverse('dcim-api:consoleporttemplate-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(ConsolePortTemplate.objects.count(), 4) consoleporttemplate4 = ConsolePortTemplate.objects.get(pk=response.data['id']) self.assertEqual(consoleporttemplate4.device_type_id, data['device_type']) @@ -674,7 +675,7 @@ class ConsolePortTemplateTest(APITestCase): url = reverse('dcim-api:consoleporttemplate-detail', kwargs={'pk': self.consoleporttemplate1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(ConsolePortTemplate.objects.count(), 3) consoleporttemplate1 = ConsolePortTemplate.objects.get(pk=response.data['id']) self.assertEqual(consoleporttemplate1.name, data['name']) @@ -684,11 +685,11 @@ class ConsolePortTemplateTest(APITestCase): url = reverse('dcim-api:consoleporttemplate-detail', kwargs={'pk': self.consoleporttemplate1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(ConsolePortTemplate.objects.count(), 2) -class ConsoleServerPortTemplateTest(APITestCase): +class ConsoleServerPortTemplateTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -734,7 +735,7 @@ class ConsoleServerPortTemplateTest(APITestCase): url = reverse('dcim-api:consoleserverporttemplate-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(ConsoleServerPortTemplate.objects.count(), 4) consoleserverporttemplate4 = ConsoleServerPortTemplate.objects.get(pk=response.data['id']) self.assertEqual(consoleserverporttemplate4.device_type_id, data['device_type']) @@ -750,7 +751,7 @@ class ConsoleServerPortTemplateTest(APITestCase): url = reverse('dcim-api:consoleserverporttemplate-detail', kwargs={'pk': self.consoleserverporttemplate1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(ConsoleServerPortTemplate.objects.count(), 3) consoleserverporttemplate1 = ConsoleServerPortTemplate.objects.get(pk=response.data['id']) self.assertEqual(consoleserverporttemplate1.name, data['name']) @@ -760,11 +761,11 @@ class ConsoleServerPortTemplateTest(APITestCase): url = reverse('dcim-api:consoleserverporttemplate-detail', kwargs={'pk': self.consoleserverporttemplate1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(ConsoleServerPortTemplate.objects.count(), 2) -class PowerPortTemplateTest(APITestCase): +class PowerPortTemplateTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -810,7 +811,7 @@ class PowerPortTemplateTest(APITestCase): url = reverse('dcim-api:powerporttemplate-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(PowerPortTemplate.objects.count(), 4) powerporttemplate4 = PowerPortTemplate.objects.get(pk=response.data['id']) self.assertEqual(powerporttemplate4.device_type_id, data['device_type']) @@ -826,7 +827,7 @@ class PowerPortTemplateTest(APITestCase): url = reverse('dcim-api:powerporttemplate-detail', kwargs={'pk': self.powerporttemplate1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(PowerPortTemplate.objects.count(), 3) powerporttemplate1 = PowerPortTemplate.objects.get(pk=response.data['id']) self.assertEqual(powerporttemplate1.name, data['name']) @@ -836,11 +837,11 @@ class PowerPortTemplateTest(APITestCase): url = reverse('dcim-api:powerporttemplate-detail', kwargs={'pk': self.powerporttemplate1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(PowerPortTemplate.objects.count(), 2) -class PowerOutletTemplateTest(APITestCase): +class PowerOutletTemplateTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -886,7 +887,7 @@ class PowerOutletTemplateTest(APITestCase): url = reverse('dcim-api:poweroutlettemplate-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(PowerOutletTemplate.objects.count(), 4) poweroutlettemplate4 = PowerOutletTemplate.objects.get(pk=response.data['id']) self.assertEqual(poweroutlettemplate4.device_type_id, data['device_type']) @@ -902,7 +903,7 @@ class PowerOutletTemplateTest(APITestCase): url = reverse('dcim-api:poweroutlettemplate-detail', kwargs={'pk': self.poweroutlettemplate1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(PowerOutletTemplate.objects.count(), 3) poweroutlettemplate1 = PowerOutletTemplate.objects.get(pk=response.data['id']) self.assertEqual(poweroutlettemplate1.name, data['name']) @@ -912,11 +913,11 @@ class PowerOutletTemplateTest(APITestCase): url = reverse('dcim-api:poweroutlettemplate-detail', kwargs={'pk': self.poweroutlettemplate1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(PowerOutletTemplate.objects.count(), 2) -class InterfaceTemplateTest(APITestCase): +class InterfaceTemplateTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -962,7 +963,7 @@ class InterfaceTemplateTest(APITestCase): url = reverse('dcim-api:interfacetemplate-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(InterfaceTemplate.objects.count(), 4) interfacetemplate4 = InterfaceTemplate.objects.get(pk=response.data['id']) self.assertEqual(interfacetemplate4.device_type_id, data['device_type']) @@ -978,7 +979,7 @@ class InterfaceTemplateTest(APITestCase): url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(InterfaceTemplate.objects.count(), 3) interfacetemplate1 = InterfaceTemplate.objects.get(pk=response.data['id']) self.assertEqual(interfacetemplate1.name, data['name']) @@ -988,11 +989,11 @@ class InterfaceTemplateTest(APITestCase): url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(InterfaceTemplate.objects.count(), 2) -class DeviceBayTemplateTest(APITestCase): +class DeviceBayTemplateTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -1038,7 +1039,7 @@ class DeviceBayTemplateTest(APITestCase): url = reverse('dcim-api:devicebaytemplate-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(DeviceBayTemplate.objects.count(), 4) devicebaytemplate4 = DeviceBayTemplate.objects.get(pk=response.data['id']) self.assertEqual(devicebaytemplate4.device_type_id, data['device_type']) @@ -1054,7 +1055,7 @@ class DeviceBayTemplateTest(APITestCase): url = reverse('dcim-api:devicebaytemplate-detail', kwargs={'pk': self.devicebaytemplate1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(DeviceBayTemplate.objects.count(), 3) devicebaytemplate1 = DeviceBayTemplate.objects.get(pk=response.data['id']) self.assertEqual(devicebaytemplate1.name, data['name']) @@ -1064,11 +1065,11 @@ class DeviceBayTemplateTest(APITestCase): url = reverse('dcim-api:devicebaytemplate-detail', kwargs={'pk': self.devicebaytemplate1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(DeviceBayTemplate.objects.count(), 2) -class DeviceRoleTest(APITestCase): +class DeviceRoleTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -1111,7 +1112,7 @@ class DeviceRoleTest(APITestCase): url = reverse('dcim-api:devicerole-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(DeviceRole.objects.count(), 4) devicerole4 = DeviceRole.objects.get(pk=response.data['id']) self.assertEqual(devicerole4.name, data['name']) @@ -1129,7 +1130,7 @@ class DeviceRoleTest(APITestCase): url = reverse('dcim-api:devicerole-detail', kwargs={'pk': self.devicerole1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(DeviceRole.objects.count(), 3) devicerole1 = DeviceRole.objects.get(pk=response.data['id']) self.assertEqual(devicerole1.name, data['name']) @@ -1141,11 +1142,11 @@ class DeviceRoleTest(APITestCase): url = reverse('dcim-api:devicerole-detail', kwargs={'pk': self.devicerole1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(DeviceRole.objects.count(), 2) -class PlatformTest(APITestCase): +class PlatformTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -1181,7 +1182,7 @@ class PlatformTest(APITestCase): url = reverse('dcim-api:platform-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Platform.objects.count(), 4) platform4 = Platform.objects.get(pk=response.data['id']) self.assertEqual(platform4.name, data['name']) @@ -1197,7 +1198,7 @@ class PlatformTest(APITestCase): url = reverse('dcim-api:platform-detail', kwargs={'pk': self.platform1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Platform.objects.count(), 3) platform1 = Platform.objects.get(pk=response.data['id']) self.assertEqual(platform1.name, data['name']) @@ -1208,11 +1209,11 @@ class PlatformTest(APITestCase): url = reverse('dcim-api:platform-detail', kwargs={'pk': self.platform1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Platform.objects.count(), 2) -class DeviceTest(APITestCase): +class DeviceTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -1271,7 +1272,7 @@ class DeviceTest(APITestCase): url = reverse('dcim-api:device-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Device.objects.count(), 4) device4 = Device.objects.get(pk=response.data['id']) self.assertEqual(device4.device_type_id, data['device_type']) @@ -1291,7 +1292,7 @@ class DeviceTest(APITestCase): url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Device.objects.count(), 3) device1 = Device.objects.get(pk=response.data['id']) self.assertEqual(device1.device_type_id, data['device_type']) @@ -1304,11 +1305,11 @@ class DeviceTest(APITestCase): url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Device.objects.count(), 2) -class ConsolePortTest(APITestCase): +class ConsolePortTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -1355,7 +1356,7 @@ class ConsolePortTest(APITestCase): url = reverse('dcim-api:consoleport-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(ConsolePort.objects.count(), 4) consoleport4 = ConsolePort.objects.get(pk=response.data['id']) self.assertEqual(consoleport4.device_id, data['device']) @@ -1374,7 +1375,7 @@ class ConsolePortTest(APITestCase): url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(ConsolePort.objects.count(), 3) consoleport1 = ConsolePort.objects.get(pk=response.data['id']) self.assertEqual(consoleport1.name, data['name']) @@ -1385,11 +1386,11 @@ class ConsolePortTest(APITestCase): url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(ConsolePort.objects.count(), 2) -class ConsoleServerPortTest(APITestCase): +class ConsoleServerPortTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -1436,7 +1437,7 @@ class ConsoleServerPortTest(APITestCase): url = reverse('dcim-api:consoleserverport-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(ConsoleServerPort.objects.count(), 4) consoleserverport4 = ConsoleServerPort.objects.get(pk=response.data['id']) self.assertEqual(consoleserverport4.device_id, data['device']) @@ -1452,7 +1453,7 @@ class ConsoleServerPortTest(APITestCase): url = reverse('dcim-api:consoleserverport-detail', kwargs={'pk': self.consoleserverport1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(ConsoleServerPort.objects.count(), 3) consoleserverport1 = ConsoleServerPort.objects.get(pk=response.data['id']) self.assertEqual(consoleserverport1.name, data['name']) @@ -1462,11 +1463,11 @@ class ConsoleServerPortTest(APITestCase): url = reverse('dcim-api:consoleserverport-detail', kwargs={'pk': self.consoleserverport1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(ConsoleServerPort.objects.count(), 2) -class PowerPortTest(APITestCase): +class PowerPortTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -1513,7 +1514,7 @@ class PowerPortTest(APITestCase): url = reverse('dcim-api:powerport-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(PowerPort.objects.count(), 4) powerport4 = PowerPort.objects.get(pk=response.data['id']) self.assertEqual(powerport4.device_id, data['device']) @@ -1532,7 +1533,7 @@ class PowerPortTest(APITestCase): url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(PowerPort.objects.count(), 3) powerport1 = PowerPort.objects.get(pk=response.data['id']) self.assertEqual(powerport1.name, data['name']) @@ -1543,11 +1544,11 @@ class PowerPortTest(APITestCase): url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(PowerPort.objects.count(), 2) -class PowerOutletTest(APITestCase): +class PowerOutletTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -1594,7 +1595,7 @@ class PowerOutletTest(APITestCase): url = reverse('dcim-api:poweroutlet-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(PowerOutlet.objects.count(), 4) poweroutlet4 = PowerOutlet.objects.get(pk=response.data['id']) self.assertEqual(poweroutlet4.device_id, data['device']) @@ -1610,7 +1611,7 @@ class PowerOutletTest(APITestCase): url = reverse('dcim-api:poweroutlet-detail', kwargs={'pk': self.poweroutlet1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(PowerOutlet.objects.count(), 3) poweroutlet1 = PowerOutlet.objects.get(pk=response.data['id']) self.assertEqual(poweroutlet1.name, data['name']) @@ -1620,11 +1621,11 @@ class PowerOutletTest(APITestCase): url = reverse('dcim-api:poweroutlet-detail', kwargs={'pk': self.poweroutlet1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(PowerOutlet.objects.count(), 2) -class InterfaceTest(APITestCase): +class InterfaceTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -1671,7 +1672,7 @@ class InterfaceTest(APITestCase): url = reverse('dcim-api:interface-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Interface.objects.count(), 4) interface4 = Interface.objects.get(pk=response.data['id']) self.assertEqual(interface4.device_id, data['device']) @@ -1692,7 +1693,7 @@ class InterfaceTest(APITestCase): url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Interface.objects.count(), 4) interface1 = Interface.objects.get(pk=response.data['id']) self.assertEqual(interface1.name, data['name']) @@ -1703,11 +1704,11 @@ class InterfaceTest(APITestCase): url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Interface.objects.count(), 2) -class DeviceBayTest(APITestCase): +class DeviceBayTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -1763,7 +1764,7 @@ class DeviceBayTest(APITestCase): url = reverse('dcim-api:devicebay-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(DeviceBay.objects.count(), 4) devicebay4 = DeviceBay.objects.get(pk=response.data['id']) self.assertEqual(devicebay4.device_id, data['device']) @@ -1781,7 +1782,7 @@ class DeviceBayTest(APITestCase): url = reverse('dcim-api:devicebay-detail', kwargs={'pk': self.devicebay1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(DeviceBay.objects.count(), 3) devicebay1 = DeviceBay.objects.get(pk=response.data['id']) self.assertEqual(devicebay1.name, data['name']) @@ -1792,11 +1793,11 @@ class DeviceBayTest(APITestCase): url = reverse('dcim-api:devicebay-detail', kwargs={'pk': self.devicebay1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(DeviceBay.objects.count(), 2) -class ModuleTest(APITestCase): +class ModuleTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -1845,7 +1846,7 @@ class ModuleTest(APITestCase): url = reverse('dcim-api:module-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Module.objects.count(), 4) module4 = Module.objects.get(pk=response.data['id']) self.assertEqual(module4.device_id, data['device']) @@ -1865,7 +1866,7 @@ class ModuleTest(APITestCase): url = reverse('dcim-api:module-detail', kwargs={'pk': self.module1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Module.objects.count(), 3) module1 = Module.objects.get(pk=response.data['id']) self.assertEqual(module1.device_id, data['device']) @@ -1878,11 +1879,11 @@ class ModuleTest(APITestCase): url = reverse('dcim-api:module-detail', kwargs={'pk': self.module1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Module.objects.count(), 2) -class InterfaceConnectionTest(APITestCase): +class InterfaceConnectionTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -1944,7 +1945,7 @@ class InterfaceConnectionTest(APITestCase): url = reverse('dcim-api:interfaceconnection-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(InterfaceConnection.objects.count(), 4) interfaceconnection4 = InterfaceConnection.objects.get(pk=response.data['id']) self.assertEqual(interfaceconnection4.interface_a_id, data['interface_a']) @@ -1963,7 +1964,7 @@ class InterfaceConnectionTest(APITestCase): url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(InterfaceConnection.objects.count(), 3) interfaceconnection1 = InterfaceConnection.objects.get(pk=response.data['id']) self.assertEqual(interfaceconnection1.interface_a_id, data['interface_a']) @@ -1975,5 +1976,5 @@ class InterfaceConnectionTest(APITestCase): url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(InterfaceConnection.objects.count(), 2) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 64d8c98a1..cd58f865b 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -11,9 +11,10 @@ from ipam.models import ( Aggregate, IPAddress, IP_PROTOCOL_TCP, IP_PROTOCOL_UDP, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF, ) from users.models import Token +from utilities.tests import HttpStatusMixin -class VRFTest(APITestCase): +class VRFTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -49,7 +50,7 @@ class VRFTest(APITestCase): url = reverse('ipam-api:vrf-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(VRF.objects.count(), 4) vrf4 = VRF.objects.get(pk=response.data['id']) self.assertEqual(vrf4.name, data['name']) @@ -65,7 +66,7 @@ class VRFTest(APITestCase): url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(VRF.objects.count(), 3) vrf1 = VRF.objects.get(pk=response.data['id']) self.assertEqual(vrf1.name, data['name']) @@ -76,11 +77,11 @@ class VRFTest(APITestCase): url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(VRF.objects.count(), 2) -class RIRTest(APITestCase): +class RIRTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -116,7 +117,7 @@ class RIRTest(APITestCase): url = reverse('ipam-api:rir-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(RIR.objects.count(), 4) rir4 = RIR.objects.get(pk=response.data['id']) self.assertEqual(rir4.name, data['name']) @@ -132,7 +133,7 @@ class RIRTest(APITestCase): url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(RIR.objects.count(), 3) rir1 = RIR.objects.get(pk=response.data['id']) self.assertEqual(rir1.name, data['name']) @@ -143,11 +144,11 @@ class RIRTest(APITestCase): url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(RIR.objects.count(), 2) -class AggregateTest(APITestCase): +class AggregateTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -185,7 +186,7 @@ class AggregateTest(APITestCase): url = reverse('ipam-api:aggregate-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Aggregate.objects.count(), 4) aggregate4 = Aggregate.objects.get(pk=response.data['id']) self.assertEqual(str(aggregate4.prefix), data['prefix']) @@ -201,7 +202,7 @@ class AggregateTest(APITestCase): url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Aggregate.objects.count(), 3) aggregate1 = Aggregate.objects.get(pk=response.data['id']) self.assertEqual(str(aggregate1.prefix), data['prefix']) @@ -212,11 +213,11 @@ class AggregateTest(APITestCase): url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Aggregate.objects.count(), 2) -class RoleTest(APITestCase): +class RoleTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -252,7 +253,7 @@ class RoleTest(APITestCase): url = reverse('ipam-api:role-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Role.objects.count(), 4) role4 = Role.objects.get(pk=response.data['id']) self.assertEqual(role4.name, data['name']) @@ -268,7 +269,7 @@ class RoleTest(APITestCase): url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Role.objects.count(), 3) role1 = Role.objects.get(pk=response.data['id']) self.assertEqual(role1.name, data['name']) @@ -279,11 +280,11 @@ class RoleTest(APITestCase): url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Role.objects.count(), 2) -class PrefixTest(APITestCase): +class PrefixTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -326,7 +327,7 @@ class PrefixTest(APITestCase): url = reverse('ipam-api:prefix-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Prefix.objects.count(), 4) prefix4 = Prefix.objects.get(pk=response.data['id']) self.assertEqual(str(prefix4.prefix), data['prefix']) @@ -348,7 +349,7 @@ class PrefixTest(APITestCase): url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Prefix.objects.count(), 3) prefix1 = Prefix.objects.get(pk=response.data['id']) self.assertEqual(str(prefix1.prefix), data['prefix']) @@ -362,11 +363,11 @@ class PrefixTest(APITestCase): url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Prefix.objects.count(), 2) -class IPAddressTest(APITestCase): +class IPAddressTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -403,7 +404,7 @@ class IPAddressTest(APITestCase): url = reverse('ipam-api:ipaddress-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(IPAddress.objects.count(), 4) ipaddress4 = IPAddress.objects.get(pk=response.data['id']) self.assertEqual(str(ipaddress4.address), data['address']) @@ -419,7 +420,7 @@ class IPAddressTest(APITestCase): url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(IPAddress.objects.count(), 3) ipaddress1 = IPAddress.objects.get(pk=response.data['id']) self.assertEqual(str(ipaddress1.address), data['address']) @@ -430,11 +431,11 @@ class IPAddressTest(APITestCase): url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(IPAddress.objects.count(), 2) -class VLANGroupTest(APITestCase): +class VLANGroupTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -470,7 +471,7 @@ class VLANGroupTest(APITestCase): url = reverse('ipam-api:vlangroup-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(VLANGroup.objects.count(), 4) vlangroup4 = VLANGroup.objects.get(pk=response.data['id']) self.assertEqual(vlangroup4.name, data['name']) @@ -486,7 +487,7 @@ class VLANGroupTest(APITestCase): url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(VLANGroup.objects.count(), 3) vlangroup1 = VLANGroup.objects.get(pk=response.data['id']) self.assertEqual(vlangroup1.name, data['name']) @@ -497,11 +498,11 @@ class VLANGroupTest(APITestCase): url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(VLANGroup.objects.count(), 2) -class VLANTest(APITestCase): +class VLANTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -537,7 +538,7 @@ class VLANTest(APITestCase): url = reverse('ipam-api:vlan-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(VLAN.objects.count(), 4) vlan4 = VLAN.objects.get(pk=response.data['id']) self.assertEqual(vlan4.vid, data['vid']) @@ -553,7 +554,7 @@ class VLANTest(APITestCase): url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(VLAN.objects.count(), 3) vlan1 = VLAN.objects.get(pk=response.data['id']) self.assertEqual(vlan1.vid, data['vid']) @@ -564,11 +565,11 @@ class VLANTest(APITestCase): url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(VLAN.objects.count(), 2) -class ServiceTest(APITestCase): +class ServiceTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -622,7 +623,7 @@ class ServiceTest(APITestCase): url = reverse('ipam-api:service-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Service.objects.count(), 4) service4 = Service.objects.get(pk=response.data['id']) self.assertEqual(service4.device_id, data['device']) @@ -642,7 +643,7 @@ class ServiceTest(APITestCase): url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Service.objects.count(), 3) service1 = Service.objects.get(pk=response.data['id']) self.assertEqual(service1.device_id, data['device']) @@ -655,5 +656,5 @@ class ServiceTest(APITestCase): url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Service.objects.count(), 2) diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 99fbc0aaf..854e90882 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -8,6 +8,7 @@ from django.urls import reverse from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey from users.models import Token +from utilities.tests import HttpStatusMixin # Dummy RSA key pair for testing use only @@ -50,7 +51,7 @@ qQIDAQAB -----END PUBLIC KEY-----""" -class SecretRoleTest(APITestCase): +class SecretRoleTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -86,7 +87,7 @@ class SecretRoleTest(APITestCase): url = reverse('secrets-api:secretrole-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(SecretRole.objects.count(), 4) secretrole4 = SecretRole.objects.get(pk=response.data['id']) self.assertEqual(secretrole4.name, data['name']) @@ -102,7 +103,7 @@ class SecretRoleTest(APITestCase): url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(SecretRole.objects.count(), 3) secretrole1 = SecretRole.objects.get(pk=response.data['id']) self.assertEqual(secretrole1.name, data['name']) @@ -113,11 +114,11 @@ class SecretRoleTest(APITestCase): url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(SecretRole.objects.count(), 2) -class SecretTest(APITestCase): +class SecretTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -191,7 +192,7 @@ class SecretTest(APITestCase): url = reverse('secrets-api:secret-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(response.data['plaintext'], data['plaintext']) self.assertEqual(Secret.objects.count(), 4) secret4 = Secret.objects.get(pk=response.data['id']) @@ -210,7 +211,7 @@ class SecretTest(APITestCase): url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['plaintext'], data['plaintext']) self.assertEqual(Secret.objects.count(), 3) secret1 = Secret.objects.get(pk=response.data['id']) @@ -223,5 +224,5 @@ class SecretTest(APITestCase): url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Secret.objects.count(), 2) diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 9d0f925c0..11e89fae1 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -6,9 +6,10 @@ from django.urls import reverse from tenancy.models import Tenant, TenantGroup from users.models import Token +from utilities.tests import HttpStatusMixin -class TenantGroupTest(APITestCase): +class TenantGroupTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -44,7 +45,7 @@ class TenantGroupTest(APITestCase): url = reverse('tenancy-api:tenantgroup-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(TenantGroup.objects.count(), 4) tenantgroup4 = TenantGroup.objects.get(pk=response.data['id']) self.assertEqual(tenantgroup4.name, data['name']) @@ -60,7 +61,7 @@ class TenantGroupTest(APITestCase): url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(TenantGroup.objects.count(), 3) tenantgroup1 = TenantGroup.objects.get(pk=response.data['id']) self.assertEqual(tenantgroup1.name, data['name']) @@ -71,11 +72,11 @@ class TenantGroupTest(APITestCase): url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(TenantGroup.objects.count(), 2) -class TenantTest(APITestCase): +class TenantTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -114,7 +115,7 @@ class TenantTest(APITestCase): url = reverse('tenancy-api:tenant-list') response = self.client.post(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Tenant.objects.count(), 4) tenant4 = Tenant.objects.get(pk=response.data['id']) self.assertEqual(tenant4.name, data['name']) @@ -132,7 +133,7 @@ class TenantTest(APITestCase): url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk}) response = self.client.put(url, data, **self.header) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Tenant.objects.count(), 3) tenant1 = Tenant.objects.get(pk=response.data['id']) self.assertEqual(tenant1.name, data['name']) @@ -144,5 +145,5 @@ class TenantTest(APITestCase): url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk}) response = self.client.delete(url, **self.header) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Tenant.objects.count(), 2) diff --git a/netbox/utilities/tests.py b/netbox/utilities/tests.py new file mode 100644 index 000000000..d80b42c63 --- /dev/null +++ b/netbox/utilities/tests.py @@ -0,0 +1,10 @@ +class HttpStatusMixin(object): + """ + Custom mixin to provide more detail in the event of an unexpected HTTP response. + """ + + def assertHttpStatus(self, response, expected_status): + err_message = "Expected HTTP status {}; received {}: {}" + self.assertEqual(response.status_code, expected_status, err_message.format( + expected_status, response.status_code, response.data + )) From 266f9cc370d1051bbcd627d0b98e18ebe1c96d67 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Mar 2017 15:14:33 -0400 Subject: [PATCH 087/182] Added API endpoint, tests for Graphs --- netbox/circuits/api/views.py | 7 ++- netbox/circuits/tests/test_api.py | 22 ++++++++ netbox/dcim/api/views.py | 12 +++-- netbox/dcim/tests/test_api.py | 43 ++++++++++++++++ netbox/extras/api/serializers.py | 24 +++++++-- netbox/extras/api/urls.py | 3 ++ netbox/extras/api/views.py | 9 +++- netbox/extras/filters.py | 9 +++- netbox/extras/tests/test_api.py | 86 +++++++++++++++++++++++++++++++ 9 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 netbox/extras/tests/test_api.py diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index c8aa7b010..582de3ab1 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -7,7 +7,7 @@ from rest_framework.viewsets import ModelViewSet from circuits import filters from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from extras.models import Graph, GRAPH_TYPE_PROVIDER -from extras.api.serializers import GraphSerializer +from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet from utilities.api import WritableSerializerMixin from . import serializers @@ -25,9 +25,12 @@ class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet): @detail_route() def graphs(self, request, pk=None): + """ + A convenience method for rendering graphs for a particular provider. + """ provider = get_object_or_404(Provider, pk=pk) queryset = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER) - serializer = GraphSerializer(queryset, many=True, context={'graphed_object': provider}) + serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider}) return Response(serializer.data) diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index a6e5fbd57..7bd3d8040 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import User from django.urls import reverse from dcim.models import Site +from extras.models import Graph, GRAPH_TYPE_PROVIDER from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z from users.models import Token from utilities.tests import HttpStatusMixin @@ -29,6 +30,27 @@ class ProviderTest(HttpStatusMixin, APITestCase): self.assertEqual(response.data['name'], self.provider1.name) + def test_get_provider_graphs(self): + + self.graph1 = Graph.objects.create( + type=GRAPH_TYPE_PROVIDER, name='Test Graph 1', + source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1' + ) + self.graph2 = Graph.objects.create( + type=GRAPH_TYPE_PROVIDER, name='Test Graph 2', + source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2' + ) + self.graph3 = Graph.objects.create( + type=GRAPH_TYPE_PROVIDER, name='Test Graph 3', + source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3' + ) + + url = reverse('circuits-api:provider-graphs', kwargs={'pk': self.provider1.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=test-provider-1&foo=1') + def test_list_providers(self): url = reverse('circuits-api:provider-list') diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index ac4c4cb56..38728679a 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -15,7 +15,7 @@ from dcim.models import ( ) from dcim import filters from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer -from extras.api.serializers import GraphSerializer +from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from utilities.api import ServiceUnavailable, WritableSerializerMixin @@ -45,9 +45,12 @@ class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet): @detail_route() def graphs(self, request, pk=None): + """ + A convenience method for rendering graphs for a particular site. + """ site = get_object_or_404(Site, pk=pk) queryset = Graph.objects.filter(type=GRAPH_TYPE_SITE) - serializer = GraphSerializer(queryset, many=True, context={'graphed_object': site}) + serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site}) return Response(serializer.data) @@ -278,9 +281,12 @@ class InterfaceViewSet(WritableSerializerMixin, ModelViewSet): @detail_route() def graphs(self, request, pk=None): + """ + A convenience method for rendering graphs for a particular interface. + """ interface = get_object_or_404(Interface, pk=pk) queryset = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE) - serializer = GraphSerializer(queryset, many=True, context={'graphed_object': interface}) + serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface}) return Response(serializer.data) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 74751d8be..628973cdd 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -10,6 +10,7 @@ from dcim.models import ( Manufacturer, Module, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, ) +from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from users.models import Token from utilities.tests import HttpStatusMixin @@ -102,6 +103,27 @@ class SiteTest(HttpStatusMixin, APITestCase): self.assertEqual(response.data['name'], self.site1.name) + def test_get_site_graphs(self): + + self.graph1 = Graph.objects.create( + type=GRAPH_TYPE_SITE, name='Test Graph 1', + source='http://example.com/graphs.py?site={{ obj.slug }}&foo=1' + ) + self.graph2 = Graph.objects.create( + type=GRAPH_TYPE_SITE, name='Test Graph 2', + source='http://example.com/graphs.py?site={{ obj.slug }}&foo=2' + ) + self.graph3 = Graph.objects.create( + type=GRAPH_TYPE_SITE, name='Test Graph 3', + source='http://example.com/graphs.py?site={{ obj.slug }}&foo=3' + ) + + url = reverse('dcim-api:site-graphs', kwargs={'pk': self.site1.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=test-site-1&foo=1') + def test_list_sites(self): url = reverse('dcim-api:site-list') @@ -1655,6 +1677,27 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertEqual(response.data['name'], self.interface1.name) + def test_get_interface_graphs(self): + + self.graph1 = Graph.objects.create( + type=GRAPH_TYPE_INTERFACE, name='Test Graph 1', + source='http://example.com/graphs.py?interface={{ obj.name }}&foo=1' + ) + self.graph2 = Graph.objects.create( + type=GRAPH_TYPE_INTERFACE, name='Test Graph 2', + source='http://example.com/graphs.py?interface={{ obj.name }}&foo=2' + ) + self.graph3 = Graph.objects.create( + type=GRAPH_TYPE_INTERFACE, name='Test Graph 3', + source='http://example.com/graphs.py?interface={{ obj.name }}&foo=3' + ) + + url = reverse('dcim-api:interface-graphs', kwargs={'pk': self.interface1.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=Test Interface 1&foo=1') + def test_list_interfaces(self): url = reverse('dcim-api:interface-list') diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 5348dcc10..a7d081c5c 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from dcim.api.serializers import NestedSiteSerializer -from extras.models import ACTION_CHOICES, Graph, TopologyMap, UserAction +from extras.models import ACTION_CHOICES, Graph, GRAPH_TYPE_CHOICES, TopologyMap, UserAction from users.api.serializers import NestedUserSerializer from utilities.api import ChoiceFieldSerializer @@ -11,12 +11,28 @@ from utilities.api import ChoiceFieldSerializer # class GraphSerializer(serializers.ModelSerializer): - embed_url = serializers.SerializerMethodField() - embed_link = serializers.SerializerMethodField() + type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES) class Meta: model = Graph - fields = ['name', 'embed_url', 'embed_link'] + fields = ['id', 'type', 'weight', 'name', 'source', 'link'] + + +class WritableGraphSerializer(serializers.ModelSerializer): + + class Meta: + model = Graph + fields = ['id', 'type', 'weight', 'name', 'source', 'link'] + + +class RenderedGraphSerializer(serializers.ModelSerializer): + embed_url = serializers.SerializerMethodField() + embed_link = serializers.SerializerMethodField() + type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES) + + 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']) diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 141b4b7e4..ced4035c1 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -5,6 +5,9 @@ from . import views router = routers.DefaultRouter() +# Graphs +router.register(r'graphs', views.GraphViewSet) + # Topology maps router.register(r'topology-maps', views.TopologyMapViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 93bc724ec..5b9ea5afb 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -6,7 +6,7 @@ from django.http import HttpResponse from django.shortcuts import get_object_or_404 from extras import filters -from extras.models import TopologyMap, UserAction +from extras.models import Graph, TopologyMap, UserAction from utilities.api import WritableSerializerMixin from . import serializers @@ -41,6 +41,13 @@ class CustomFieldModelViewSet(ModelViewSet): return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field') +class GraphViewSet(WritableSerializerMixin, ModelViewSet): + queryset = Graph.objects.all() + serializer_class = serializers.GraphSerializer + write_serializer_class = serializers.WritableGraphSerializer + filter_class = filters.GraphFilter + + class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): queryset = TopologyMap.objects.select_related('site') serializer_class = serializers.TopologyMapSerializer diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 609a0789a..740296c9e 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -4,7 +4,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from dcim.models import Site -from .models import CF_TYPE_SELECT, CustomField, TopologyMap, UserAction +from .models import CF_TYPE_SELECT, CustomField, Graph, TopologyMap, UserAction class CustomFieldFilter(django_filters.Filter): @@ -48,6 +48,13 @@ class CustomFieldFilterSet(django_filters.FilterSet): self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type) +class GraphFilter(django_filters.FilterSet): + + class Meta: + model = Graph + fields = ['type', 'name'] + + class TopologyMapFilter(django_filters.FilterSet): site_id = django_filters.ModelMultipleChoiceFilter( name='site', diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py new file mode 100644 index 000000000..8594e5faa --- /dev/null +++ b/netbox/extras/tests/test_api.py @@ -0,0 +1,86 @@ +from rest_framework import status +from rest_framework.test import APITestCase + +from django.contrib.auth.models import User +from django.urls import reverse + +from extras.models import Graph, GRAPH_TYPE_SITE +from users.models import Token +from utilities.tests import HttpStatusMixin + + +class GraphTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.graph1 = Graph.objects.create( + type=GRAPH_TYPE_SITE, name='Test Graph 1', source='http://example.com/graphs.py?site={{ obj.name }}&foo=1' + ) + self.graph2 = Graph.objects.create( + type=GRAPH_TYPE_SITE, name='Test Graph 2', source='http://example.com/graphs.py?site={{ obj.name }}&foo=2' + ) + self.graph3 = Graph.objects.create( + type=GRAPH_TYPE_SITE, name='Test Graph 3', source='http://example.com/graphs.py?site={{ obj.name }}&foo=3' + ) + + def test_get_graph(self): + + url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.graph1.name) + + def test_list_graphs(self): + + url = reverse('extras-api:graph-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_graph(self): + + data = { + 'type': GRAPH_TYPE_SITE, + 'name': 'Test Graph 4', + 'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4', + } + + url = reverse('extras-api:graph-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Graph.objects.count(), 4) + graph4 = Graph.objects.get(pk=response.data['id']) + self.assertEqual(graph4.type, data['type']) + self.assertEqual(graph4.name, data['name']) + self.assertEqual(graph4.source, data['source']) + + def test_update_graph(self): + + data = { + 'type': GRAPH_TYPE_SITE, + 'name': 'Test Graph X', + 'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=99', + } + + url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Graph.objects.count(), 3) + graph1 = Graph.objects.get(pk=response.data['id']) + self.assertEqual(graph1.type, data['type']) + self.assertEqual(graph1.name, data['name']) + self.assertEqual(graph1.source, data['source']) + + def test_delete_graph(self): + + url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Graph.objects.count(), 2) From b04fe21d65e9cfd7582c7dc96fab5d0fe05f89a5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Mar 2017 16:21:10 -0400 Subject: [PATCH 088/182] Wrote API endpoints, tests for ExportTemplates --- netbox/extras/api/serializers.py | 13 ++++- netbox/extras/api/urls.py | 3 ++ netbox/extras/api/views.py | 9 +++- netbox/extras/filters.py | 9 +++- netbox/extras/tests/test_api.py | 84 +++++++++++++++++++++++++++++++- 5 files changed, 114 insertions(+), 4 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index a7d081c5c..a5c139c08 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from dcim.api.serializers import NestedSiteSerializer -from extras.models import ACTION_CHOICES, Graph, GRAPH_TYPE_CHOICES, TopologyMap, UserAction +from extras.models import ACTION_CHOICES, Graph, GRAPH_TYPE_CHOICES, ExportTemplate, TopologyMap, UserAction from users.api.serializers import NestedUserSerializer from utilities.api import ChoiceFieldSerializer @@ -41,6 +41,17 @@ class RenderedGraphSerializer(serializers.ModelSerializer): return obj.embed_link(self.context['graphed_object']) +# +# Export templates +# + +class ExportTemplateSerializer(serializers.ModelSerializer): + + class Meta: + model = ExportTemplate + fields = ['id', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension'] + + # # Topology maps # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index ced4035c1..ec1fa978b 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -8,6 +8,9 @@ router = routers.DefaultRouter() # Graphs router.register(r'graphs', views.GraphViewSet) +# Export templates +router.register(r'export-templates', views.ExportTemplateViewSet) + # Topology maps router.register(r'topology-maps', views.TopologyMapViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 5b9ea5afb..fab1ccdb5 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -6,7 +6,7 @@ from django.http import HttpResponse from django.shortcuts import get_object_or_404 from extras import filters -from extras.models import Graph, TopologyMap, UserAction +from extras.models import ExportTemplate, Graph, TopologyMap, UserAction from utilities.api import WritableSerializerMixin from . import serializers @@ -48,6 +48,13 @@ class GraphViewSet(WritableSerializerMixin, ModelViewSet): filter_class = filters.GraphFilter +class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet): + queryset = ExportTemplate.objects.all() + serializer_class = serializers.ExportTemplateSerializer + # write_serializer_class = serializers.WritableExportTemplateSerializer + filter_class = filters.ExportTemplateFilter + + class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): queryset = TopologyMap.objects.select_related('site') serializer_class = serializers.TopologyMapSerializer diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 740296c9e..9d9dc5f87 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -4,7 +4,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from dcim.models import Site -from .models import CF_TYPE_SELECT, CustomField, Graph, TopologyMap, UserAction +from .models import CF_TYPE_SELECT, CustomField, Graph, ExportTemplate, TopologyMap, UserAction class CustomFieldFilter(django_filters.Filter): @@ -55,6 +55,13 @@ class GraphFilter(django_filters.FilterSet): fields = ['type', 'name'] +class ExportTemplateFilter(django_filters.FilterSet): + + class Meta: + model = ExportTemplate + fields = ['content_type', 'name'] + + class TopologyMapFilter(django_filters.FilterSet): site_id = django_filters.ModelMultipleChoiceFilter( name='site', diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 8594e5faa..4c80ddee8 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -2,9 +2,11 @@ from rest_framework import status from rest_framework.test import APITestCase from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.urls import reverse -from extras.models import Graph, GRAPH_TYPE_SITE +from dcim.models import Device +from extras.models import Graph, GRAPH_TYPE_SITE, ExportTemplate from users.models import Token from utilities.tests import HttpStatusMixin @@ -84,3 +86,83 @@ class GraphTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Graph.objects.count(), 2) + + +class ExportTemplateTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.content_type = ContentType.objects.get_for_model(Device) + self.exporttemplate1 = ExportTemplate.objects.create( + content_type=self.content_type, name='Test Export Template 1', + template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' + ) + self.exporttemplate2 = ExportTemplate.objects.create( + content_type=self.content_type, name='Test Export Template 2', + template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' + ) + self.exporttemplate3 = ExportTemplate.objects.create( + content_type=self.content_type, name='Test Export Template 3', + template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' + ) + + def test_get_exporttemplate(self): + + url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.exporttemplate1.name) + + def test_list_exporttemplates(self): + + url = reverse('extras-api:exporttemplate-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_exporttemplate(self): + + data = { + 'content_type': self.content_type.pk, + 'name': 'Test Export Template 4', + 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', + } + + url = reverse('extras-api:exporttemplate-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ExportTemplate.objects.count(), 4) + exporttemplate4 = ExportTemplate.objects.get(pk=response.data['id']) + self.assertEqual(exporttemplate4.content_type_id, data['content_type']) + self.assertEqual(exporttemplate4.name, data['name']) + self.assertEqual(exporttemplate4.template_code, data['template_code']) + + def test_update_exporttemplate(self): + + data = { + 'content_type': self.content_type.pk, + 'name': 'Test Export Template X', + 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', + } + + url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(ExportTemplate.objects.count(), 3) + exporttemplate1 = ExportTemplate.objects.get(pk=response.data['id']) + self.assertEqual(exporttemplate1.name, data['name']) + self.assertEqual(exporttemplate1.template_code, data['template_code']) + + def test_delete_exporttemplate(self): + + url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(ExportTemplate.objects.count(), 2) From 2bd46230be1e4d958a712db74ec8a0b5928f8bcd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Mar 2017 16:32:59 -0400 Subject: [PATCH 089/182] Converted ChoiceFieldSerializer to display an object --- netbox/utilities/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index b4da922c5..ca4384f08 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -59,7 +59,7 @@ class TokenPermissions(DjangoModelPermissions): class ChoiceFieldSerializer(Field): """ - Represent a ChoiceField as (value, label). + Represent a ChoiceField as {'value': , 'label': }. """ def __init__(self, choices, **kwargs): self._choices = dict() @@ -73,7 +73,7 @@ class ChoiceFieldSerializer(Field): super(ChoiceFieldSerializer, self).__init__(**kwargs) def to_representation(self, obj): - return obj, self._choices[obj] + return {'value': obj, 'label': self._choices[obj]} def to_internal_value(self, data): return self._choices.get(data) From ffde2c96c7b24f4c65d691991d7b44790bd2d2f2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Mar 2017 17:11:32 -0400 Subject: [PATCH 090/182] Fixed custom renderers to work with paginated data --- netbox/extras/api/renderers.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/netbox/extras/api/renderers.py b/netbox/extras/api/renderers.py index 2e85ed3a8..f5a05cf1c 100644 --- a/netbox/extras/api/renderers.py +++ b/netbox/extras/api/renderers.py @@ -27,9 +27,7 @@ class BINDZoneRenderer(renderers.BaseRenderer): def render(self, data, media_type=None, renderer_context=None): records = [] - if not isinstance(data, (list, tuple)): - data = (data,) - for record in data: + for record in data['results']: if record.get('name') and record.get('primary_ip'): try: records.append("{} IN {} {}".format( @@ -59,7 +57,7 @@ class FlatJSONRenderer(renderers.BaseRenderer): else: yield key, val - return json.dumps([dict(flatten(i)) for i in data]) + return json.dumps([dict(flatten(i)) for i in data['results']]) class FreeRADIUSClientsRenderer(renderers.BaseRenderer): @@ -77,7 +75,7 @@ class FreeRADIUSClientsRenderer(renderers.BaseRenderer): def render(self, data, media_type=None, renderer_context=None): clients = [] try: - for secret in data: + for secret in data['results']: if secret['device']['primary_ip'] and secret['plaintext']: client = self.CLIENT_TEMPLATE.format( name=secret['device']['name'], From 4a2206ecb149e46ca4ad1603c47352cf7e8af28d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Mar 2017 17:47:18 -0400 Subject: [PATCH 091/182] Removed custom renderers --- netbox/dcim/api/views.py | 3 -- netbox/extras/api/renderers.py | 88 ---------------------------------- 2 files changed, 91 deletions(-) delete mode 100644 netbox/extras/api/renderers.py diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 38728679a..a5a53fbb5 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,7 +1,6 @@ from rest_framework.decorators import detail_route from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.settings import api_settings from rest_framework.viewsets import ModelViewSet, ViewSet from django.conf import settings @@ -14,7 +13,6 @@ from dcim.models import ( RackRole, Region, Site, ) from dcim import filters -from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE @@ -216,7 +214,6 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): serializer_class = serializers.DeviceSerializer write_serializer_class = serializers.WritableDeviceSerializer filter_class = filters.DeviceFilter - renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer] @detail_route(url_path='lldp-neighbors') def lldp_neighbors(self, request, pk): diff --git a/netbox/extras/api/renderers.py b/netbox/extras/api/renderers.py deleted file mode 100644 index f5a05cf1c..000000000 --- a/netbox/extras/api/renderers.py +++ /dev/null @@ -1,88 +0,0 @@ -import json -from rest_framework import renderers - - -# IP address family designations -AF = { - 4: 'A', - 6: 'AAAA', -} - - -class FormlessBrowsableAPIRenderer(renderers.BrowsableAPIRenderer): - """ - An instance of the browseable API with forms suppressed. Useful for POST endpoints that don't create objects. - """ - def show_form_for_method(self, *args, **kwargs): - return False - - -class BINDZoneRenderer(renderers.BaseRenderer): - """ - Generate a BIND zone file from a list of DNS records. - Required fields: `name`, `primary_ip` - """ - media_type = 'text/plain' - format = 'bind-zone' - - def render(self, data, media_type=None, renderer_context=None): - records = [] - for record in data['results']: - if record.get('name') and record.get('primary_ip'): - try: - records.append("{} IN {} {}".format( - record['name'], - AF[record['primary_ip']['family']], - record['primary_ip']['address'].split('/')[0], - )) - except KeyError: - pass - return '\n'.join(records) - - -class FlatJSONRenderer(renderers.BaseRenderer): - """ - Flattens a nested JSON response. - """ - format = 'json_flat' - media_type = 'application/json' - - def render(self, data, media_type=None, renderer_context=None): - - def flatten(entry): - for key, val in entry.items(): - if isinstance(val, dict): - for child_key, child_val in flatten(val): - yield "{}_{}".format(key, child_key), child_val - else: - yield key, val - - return json.dumps([dict(flatten(i)) for i in data['results']]) - - -class FreeRADIUSClientsRenderer(renderers.BaseRenderer): - """ - Generate a FreeRADIUS clients.conf file from a list of Secrets. - """ - media_type = 'text/plain' - format = 'freeradius' - - CLIENT_TEMPLATE = """client {name} {{ - ipaddr = {ip} - secret = {secret} -}}""" - - def render(self, data, media_type=None, renderer_context=None): - clients = [] - try: - for secret in data['results']: - if secret['device']['primary_ip'] and secret['plaintext']: - client = self.CLIENT_TEMPLATE.format( - name=secret['device']['name'], - ip=secret['device']['primary_ip']['address'].split('/')[0], - secret=secret['plaintext'] - ) - clients.append(client) - except: - pass - return '\n'.join(clients) From f743410b4eb0aa1f029f013e0be1fb80b1f9f16f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Mar 2017 21:18:37 -0400 Subject: [PATCH 092/182] Renamed rack-units API and added a test --- netbox/dcim/api/views.py | 4 ++-- netbox/dcim/forms.py | 2 +- netbox/dcim/tests/test_api.py | 13 ++++++++++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index a5a53fbb5..e185eac93 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -82,8 +82,8 @@ class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet): write_serializer_class = serializers.WritableRackSerializer filter_class = filters.RackFilter - @detail_route(url_path='rack-units') - def rack_units(self, request, pk=None): + @detail_route() + def units(self, request, pk=None): """ List rack units (by rack) """ diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index c79f65d53..5fa5865fe 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -512,7 +512,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm): )) position = forms.TypedChoiceField(required=False, empty_value=None, help_text="The lowest-numbered unit occupied by the device", - widget=APISelect(api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}', + widget=APISelect(api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}', disabled_indicator='device')) manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), widget=forms.Select(attrs={'filter-for': 'device_type'})) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 628973cdd..bf3e000bd 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -335,13 +335,13 @@ class RackTest(HttpStatusMixin, APITestCase): self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000') self.rackrole2 = RackRole.objects.create(name='Test Rack Role 2', slug='test-rack-role-2', color='00ff00') self.rack1 = Rack.objects.create( - site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42, ) self.rack2 = Rack.objects.create( - site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2' + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42, ) self.rack3 = Rack.objects.create( - site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 3' + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 3', u_height=42, ) def test_get_rack(self): @@ -351,6 +351,13 @@ class RackTest(HttpStatusMixin, APITestCase): self.assertEqual(response.data['name'], self.rack1.name) + def test_get_rack_units(self): + + url = reverse('dcim-api:rack-units', kwargs={'pk': self.rack1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 42) + def test_list_racks(self): url = reverse('dcim-api:rack-list') From 925afe0999ef02c4387e5db8689d293a45097721 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Mar 2017 21:39:40 -0400 Subject: [PATCH 093/182] Added test case for ConnectedDeviceViewSet --- netbox/dcim/api/views.py | 2 +- netbox/dcim/tests/test_api.py | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e185eac93..3b2520713 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,4 +1,4 @@ -from rest_framework.decorators import detail_route +from rest_framework.decorators import detail_route, list_route from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet, ViewSet diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index bf3e000bd..b06a4f958 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2028,3 +2028,45 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(InterfaceConnection.objects.count(), 2) + + +class ConnectedDeviceTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') + self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype1 = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.devicetype2 = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 2', slug='test-device-type-2' + ) + self.devicerole1 = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.devicerole2 = DeviceRole.objects.create( + name='Test Device Role 2', slug='test-device-role-2', color='00ff00' + ) + self.device1 = Device.objects.create( + device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice1', site=self.site1 + ) + self.device2 = Device.objects.create( + device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice2', site=self.site1 + ) + self.interface1 = Interface.objects.create(device=self.device1, name='eth0') + self.interface2 = Interface.objects.create(device=self.device2, name='eth0') + InterfaceConnection.objects.create(interface_a=self.interface1, interface_b=self.interface2) + + def test_get_connected_device(self): + + url = reverse('dcim-api:connected-device-list') + response = self.client.get(url + '?peer-device=TestDevice2&peer-interface=eth0', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['name'], self.device1.name) From 6cb36a6ceeb0d52d2aaaa3c6212d1e03ec090a0f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Mar 2017 21:50:10 -0400 Subject: [PATCH 094/182] Fixed browsable API breadcrumbs --- netbox/circuits/api/urls.py | 9 +++++++++ netbox/dcim/api/urls.py | 9 +++++++++ netbox/extras/api/urls.py | 9 +++++++++ netbox/ipam/api/urls.py | 9 +++++++++ netbox/secrets/api/urls.py | 9 +++++++++ netbox/tenancy/api/urls.py | 9 +++++++++ 6 files changed, 54 insertions(+) diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index d53f13d83..4957a7e73 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -3,7 +3,16 @@ from rest_framework import routers from . import views +class CircuitsRootView(routers.APIRootView): + """ + Circuits API root view + """ + def get_view_name(self): + return 'Circuits' + + router = routers.DefaultRouter() +router.APIRootView = CircuitsRootView # Providers router.register(r'providers', views.ProviderViewSet) diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 9f7aa3717..8556e4141 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -3,7 +3,16 @@ from rest_framework import routers from . import views +class DCIMRootView(routers.APIRootView): + """ + DCIM API root view + """ + def get_view_name(self): + return 'DCIM' + + router = routers.DefaultRouter() +router.APIRootView = DCIMRootView # Sites router.register(r'regions', views.RegionViewSet) diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index ec1fa978b..1623dcdeb 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -3,7 +3,16 @@ from rest_framework import routers from . import views +class ExtrasRootView(routers.APIRootView): + """ + Extras API root view + """ + def get_view_name(self): + return 'Extras' + + router = routers.DefaultRouter() +router.APIRootView = ExtrasRootView # Graphs router.register(r'graphs', views.GraphViewSet) diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index c72d501dd..cc37b651a 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -3,7 +3,16 @@ from rest_framework import routers from . import views +class IPAMRootView(routers.APIRootView): + """ + IPAM API root view + """ + def get_view_name(self): + return 'IPAM' + + router = routers.DefaultRouter() +router.APIRootView = IPAMRootView # VRFs router.register(r'vrfs', views.VRFViewSet) diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index da84afe82..18c7ec4c1 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -3,7 +3,16 @@ from rest_framework import routers from . import views +class SecretsRootView(routers.APIRootView): + """ + Secrets API root view + """ + def get_view_name(self): + return 'Secrets' + + router = routers.DefaultRouter() +router.APIRootView = SecretsRootView # Secrets router.register(r'secret-roles', views.SecretRoleViewSet) diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 5fb0be708..bedabf67d 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -3,7 +3,16 @@ from rest_framework import routers from . import views +class TenancyRootView(routers.APIRootView): + """ + Tenancy API root view + """ + def get_view_name(self): + return 'Tenancy' + + router = routers.DefaultRouter() +router.APIRootView = TenancyRootView # Tenants router.register(r'tenant-groups', views.TenantGroupViewSet) From 122526a9d028273ba04c67e627cd0a8673ba297f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Mar 2017 21:54:01 -0400 Subject: [PATCH 095/182] Custom name for ConnectedDeviceViewSet --- netbox/dcim/api/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 3b2520713..394fae5fa 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -326,6 +326,9 @@ class ConnectedDeviceViewSet(ViewSet): """ permission_classes = [IsAuthenticated] + def get_view_name(self): + return "Connected Device Locator" + def list(self, request): peer_device_name = request.query_params.get('peer-device') From 22768ff6c6c64fe22972bffbeb44d0c64bf49a16 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Mar 2017 12:54:08 -0400 Subject: [PATCH 096/182] Renamed Module to InventoryItem (prep for #824) --- docs/data-model/dcim.md | 10 +-- netbox/dcim/admin.py | 8 +- netbox/dcim/api/serializers.py | 12 +-- netbox/dcim/api/urls.py | 2 +- netbox/dcim/api/views.py | 12 +-- netbox/dcim/filters.py | 8 +- netbox/dcim/forms.py | 8 +- .../0034_rename_module_to_inventoryitem.py | 35 ++++++++ netbox/dcim/models.py | 14 ++-- netbox/dcim/tests/test_api.py | 66 ++++++++-------- netbox/dcim/urls.py | 10 +-- netbox/dcim/views.py | 24 +++--- .../management/commands/run_inventory.py | 24 +++--- netbox/extras/rpc.py | 44 +++++------ netbox/templates/dcim/device_inventory.html | 79 ++----------------- netbox/templates/dcim/inc/inventoryitem.html | 20 +++++ .../templates/dcim/inventoryitem_delete.html | 8 ++ netbox/templates/dcim/module_delete.html | 8 -- 18 files changed, 192 insertions(+), 200 deletions(-) create mode 100644 netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py create mode 100644 netbox/templates/dcim/inc/inventoryitem.html create mode 100644 netbox/templates/dcim/inventoryitem_delete.html delete mode 100644 netbox/templates/dcim/module_delete.html diff --git a/docs/data-model/dcim.md b/docs/data-model/dcim.md index 618f2d8d9..74dffb320 100644 --- a/docs/data-model/dcim.md +++ b/docs/data-model/dcim.md @@ -93,9 +93,12 @@ A device's platform is used to denote the type of software running on it. This c The assignment of platforms to devices is an optional feature, and may be disregarded if not desired. -### Modules +### Inventory Items -A device can be assigned modules which represent internal components. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Each module can optionally be assigned to a manufacturer. +Inventory items represent hardware components installed within a device, such as a power supply or CPU. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Each item can optionally be assigned a manufacturer. + +!!! note + Prior to version 2.0, inventory items were called modules. ### Components @@ -113,6 +116,3 @@ Console ports connect only to console server ports, and power ports connect only Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. Each interface can also be designated as management-only (for out-of-band management) and assigned a short description. Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view. - -!!! note - Child devices differ from modules in that they are still treated as independent devices, with their own console/power/data components, modules, and IP addresses. Modules, on the other hand, are parts within a device, such as a hard disk or power supply, which do not provide their own management plane. diff --git a/netbox/dcim/admin.py b/netbox/dcim/admin.py index 16f07dfcf..a1b64f235 100644 --- a/netbox/dcim/admin.py +++ b/netbox/dcim/admin.py @@ -5,7 +5,7 @@ from mptt.admin import MPTTModelAdmin from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform, + DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, ) @@ -183,8 +183,8 @@ class DeviceBayAdmin(admin.TabularInline): readonly_fields = ['installed_device'] -class ModuleAdmin(admin.TabularInline): - model = Module +class InventoryItemAdmin(admin.TabularInline): + model = InventoryItem readonly_fields = ['parent', 'discovered'] @@ -197,7 +197,7 @@ class DeviceAdmin(admin.ModelAdmin): PowerOutletAdmin, InterfaceAdmin, DeviceBayAdmin, - ModuleAdmin, + InventoryItemAdmin, ] list_display = ['display_name', 'device_type_full_name', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag', 'serial'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d4e403598..e9a1bd61e 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -5,7 +5,7 @@ from ipam.models import IPAddress from dcim.models import ( CONNECTION_STATUS_CHOICES, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface, - InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, + InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, ) @@ -642,22 +642,22 @@ class WritableDeviceBaySerializer(serializers.ModelSerializer): # -# Modules +# Inventory items # -class ModuleSerializer(serializers.ModelSerializer): +class InventoryItemSerializer(serializers.ModelSerializer): device = NestedDeviceSerializer() manufacturer = NestedManufacturerSerializer() class Meta: - model = Module + model = InventoryItem fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] -class WritableModuleSerializer(serializers.ModelSerializer): +class WritableInventoryItemSerializer(serializers.ModelSerializer): class Meta: - model = Module + model = InventoryItem fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 8556e4141..f648c869d 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -48,7 +48,7 @@ router.register(r'power-ports', views.PowerPortViewSet) router.register(r'power-outlets', views.PowerOutletViewSet) router.register(r'interfaces', views.InterfaceViewSet) router.register(r'device-bays', views.DeviceBayViewSet) -router.register(r'modules', views.ModuleViewSet) +router.register(r'inventory-items', views.InventoryItemViewSet) # Interface connections router.register(r'interface-connections', views.InterfaceConnectionViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 394fae5fa..77344f4fa 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -8,7 +8,7 @@ from django.shortcuts import get_object_or_404 from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, + DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, ) @@ -294,11 +294,11 @@ class DeviceBayViewSet(WritableSerializerMixin, ModelViewSet): filter_class = filters.DeviceBayFilter -class ModuleViewSet(WritableSerializerMixin, ModelViewSet): - queryset = Module.objects.select_related('device', 'manufacturer') - serializer_class = serializers.ModuleSerializer - write_serializer_class = serializers.WritableModuleSerializer - filter_class = filters.ModuleFilter +class InventoryItemViewSet(WritableSerializerMixin, ModelViewSet): + queryset = InventoryItem.objects.select_related('device', 'manufacturer') + serializer_class = serializers.InventoryItemSerializer + write_serializer_class = serializers.WritableInventoryItemSerializer + filter_class = filters.InventoryItemFilter # diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 117cd4cf4..cdf5abc33 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -9,7 +9,7 @@ from utilities.filters import NullableModelMultipleChoiceFilter from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, InterfaceTemplate, - Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, + Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, VIRTUAL_IFACE_TYPES, ) @@ -359,7 +359,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): return queryset.filter( Q(name__icontains=value) | Q(serial__icontains=value.strip()) | - Q(modules__serial__icontains=value.strip()) | + Q(inventory_items__serial__icontains=value.strip()) | Q(asset_tag=value.strip()) | Q(comments__icontains=value) ).distinct() @@ -444,10 +444,10 @@ class DeviceBayFilter(DeviceComponentFilterSet): fields = ['name'] -class ModuleFilter(DeviceComponentFilterSet): +class InventoryItemFilter(DeviceComponentFilterSet): class Meta: - model = Module + model = InventoryItem fields = ['name'] diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 5fa5865fe..a2f58f1e3 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -21,7 +21,7 @@ from .models import ( DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, - Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, + Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, VIRTUAL_IFACE_TYPES ) @@ -1684,11 +1684,11 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm): # -# Modules +# Inventory items # -class ModuleForm(BootstrapMixin, forms.ModelForm): +class InventoryItemForm(BootstrapMixin, forms.ModelForm): class Meta: - model = Module + model = InventoryItem fields = ['name', 'manufacturer', 'part_id', 'serial'] diff --git a/netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py b/netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py new file mode 100644 index 000000000..ff430c067 --- /dev/null +++ b/netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.6 on 2017-03-21 14:55 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0033_rackreservation_rack_editable'), + ] + + operations = [ + migrations.RenameModel( + old_name='Module', + new_name='InventoryItem', + ), + migrations.AlterField( + model_name='inventoryitem', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='dcim.Device'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.InventoryItem'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='manufacturer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_items', to='dcim.Manufacturer'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 351d0a2b2..f9703898f 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1397,19 +1397,19 @@ class DeviceBay(models.Model): # -# Modules +# Inventory items # @python_2_unicode_compatible -class Module(models.Model): +class InventoryItem(models.Model): """ - A Module represents a piece of hardware within a Device, such as a line card or power supply. Modules are used only - for inventory purposes. + An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. + InventoryItems are used only for inventory purposes. """ - device = models.ForeignKey('Device', related_name='modules', on_delete=models.CASCADE) - parent = models.ForeignKey('self', related_name='submodules', blank=True, null=True, on_delete=models.CASCADE) + device = models.ForeignKey('Device', related_name='inventory_items', on_delete=models.CASCADE) + parent = models.ForeignKey('self', related_name='child_items', blank=True, null=True, on_delete=models.CASCADE) name = models.CharField(max_length=50, verbose_name='Name') - manufacturer = models.ForeignKey('Manufacturer', related_name='modules', blank=True, null=True, + manufacturer = models.ForeignKey('Manufacturer', related_name='inventory_items', blank=True, null=True, on_delete=models.PROTECT) part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True) serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index b06a4f958..ef8c6f299 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -7,7 +7,7 @@ from django.urls import reverse from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, InterfaceTemplate, - Manufacturer, Module, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, + Manufacturer, InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, ) from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE @@ -1847,7 +1847,7 @@ class DeviceBayTest(HttpStatusMixin, APITestCase): self.assertEqual(DeviceBay.objects.count(), 2) -class ModuleTest(HttpStatusMixin, APITestCase): +class InventoryItemTest(HttpStatusMixin, APITestCase): def setUp(self): @@ -1866,71 +1866,71 @@ class ModuleTest(HttpStatusMixin, APITestCase): self.device = Device.objects.create( device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site ) - self.module1 = Module.objects.create(device=self.device, name='Test Module 1') - self.module2 = Module.objects.create(device=self.device, name='Test Module 2') - self.module3 = Module.objects.create(device=self.device, name='Test Module 3') + self.inventoryitem1 = InventoryItem.objects.create(device=self.device, name='Test Inventory Item 1') + self.inventoryitem2 = InventoryItem.objects.create(device=self.device, name='Test Inventory Item 2') + self.inventoryitem3 = InventoryItem.objects.create(device=self.device, name='Test Inventory Item 3') - def test_get_module(self): + def test_get_inventoryitem(self): - url = reverse('dcim-api:module-detail', kwargs={'pk': self.module1.pk}) + url = reverse('dcim-api:inventoryitem-detail', kwargs={'pk': self.inventoryitem1.pk}) response = self.client.get(url, **self.header) - self.assertEqual(response.data['name'], self.module1.name) + self.assertEqual(response.data['name'], self.inventoryitem1.name) - def test_list_modules(self): + def test_list_inventoryitems(self): - url = reverse('dcim-api:module-list') + url = reverse('dcim-api:inventoryitem-list') response = self.client.get(url, **self.header) self.assertEqual(response.data['count'], 3) - def test_create_module(self): + def test_create_inventoryitem(self): data = { 'device': self.device.pk, - 'parent': self.module1.pk, - 'name': 'Test Module 4', + 'parent': self.inventoryitem1.pk, + 'name': 'Test Inventory Item 4', 'manufacturer': self.manufacturer.pk, } - url = reverse('dcim-api:module-list') + url = reverse('dcim-api:inventoryitem-list') response = self.client.post(url, data, **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Module.objects.count(), 4) - module4 = Module.objects.get(pk=response.data['id']) - self.assertEqual(module4.device_id, data['device']) - self.assertEqual(module4.parent_id, data['parent']) - self.assertEqual(module4.name, data['name']) - self.assertEqual(module4.manufacturer_id, data['manufacturer']) + self.assertEqual(InventoryItem.objects.count(), 4) + inventoryitem4 = InventoryItem.objects.get(pk=response.data['id']) + self.assertEqual(inventoryitem4.device_id, data['device']) + self.assertEqual(inventoryitem4.parent_id, data['parent']) + self.assertEqual(inventoryitem4.name, data['name']) + self.assertEqual(inventoryitem4.manufacturer_id, data['manufacturer']) - def test_update_module(self): + def test_update_inventoryitem(self): data = { 'device': self.device.pk, - 'parent': self.module1.pk, - 'name': 'Test Module X', + 'parent': self.inventoryitem1.pk, + 'name': 'Test Inventory Item X', 'manufacturer': self.manufacturer.pk, } - url = reverse('dcim-api:module-detail', kwargs={'pk': self.module1.pk}) + url = reverse('dcim-api:inventoryitem-detail', kwargs={'pk': self.inventoryitem1.pk}) response = self.client.put(url, data, **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(Module.objects.count(), 3) - module1 = Module.objects.get(pk=response.data['id']) - self.assertEqual(module1.device_id, data['device']) - self.assertEqual(module1.parent_id, data['parent']) - self.assertEqual(module1.name, data['name']) - self.assertEqual(module1.manufacturer_id, data['manufacturer']) + self.assertEqual(InventoryItem.objects.count(), 3) + inventoryitem1 = InventoryItem.objects.get(pk=response.data['id']) + self.assertEqual(inventoryitem1.device_id, data['device']) + self.assertEqual(inventoryitem1.parent_id, data['parent']) + self.assertEqual(inventoryitem1.name, data['name']) + self.assertEqual(inventoryitem1.manufacturer_id, data['manufacturer']) - def test_delete_module(self): + def test_delete_inventoryitem(self): - url = reverse('dcim-api:module-detail', kwargs={'pk': self.module1.pk}) + url = reverse('dcim-api:inventoryitem-detail', kwargs={'pk': self.inventoryitem1.pk}) response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(Module.objects.count(), 2) + self.assertEqual(InventoryItem.objects.count(), 2) class InterfaceConnectionTest(HttpStatusMixin, APITestCase): diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 7fde6e9b3..b4731df33 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -173,6 +173,11 @@ urlpatterns = [ url(r'^device-bays/(?P\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'), url(r'^device-bays/(?P\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'), + # Inventory items + url(r'^devices/(?P\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'), + url(r'^inventory-items/(?P\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), + url(r'^inventory-items/(?P\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), + # Console/power/interface connections url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'), @@ -181,9 +186,4 @@ urlpatterns = [ url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'), - # Modules - url(r'^devices/(?P\d+)/modules/add/$', views.ModuleEditView.as_view(), name='module_add'), - url(r'^modules/(?P\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'), - url(r'^modules/(?P\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'), - ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 246fe06f0..20e51d34e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -25,7 +25,7 @@ from . import filters, forms, tables from .models import ( CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, - Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, + Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, ) @@ -799,12 +799,12 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): def device_inventory(request, pk): device = get_object_or_404(Device, pk=pk) - modules = Module.objects.filter(device=device, parent=None).select_related('manufacturer')\ - .prefetch_related('submodules') + inventory_items = InventoryItem.objects.filter(device=device, parent=None).select_related('manufacturer')\ + .prefetch_related('child_items') return render(request, 'dcim/device_inventory.html', { 'device': device, - 'modules': modules, + 'inventory_items': inventory_items, }) @@ -1594,13 +1594,13 @@ def ipaddress_assign(request, pk): # -# Modules +# Inventory items # -class ModuleEditView(PermissionRequiredMixin, ComponentEditView): - permission_required = 'dcim.change_module' - model = Module - form_class = forms.ModuleForm +class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): + permission_required = 'dcim.change_inventoryitem' + model = InventoryItem + form_class = forms.InventoryItemForm def alter_obj(self, obj, request, url_args, url_kwargs): if 'device' in url_kwargs: @@ -1608,6 +1608,6 @@ class ModuleEditView(PermissionRequiredMixin, ComponentEditView): return obj -class ModuleDeleteView(PermissionRequiredMixin, ComponentDeleteView): - permission_required = 'dcim.delete_module' - model = Module +class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView): + permission_required = 'dcim.delete_inventoryitem' + model = InventoryItem diff --git a/netbox/extras/management/commands/run_inventory.py b/netbox/extras/management/commands/run_inventory.py index ebfee92e0..a7d643173 100644 --- a/netbox/extras/management/commands/run_inventory.py +++ b/netbox/extras/management/commands/run_inventory.py @@ -6,7 +6,7 @@ from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.db import transaction -from dcim.models import Device, Module, Site +from dcim.models import Device, InventoryItem, Site class Command(BaseCommand): @@ -25,12 +25,12 @@ class Command(BaseCommand): def handle(self, *args, **options): - def create_modules(modules, parent=None): - for module in modules: - m = Module(device=device, parent=parent, name=module['name'], part_id=module['part_id'], - serial=module['serial'], discovered=True) - m.save() - create_modules(module.get('modules', []), parent=m) + def create_inventory_items(inventory_items, parent=None): + for item in inventory_items: + i = InventoryItem(device=device, parent=parent, name=item['name'], part_id=item['part_id'], + serial=item['serial'], discovered=True) + i.save() + create_inventory_items(item.get('items', []), parent=i) # Credentials if options['username']: @@ -107,9 +107,9 @@ class Command(BaseCommand): self.stdout.write("") self.stdout.write("\tSerial: {}".format(inventory['chassis']['serial'])) self.stdout.write("\tDescription: {}".format(inventory['chassis']['description'])) - for module in inventory['modules']: - self.stdout.write("\tModule: {} / {} ({})".format(module['name'], module['part_id'], - module['serial'])) + for item in inventory['items']: + self.stdout.write("\tItem: {} / {} ({})".format(item['name'], item['part_id'], + item['serial'])) else: self.stdout.write("{} ({})".format(inventory['chassis']['description'], inventory['chassis']['serial'])) @@ -119,7 +119,7 @@ class Command(BaseCommand): if device.serial != inventory['chassis']['serial']: device.serial = inventory['chassis']['serial'] device.save() - Module.objects.filter(device=device, discovered=True).delete() - create_modules(inventory.get('modules', [])) + InventoryItem.objects.filter(device=device, discovered=True).delete() + create_inventory_items(inventory.get('items', [])) self.stdout.write("Finished!") diff --git a/netbox/extras/rpc.py b/netbox/extras/rpc.py index b52dd0310..ae651b162 100644 --- a/netbox/extras/rpc.py +++ b/netbox/extras/rpc.py @@ -33,14 +33,14 @@ class RPCClient(object): def get_inventory(self): """ - Returns a dictionary representing the device chassis and installed modules. + Returns a dictionary representing the device chassis and installed inventory items. { 'chassis': { 'serial': , 'description': , } - 'modules': [ + 'items': [ { 'name': , 'part_id': , @@ -144,23 +144,23 @@ class JunosNC(RPCClient): def get_inventory(self): - def glean_modules(node, depth=0): - modules = [] - modules_list = node.get('chassis{}-module'.format('-sub' * depth), []) + def glean_items(node, depth=0): + items = [] + items_list = node.get('chassis{}-module'.format('-sub' * depth), []) # Junos like to return single children directly instead of as a single-item list - if hasattr(modules_list, 'items'): - modules_list = [modules_list] - for module in modules_list: + if hasattr(items_list, 'items'): + items_list = [items_list] + for item in items_list: m = { - 'name': module['name'], - 'part_id': module.get('model-number') or module.get('part-number', ''), - 'serial': module.get('serial-number', ''), + 'name': item['name'], + 'part_id': item.get('model-number') or item.get('part-number', ''), + 'serial': item.get('serial-number', ''), } - submodules = glean_modules(module, depth + 1) - if submodules: - m['modules'] = submodules - modules.append(m) - return modules + child_items = glean_items(item, depth + 1) + if child_items: + m['items'] = child_items + items.append(m) + return items rpc_reply = self.manager.dispatch('get-chassis-inventory') inventory_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['chassis-inventory']['chassis'] @@ -173,8 +173,8 @@ class JunosNC(RPCClient): 'description': inventory_raw['description'], } - # Gather modules - result['modules'] = glean_modules(inventory_raw) + # Gather inventory items + result['items'] = glean_items(inventory_raw) return result @@ -199,7 +199,7 @@ class IOSSSH(SSHClient): 'description': parse(sh_ver, 'cisco ([^\s]+)') } - def modules(chassis_serial=None): + def items(chassis_serial=None): cmd = self._send('show inventory').split('\r\n\r\n') for i in cmd: i_fmt = i.replace('\r\n', ' ') @@ -207,7 +207,7 @@ class IOSSSH(SSHClient): m_name = re.search('NAME: "([^"]+)"', i_fmt).group(1) m_pid = re.search('PID: ([^\s]+)', i_fmt).group(1) m_serial = re.search('SN: ([^\s]+)', i_fmt).group(1) - # Omit built-in modules and those with no PID + # Omit built-in items and those with no PID if m_serial != chassis_serial and m_pid.lower() != 'unspecified': yield { 'name': m_name, @@ -222,7 +222,7 @@ class IOSSSH(SSHClient): return { 'chassis': sh_version, - 'modules': list(modules(chassis_serial=sh_version.get('serial'))) + 'items': list(items(chassis_serial=sh_version.get('serial'))) } @@ -257,7 +257,7 @@ class OpengearSSH(SSHClient): 'serial': serial, 'description': description, }, - 'modules': [], + 'items': [], } diff --git a/netbox/templates/dcim/device_inventory.html b/netbox/templates/dcim/device_inventory.html index a4fbe7170..cc3dd361b 100644 --- a/netbox/templates/dcim/device_inventory.html +++ b/netbox/templates/dcim/device_inventory.html @@ -46,7 +46,7 @@ - + @@ -55,81 +55,18 @@ - {% for m in modules %} - - - - - - - - - {% for m2 in m.submodules.all %} - - - - - - - - - {% for m3 in m2.submodules.all %} - - - - - - - - - {% for m4 in m3.submodules.all %} - - - - - - - - - {% endfor %} - {% endfor %} - {% endfor %} + {% for item in inventory_items %} + {% with template_name='dcim/inc/inventoryitem.html' indent=0 %} + {% include template_name %} + {% endwith %} {% endfor %}
    ModuleName Manufacturer Part Number
    {{ m.name }}{% if not m.discovered %}{% endif %}{{ m.manufacturer|default:'' }}{{ m.part_id }}{{ m.serial }} - {% if perms.dcim.change_module %} - - {% endif %} - {% if perms.dcim.delete_module %} - - {% endif %} -
    {{ m2.name }}{% if not m2.discovered %}{% endif %}{{ m2.manufacturer|default:'' }}{{ m2.part_id }}{{ m2.serial }} - {% if perms.dcim.change_module %} - - {% endif %} - {% if perms.dcim.delete_module %} - - {% endif %} -
    {{ m3.name }}{% if not m3.discovered %}{% endif %}{{ m3.manufacturer|default:'' }}{{ m3.part_id }}{{ m3.serial }} - {% if perms.dcim.change_module %} - - {% endif %} - {% if perms.dcim.delete_module %} - - {% endif %} -
    {{ m4.name }}{% if not m4.discovered %}{% endif %}{{ m4.manufacturer|default:'' }}{{ m4.part_id }}{{ m4.serial }} - {% if perms.dcim.change_module %} - - {% endif %} - {% if perms.dcim.delete_module %} - - {% endif %} -
    - {% if perms.dcim.add_module %} - + {% if perms.dcim.add_inventoryitem %} + - Add a Module + Add Inventory Item {% endif %}
    diff --git a/netbox/templates/dcim/inc/inventoryitem.html b/netbox/templates/dcim/inc/inventoryitem.html new file mode 100644 index 000000000..01281c317 --- /dev/null +++ b/netbox/templates/dcim/inc/inventoryitem.html @@ -0,0 +1,20 @@ +
    {{ item.name }}{% if not item.discovered %}{% endif %}{{ item.manufacturer|default:'' }}{{ item.part_id }}{{ item.serial }} + {% if perms.dcim.change_inventory_item %} + + {% endif %} + {% if perms.dcim.delete_inventory_item %} + + {% endif %} +
    + + + + + + + {% for attachment in rack.images.all %} + + + + + + + {% endfor %} +
    NameSizeCreated
    + + {{ attachment }} + {{ attachment.image.size|filesizeformat }}{{ attachment.created }} + {% if perms.extras.change_imageattachment %} + + + + {% endif %} + {% if perms.extras.delete_imageattachment %} + + + + {% endif %} +
    + {% else %} +
    + None +
    + {% endif %} + {% if perms.extras.add_imageattachment %} + + {% endif %} +
    Reservations diff --git a/netbox/templates/utilities/obj_edit.html b/netbox/templates/utilities/obj_edit.html index 21ec67cef..07a39634d 100644 --- a/netbox/templates/utilities/obj_edit.html +++ b/netbox/templates/utilities/obj_edit.html @@ -2,7 +2,7 @@ {% load form_helpers %} {% block content %} - + {% csrf_token %} {% for field in form.hidden_fields %} {{ field }} diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index dd6235f45..8285a7b96 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -425,12 +425,13 @@ class BootstrapMixin(forms.BaseForm): def __init__(self, *args, **kwargs): super(BootstrapMixin, self).__init__(*args, **kwargs) + + exempt_widgets = [forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect] + for field_name, field in self.fields.items(): - if type(field.widget) not in [type(forms.CheckboxInput()), type(forms.RadioSelect())]: - try: - field.widget.attrs['class'] += ' form-control' - except KeyError: - field.widget.attrs['class'] = 'form-control' + if field.widget.__class__ not in exempt_widgets: + css = field.widget.attrs.get('class', '') + field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip() if field.required: field.widget.attrs['required'] = 'required' if 'placeholder' not in field.widget.attrs: diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index f38d9a0ab..ba29afbe1 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -174,7 +174,7 @@ class ObjectEditView(View): obj = self.get_object(kwargs) obj = self.alter_obj(obj, request, args, kwargs) - form = self.form_class(request.POST, instance=obj) + form = self.form_class(request.POST, request.FILES, instance=obj) if form.is_valid(): obj = form.save(commit=False) diff --git a/requirements.txt b/requirements.txt index b732ab1b1..aa361641b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ natsort>=5.0.0 ncclient==0.5.2 netaddr==0.7.18 paramiko>=2.0.0 +Pillow>=4.0.0 psycopg2>=2.6.1 py-gfm>=0.1.3 pycrypto>=2.6.1 From 1c38f705a746e8c3f1ba42569357fdf42b7062bf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Mar 2017 11:13:37 -0400 Subject: [PATCH 126/182] Fixes #1021: Corrected evaluation of API token expiration time --- netbox/users/models.py | 6 +++--- netbox/utilities/api.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/users/models.py b/netbox/users/models.py index 0dd303104..16c5005ef 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -39,6 +39,6 @@ class Token(models.Model): @property def is_expired(self): - if self.expires is not None and timezone.now() > self.expires: - return True - return False + if self.expires is None or timezone.now() < self.expires: + return False + return True diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index ca4384f08..791a504a6 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -30,7 +30,7 @@ class TokenAuthentication(authentication.TokenAuthentication): raise exceptions.AuthenticationFailed("Invalid token") # Enforce the Token's expiration time, if one has been set. - if token.expires and not token.is_expired: + if token.is_expired: raise exceptions.AuthenticationFailed("Token expired") if not token.user.is_active: From 50d7fd776fd49ad28be44935145762052136c55f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Mar 2017 15:19:44 -0400 Subject: [PATCH 127/182] Added image attachments to sites and devices --- netbox/dcim/models.py | 2 ++ netbox/dcim/urls.py | 4 ++- netbox/templates/dcim/device.html | 14 ++++++++ netbox/templates/dcim/rack.html | 37 +-------------------- netbox/templates/dcim/site.html | 14 ++++++++ netbox/templates/inc/image_attachments.html | 36 ++++++++++++++++++++ 6 files changed, 70 insertions(+), 37 deletions(-) create mode 100644 netbox/templates/inc/image_attachments.html diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 76d8c7fbc..fae85e2c1 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -254,6 +254,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail") comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + images = GenericRelation(ImageAttachment) objects = SiteManager() @@ -933,6 +934,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): blank=True, null=True, verbose_name='Primary IPv6') comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + images = GenericRelation(ImageAttachment) objects = DeviceManager() diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 9e35a1d85..7e9f680de 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -4,7 +4,7 @@ from ipam.views import ServiceEditView from secrets.views import secret_add from extras.views import ImageAttachmentEditView -from .models import Rack +from .models import Device, Rack, Site from . import views @@ -24,6 +24,7 @@ urlpatterns = [ url(r'^sites/(?P[\w-]+)/$', views.site, name='site'), url(r'^sites/(?P[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'), url(r'^sites/(?P[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'), + url(r'^sites/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), # Rack groups url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'), @@ -120,6 +121,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'), url(r'^devices/(?P\d+)/add-secret/$', secret_add, name='device_addsecret'), url(r'^devices/(?P\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'), + url(r'^devices/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), # Console ports url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 2df324a69..4e634d243 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -326,6 +326,20 @@ {% endif %}
    +
    +
    + Images +
    + {% include 'inc/image_attachments.html' with images=device.images.all %} + {% if perms.extras.add_imageattachment %} + + {% endif %} +
    Related Devices diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 4ef8277e2..22ae617d3 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -201,42 +201,7 @@
    Images
    - {% if rack.images.all %} - - - - - - - - {% for attachment in rack.images.all %} - - - - - - - {% endfor %} -
    NameSizeCreated
    - - {{ attachment }} - {{ attachment.image.size|filesizeformat }}{{ attachment.created }} - {% if perms.extras.change_imageattachment %} - - - - {% endif %} - {% if perms.extras.delete_imageattachment %} - - - - {% endif %} -
    - {% else %} -
    - None -
    - {% endif %} + {% include 'inc/image_attachments.html' with images=rack.images.all %} {% if perms.extras.add_imageattachment %} {% endif %}
    +
    Topology Maps diff --git a/netbox/templates/inc/image_attachments.html b/netbox/templates/inc/image_attachments.html new file mode 100644 index 000000000..0f922f3c6 --- /dev/null +++ b/netbox/templates/inc/image_attachments.html @@ -0,0 +1,36 @@ +{% if images %} + + + + + + + + {% for attachment in images %} + + + + + + + {% endfor %} +
    NameSizeCreated
    + + {{ attachment }} + {{ attachment.image.size|filesizeformat }}{{ attachment.created }} + {% if perms.extras.change_imageattachment %} + + + + {% endif %} + {% if perms.extras.delete_imageattachment %} + + + + {% endif %} +
    +{% else %} +
    + None +
    +{% endif %} From 6bbdc2bae168846289bfdc18eb844f13f1c99d52 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Mar 2017 15:51:17 -0400 Subject: [PATCH 128/182] Enable serving static media through Django --- netbox/netbox/urls.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 8a81e3ebb..8e4b5918d 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -38,6 +38,9 @@ _patterns = [ url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')), url(r'^api/docs/', include('rest_framework_swagger.urls')), + # Serving static media in Django to pipe it through LoginRequiredMiddleware + url(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), + # Error testing url(r'^500/$', trigger_500), @@ -50,7 +53,6 @@ if settings.DEBUG: import debug_toolbar _patterns += [ url(r'^__debug__/', include(debug_toolbar.urls)), - url(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), ] # Prepend BASE_PATH From a67fc64afb2814450d0f55e88dbc33bfb3fdcd07 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Apr 2017 11:04:17 -0400 Subject: [PATCH 129/182] Fixes #1025: Applied missing API view filters --- netbox/dcim/api/views.py | 7 ++++--- netbox/dcim/filters.py | 4 ++-- netbox/extras/api/views.py | 1 - netbox/ipam/api/views.py | 2 ++ netbox/secrets/api/views.py | 4 ++-- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 77344f4fa..1b6da12ba 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,4 +1,4 @@ -from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import detail_route from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet, ViewSet @@ -38,8 +38,8 @@ class RegionViewSet(WritableSerializerMixin, ModelViewSet): class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Site.objects.select_related('region', 'tenant') serializer_class = serializers.SiteSerializer - filter_class = filters.SiteFilter write_serializer_class = serializers.WritableSiteSerializer + filter_class = filters.SiteFilter @detail_route() def graphs(self, request, pk=None): @@ -59,8 +59,8 @@ class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet): class RackGroupViewSet(WritableSerializerMixin, ModelViewSet): queryset = RackGroup.objects.select_related('site') serializer_class = serializers.RackGroupSerializer - filter_class = filters.RackGroupFilter write_serializer_class = serializers.WritableRackGroupSerializer + filter_class = filters.RackGroupFilter # @@ -135,6 +135,7 @@ class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = DeviceType.objects.select_related('manufacturer') serializer_class = serializers.DeviceTypeSerializer write_serializer_class = serializers.WritableDeviceTypeSerializer + filter_class = filters.DeviceTypeFilter # diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 6eab5ae34..720cd0161 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -240,7 +240,7 @@ class InterfaceTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = InterfaceTemplate - fields = ['name'] + fields = ['name', 'form_factor'] class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet): @@ -448,7 +448,7 @@ class InterfaceFilter(DeviceComponentFilterSet): class Meta: model = Interface - fields = ['name'] + fields = ['name', 'form_factor'] def filter_type(self, queryset, name, value): value = value.strip().lower() diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index fab1ccdb5..09d72fe0b 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -51,7 +51,6 @@ class GraphViewSet(WritableSerializerMixin, ModelViewSet): class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet): queryset = ExportTemplate.objects.all() serializer_class = serializers.ExportTemplateSerializer - # write_serializer_class = serializers.WritableExportTemplateSerializer filter_class = filters.ExportTemplateFilter diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 3bd1f71c3..611931edf 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -34,6 +34,7 @@ class RoleViewSet(ModelViewSet): class RIRViewSet(ModelViewSet): queryset = RIR.objects.all() serializer_class = serializers.RIRSerializer + filter_class = filters.RIRFilter # @@ -99,3 +100,4 @@ class ServiceViewSet(WritableSerializerMixin, ModelViewSet): queryset = Service.objects.select_related('device') serializer_class = serializers.ServiceSerializer write_serializer_class = serializers.WritableServiceSerializer + filter_class = filters.ServiceFilter diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 601f7aece..0e6314e17 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -8,8 +8,8 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet, ViewSet +from secrets import filters from secrets.exceptions import InvalidKey -from secrets.filters import SecretFilter from secrets.models import Secret, SecretRole, SessionKey, UserKey from utilities.api import WritableSerializerMixin from . import serializers @@ -43,7 +43,7 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): ) serializer_class = serializers.SecretSerializer write_serializer_class = serializers.WritableSecretSerializer - filter_class = SecretFilter + filter_class = filters.SecretFilter master_key = None From 2c1fa628a24c240cc7ef87bf22605a78b159dd47 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Apr 2017 14:00:15 -0400 Subject: [PATCH 130/182] Implemented API endpoints for ImageAttachments --- netbox/extras/api/serializers.py | 57 ++++++++++++++++++- netbox/extras/api/urls.py | 3 + netbox/extras/api/views.py | 8 ++- .../migrations/0005_add_imageattachment.py | 2 +- netbox/extras/models.py | 2 +- netbox/utilities/api.py | 18 +++++- 6 files changed, 83 insertions(+), 7 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index a5c139c08..08da93aa0 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,9 +1,14 @@ from rest_framework import serializers -from dcim.api.serializers import NestedSiteSerializer -from extras.models import ACTION_CHOICES, Graph, GRAPH_TYPE_CHOICES, ExportTemplate, TopologyMap, UserAction +from django.core.exceptions import ObjectDoesNotExist + +from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer +from dcim.models import Device, Rack, Site +from extras.models import ( + ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction, +) from users.api.serializers import NestedUserSerializer -from utilities.api import ChoiceFieldSerializer +from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer # @@ -71,6 +76,52 @@ class WritableTopologyMapSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] +# +# Image attachments +# + +class ImageAttachmentSerializer(serializers.ModelSerializer): + parent = serializers.SerializerMethodField() + + class Meta: + model = ImageAttachment + fields = ['id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created'] + + def get_parent(self, obj): + + # Static mapping of models to their nested serializers + if isinstance(obj.parent, Device): + serializer = NestedDeviceSerializer + elif isinstance(obj.parent, Rack): + serializer = NestedRackSerializer + elif isinstance(obj.parent, Site): + serializer = NestedSiteSerializer + else: + raise Exception("Unexpected type of parent object for ImageAttachment") + + return serializer(obj.parent, context={'request': self.context['request']}).data + + +class WritableImageAttachmentSerializer(serializers.ModelSerializer): + content_type = ContentTypeFieldSerializer() + + class Meta: + model = ImageAttachment + fields = ['id', 'content_type', 'object_id', 'name', 'image'] + + def validate(self, data): + + # Validate that the parent object exists + try: + data['content_type'].get_object_for_this_type(id=data['object_id']) + except ObjectDoesNotExist: + raise serializers.ValidationError( + "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id']) + ) + + return data + + # # User actions # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 1623dcdeb..85ed93a24 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -23,6 +23,9 @@ router.register(r'export-templates', views.ExportTemplateViewSet) # Topology maps router.register(r'topology-maps', views.TopologyMapViewSet) +# Image attachments +router.register(r'image-attachments', views.ImageAttachmentViewSet) + # Recent activity router.register(r'recent-activity', views.RecentActivityViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index fab1ccdb5..d5b05fab4 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -6,7 +6,7 @@ from django.http import HttpResponse from django.shortcuts import get_object_or_404 from extras import filters -from extras.models import ExportTemplate, Graph, TopologyMap, UserAction +from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction from utilities.api import WritableSerializerMixin from . import serializers @@ -81,6 +81,12 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): return response +class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet): + queryset = ImageAttachment.objects.all() + serializer_class = serializers.ImageAttachmentSerializer + write_serializer_class = serializers.WritableImageAttachmentSerializer + + class RecentActivityViewSet(ReadOnlyModelViewSet): """ List all UserActions to provide a log of recent activity. diff --git a/netbox/extras/migrations/0005_add_imageattachment.py b/netbox/extras/migrations/0005_add_imageattachment.py index 23ed8b786..478762079 100644 --- a/netbox/extras/migrations/0005_add_imageattachment.py +++ b/netbox/extras/migrations/0005_add_imageattachment.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.6 on 2017-03-30 21:09 +# Generated by Django 1.10.6 on 2017-04-03 15:55 from __future__ import unicode_literals from django.db import migrations, models diff --git a/netbox/extras/models.py b/netbox/extras/models.py index cdf2af31c..9b31c3db4 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -384,7 +384,7 @@ class ImageAttachment(models.Model): """ content_type = models.ForeignKey(ContentType) object_id = models.PositiveIntegerField() - obj = GenericForeignKey('content_type', 'object_id') + parent = GenericForeignKey('content_type', 'object_id') image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width') image_height = models.PositiveSmallIntegerField() image_width = models.PositiveSmallIntegerField() diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index ca4384f08..b7dd61d0f 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,9 +1,10 @@ from django.conf import settings +from django.contrib.contenttypes.models import ContentType from rest_framework import authentication, exceptions from rest_framework.exceptions import APIException from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS -from rest_framework.serializers import Field +from rest_framework.serializers import Field, ValidationError from users.models import Token @@ -79,6 +80,21 @@ class ChoiceFieldSerializer(Field): return self._choices.get(data) +class ContentTypeFieldSerializer(Field): + """ + Represent a ContentType as '.' + """ + def to_representation(self, obj): + return "{}.{}".format(obj.app_label, obj.model) + + def to_internal_value(self, data): + app_label, model = data.split('.') + try: + return ContentType.objects.get_by_natural_key(app_label=app_label, model=model) + except ContentType.DoesNotExist: + raise ValidationError("Invalid content type") + + class WritableSerializerMixin(object): """ Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT). From 8799a15e734b59628e5f9c8c4cdeccf158fd81db Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Apr 2017 14:26:20 -0400 Subject: [PATCH 131/182] What would we do without you, PEP8? --- netbox/utilities/templatetags/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 3c5770cb3..c74c2601f 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -51,6 +51,7 @@ def startswith(value, arg): """ return str(value).startswith(arg) + @register.filter() def bettertitle(value): """ From 51725d3d9c848d606eb33e7f94fe52479a4044c4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Apr 2017 15:33:41 -0400 Subject: [PATCH 132/182] Added a search box to the navigation menu --- netbox/templates/_base.html | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 7e2555f18..a0c259dfb 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -240,13 +240,31 @@ {% endif %} {% endif %} + +
    + + + + +
    +