From 198170ca48504f7d0d5861513dcac6e2ba287e8d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 7 Nov 2017 15:36:10 -0500 Subject: [PATCH 01/75] Closes #1553: Introduced support for bulk object creation via the API --- netbox/circuits/api/views.py | 9 +++--- netbox/dcim/api/views.py | 48 ++++++++++++++---------------- netbox/extras/api/views.py | 12 ++++---- netbox/ipam/api/views.py | 17 +++++------ netbox/secrets/api/views.py | 6 ++-- netbox/tenancy/api/views.py | 6 ++-- netbox/utilities/api.py | 45 +++++++++++++++++----------- netbox/virtualization/api/views.py | 10 +++---- 8 files changed, 77 insertions(+), 76 deletions(-) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 666f67502..9b75bc184 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -3,14 +3,13 @@ from __future__ import unicode_literals from django.shortcuts import get_object_or_404 from rest_framework.decorators import detail_route from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet from circuits import filters from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet from extras.models import Graph, GRAPH_TYPE_PROVIDER -from utilities.api import FieldChoicesViewSet, WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, ModelViewSet from . import serializers @@ -28,7 +27,7 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet): # Providers # -class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class ProviderViewSet(CustomFieldModelViewSet): queryset = Provider.objects.all() serializer_class = serializers.ProviderSerializer write_serializer_class = serializers.WritableProviderSerializer @@ -59,7 +58,7 @@ class CircuitTypeViewSet(ModelViewSet): # Circuits # -class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.select_related('type', 'tenant', 'provider') serializer_class = serializers.CircuitSerializer write_serializer_class = serializers.WritableCircuitSerializer @@ -70,7 +69,7 @@ class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # Circuit Terminations # -class CircuitTerminationViewSet(WritableSerializerMixin, ModelViewSet): +class CircuitTerminationViewSet(ModelViewSet): queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device') serializer_class = serializers.CircuitTerminationSerializer write_serializer_class = serializers.WritableCircuitTerminationSerializer diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 1df0f9cbc..01711709c 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 rest_framework.decorators import detail_route from rest_framework.mixins import ListModelMixin from rest_framework.response import Response -from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet +from rest_framework.viewsets import GenericViewSet, ViewSet from dcim import filters from dcim.models import ( @@ -20,9 +20,7 @@ from dcim.models import ( 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 ( - IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ServiceUnavailable, WritableSerializerMixin, -) +from utilities.api import IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable from . import serializers from .exceptions import MissingFilterException @@ -47,7 +45,7 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet): # Regions # -class RegionViewSet(WritableSerializerMixin, ModelViewSet): +class RegionViewSet(ModelViewSet): queryset = Region.objects.all() serializer_class = serializers.RegionSerializer write_serializer_class = serializers.WritableRegionSerializer @@ -58,7 +56,7 @@ class RegionViewSet(WritableSerializerMixin, ModelViewSet): # Sites # -class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class SiteViewSet(CustomFieldModelViewSet): queryset = Site.objects.select_related('region', 'tenant') serializer_class = serializers.SiteSerializer write_serializer_class = serializers.WritableSiteSerializer @@ -79,7 +77,7 @@ class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # Rack groups # -class RackGroupViewSet(WritableSerializerMixin, ModelViewSet): +class RackGroupViewSet(ModelViewSet): queryset = RackGroup.objects.select_related('site') serializer_class = serializers.RackGroupSerializer write_serializer_class = serializers.WritableRackGroupSerializer @@ -100,7 +98,7 @@ class RackRoleViewSet(ModelViewSet): # Racks # -class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class RackViewSet(CustomFieldModelViewSet): queryset = Rack.objects.select_related('site', 'group__site', 'tenant') serializer_class = serializers.RackSerializer write_serializer_class = serializers.WritableRackSerializer @@ -131,7 +129,7 @@ class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # Rack reservations # -class RackReservationViewSet(WritableSerializerMixin, ModelViewSet): +class RackReservationViewSet(ModelViewSet): queryset = RackReservation.objects.select_related('rack') serializer_class = serializers.RackReservationSerializer write_serializer_class = serializers.WritableRackReservationSerializer @@ -156,7 +154,7 @@ class ManufacturerViewSet(ModelViewSet): # Device types # -class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class DeviceTypeViewSet(CustomFieldModelViewSet): queryset = DeviceType.objects.select_related('manufacturer') serializer_class = serializers.DeviceTypeSerializer write_serializer_class = serializers.WritableDeviceTypeSerializer @@ -167,42 +165,42 @@ class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # Device type components # -class ConsolePortTemplateViewSet(WritableSerializerMixin, ModelViewSet): +class ConsolePortTemplateViewSet(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): +class ConsoleServerPortTemplateViewSet(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): +class PowerPortTemplateViewSet(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): +class PowerOutletTemplateViewSet(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): +class InterfaceTemplateViewSet(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): +class DeviceBayTemplateViewSet(ModelViewSet): queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.DeviceBayTemplateSerializer write_serializer_class = serializers.WritableDeviceBayTemplateSerializer @@ -233,7 +231,7 @@ class PlatformViewSet(ModelViewSet): # Devices # -class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class DeviceViewSet(CustomFieldModelViewSet): queryset = Device.objects.select_related( 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay', ).prefetch_related( @@ -309,35 +307,35 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # Device components # -class ConsolePortViewSet(WritableSerializerMixin, ModelViewSet): +class ConsolePortViewSet(ModelViewSet): queryset = ConsolePort.objects.select_related('device', 'cs_port__device') serializer_class = serializers.ConsolePortSerializer write_serializer_class = serializers.WritableConsolePortSerializer filter_class = filters.ConsolePortFilter -class ConsoleServerPortViewSet(WritableSerializerMixin, ModelViewSet): +class ConsoleServerPortViewSet(ModelViewSet): queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device') serializer_class = serializers.ConsoleServerPortSerializer write_serializer_class = serializers.WritableConsoleServerPortSerializer filter_class = filters.ConsoleServerPortFilter -class PowerPortViewSet(WritableSerializerMixin, ModelViewSet): +class PowerPortViewSet(ModelViewSet): queryset = PowerPort.objects.select_related('device', 'power_outlet__device') serializer_class = serializers.PowerPortSerializer write_serializer_class = serializers.WritablePowerPortSerializer filter_class = filters.PowerPortFilter -class PowerOutletViewSet(WritableSerializerMixin, ModelViewSet): +class PowerOutletViewSet(ModelViewSet): queryset = PowerOutlet.objects.select_related('device', 'connected_port__device') serializer_class = serializers.PowerOutletSerializer write_serializer_class = serializers.WritablePowerOutletSerializer filter_class = filters.PowerOutletFilter -class InterfaceViewSet(WritableSerializerMixin, ModelViewSet): +class InterfaceViewSet(ModelViewSet): queryset = Interface.objects.select_related('device') serializer_class = serializers.InterfaceSerializer write_serializer_class = serializers.WritableInterfaceSerializer @@ -354,14 +352,14 @@ class InterfaceViewSet(WritableSerializerMixin, ModelViewSet): return Response(serializer.data) -class DeviceBayViewSet(WritableSerializerMixin, ModelViewSet): +class DeviceBayViewSet(ModelViewSet): queryset = DeviceBay.objects.select_related('installed_device') serializer_class = serializers.DeviceBaySerializer write_serializer_class = serializers.WritableDeviceBaySerializer filter_class = filters.DeviceBayFilter -class InventoryItemViewSet(WritableSerializerMixin, ModelViewSet): +class InventoryItemViewSet(ModelViewSet): queryset = InventoryItem.objects.select_related('device', 'manufacturer') serializer_class = serializers.InventoryItemSerializer write_serializer_class = serializers.WritableInventoryItemSerializer @@ -384,7 +382,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet): filter_class = filters.PowerConnectionFilter -class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet): +class InterfaceConnectionViewSet(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/views.py b/netbox/extras/api/views.py index c8d1e58c4..252c2d12c 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -6,12 +6,12 @@ from django.shortcuts import get_object_or_404 from rest_framework.decorators import detail_route from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet +from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet from extras import filters from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction from extras.reports import get_report, get_reports -from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet from . import serializers @@ -64,7 +64,7 @@ class CustomFieldModelViewSet(ModelViewSet): # Graphs # -class GraphViewSet(WritableSerializerMixin, ModelViewSet): +class GraphViewSet(ModelViewSet): queryset = Graph.objects.all() serializer_class = serializers.GraphSerializer write_serializer_class = serializers.WritableGraphSerializer @@ -75,7 +75,7 @@ class GraphViewSet(WritableSerializerMixin, ModelViewSet): # Export templates # -class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet): +class ExportTemplateViewSet(ModelViewSet): queryset = ExportTemplate.objects.all() serializer_class = serializers.ExportTemplateSerializer filter_class = filters.ExportTemplateFilter @@ -85,7 +85,7 @@ class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet): # Topology maps # -class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): +class TopologyMapViewSet(ModelViewSet): queryset = TopologyMap.objects.select_related('site') serializer_class = serializers.TopologyMapSerializer write_serializer_class = serializers.WritableTopologyMapSerializer @@ -115,7 +115,7 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): # Image attachments # -class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet): +class ImageAttachmentViewSet(ModelViewSet): queryset = ImageAttachment.objects.all() serializer_class = serializers.ImageAttachmentSerializer write_serializer_class = serializers.WritableImageAttachmentSerializer diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 649e74069..3c8f3c43d 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -6,12 +6,11 @@ from rest_framework import status from rest_framework.decorators import detail_route from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet from extras.api.views import CustomFieldModelViewSet from ipam import filters from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from utilities.api import FieldChoicesViewSet, WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, ModelViewSet from . import serializers @@ -33,7 +32,7 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet): # VRFs # -class VRFViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class VRFViewSet(CustomFieldModelViewSet): queryset = VRF.objects.select_related('tenant') serializer_class = serializers.VRFSerializer write_serializer_class = serializers.WritableVRFSerializer @@ -54,7 +53,7 @@ class RIRViewSet(ModelViewSet): # Aggregates # -class AggregateViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class AggregateViewSet(CustomFieldModelViewSet): queryset = Aggregate.objects.select_related('rir') serializer_class = serializers.AggregateSerializer write_serializer_class = serializers.WritableAggregateSerializer @@ -75,7 +74,7 @@ class RoleViewSet(ModelViewSet): # Prefixes # -class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class PrefixViewSet(CustomFieldModelViewSet): queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') serializer_class = serializers.PrefixSerializer write_serializer_class = serializers.WritablePrefixSerializer @@ -146,7 +145,7 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # IP addresses # -class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class IPAddressViewSet(CustomFieldModelViewSet): queryset = IPAddress.objects.select_related( 'vrf__tenant', 'tenant', 'nat_inside' ).prefetch_related( @@ -161,7 +160,7 @@ class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # VLAN groups # -class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet): +class VLANGroupViewSet(ModelViewSet): queryset = VLANGroup.objects.select_related('site') serializer_class = serializers.VLANGroupSerializer write_serializer_class = serializers.WritableVLANGroupSerializer @@ -172,7 +171,7 @@ class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet): # VLANs # -class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class VLANViewSet(CustomFieldModelViewSet): queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') serializer_class = serializers.VLANSerializer write_serializer_class = serializers.WritableVLANSerializer @@ -183,7 +182,7 @@ class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # Services # -class ServiceViewSet(WritableSerializerMixin, ModelViewSet): +class ServiceViewSet(ModelViewSet): queryset = Service.objects.select_related('device') serializer_class = serializers.ServiceSerializer write_serializer_class = serializers.WritableServiceSerializer diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index e2ffa3b28..a105e0505 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -7,12 +7,12 @@ from django.http import HttpResponseBadRequest from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet, ViewSet +from rest_framework.viewsets import ViewSet from secrets import filters from secrets.exceptions import InvalidKey from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.api import FieldChoicesViewSet, WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, ModelViewSet from . import serializers ERR_USERKEY_MISSING = "No UserKey found for the current user." @@ -44,7 +44,7 @@ class SecretRoleViewSet(ModelViewSet): # Secrets # -class SecretViewSet(WritableSerializerMixin, ModelViewSet): +class SecretViewSet(ModelViewSet): queryset = Secret.objects.select_related( 'device__primary_ip4', 'device__primary_ip6', 'role', ).prefetch_related( diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index c1f7d990d..26f9bc71e 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -1,11 +1,9 @@ from __future__ import unicode_literals -from rest_framework.viewsets import ModelViewSet - from extras.api.views import CustomFieldModelViewSet from tenancy import filters from tenancy.models import Tenant, TenantGroup -from utilities.api import FieldChoicesViewSet, WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, ModelViewSet from . import serializers @@ -31,7 +29,7 @@ class TenantGroupViewSet(ModelViewSet): # Tenants # -class TenantViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class TenantViewSet(CustomFieldModelViewSet): queryset = Tenant.objects.select_related('group') serializer_class = serializers.TenantSerializer write_serializer_class = serializers.WritableTenantSerializer diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 91e0fb8af..4f5ce4471 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -5,11 +5,12 @@ from collections import OrderedDict from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.http import Http404 +from rest_framework import mixins from rest_framework.exceptions import APIException from rest_framework.permissions import BasePermission from rest_framework.response import Response from rest_framework.serializers import Field, ModelSerializer, ValidationError -from rest_framework.viewsets import ViewSet +from rest_framework.viewsets import GenericViewSet, ViewSet WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] @@ -97,9 +98,33 @@ class ContentTypeFieldSerializer(Field): # -# Views +# Viewsets # +class ModelViewSet(mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + GenericViewSet): + """ + Substitute DRF's built-in ModelViewSet for our own, which introduces a bit of additional functionality: + 1. Use an alternate serializer (if provided) for write operations + 2. Accept either a single object or a list of objects to create + """ + def get_serializer_class(self): + # Check for a different serializer to use for write operations + if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'): + return self.write_serializer_class + return self.serializer_class + + def get_serializer(self, *args, **kwargs): + # If a list of objects has been provided, initialize the serializer with many=True + if isinstance(kwargs.get('data', {}), list): + kwargs['many'] = True + return super(ModelViewSet, self).get_serializer(*args, **kwargs) + + class FieldChoicesViewSet(ViewSet): """ Expose the built-in numeric values which represent static choices for a model's field. @@ -135,25 +160,9 @@ class FieldChoicesViewSet(ViewSet): return Response(self._fields) def retrieve(self, request, pk): - if pk not in self._fields: raise Http404 - return Response(self._fields[pk]) def get_view_name(self): return "Field Choices" - - -# -# Mixins -# - -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 - return self.serializer_class diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 2b7ce4b60..eadc93d58 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,10 +1,8 @@ from __future__ import unicode_literals -from rest_framework.viewsets import ModelViewSet - from dcim.models import Interface from extras.api.views import CustomFieldModelViewSet -from utilities.api import FieldChoicesViewSet, WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, ModelViewSet from virtualization import filters from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from . import serializers @@ -34,7 +32,7 @@ class ClusterGroupViewSet(ModelViewSet): serializer_class = serializers.ClusterGroupSerializer -class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class ClusterViewSet(CustomFieldModelViewSet): queryset = Cluster.objects.select_related('type', 'group') serializer_class = serializers.ClusterSerializer write_serializer_class = serializers.WritableClusterSerializer @@ -45,14 +43,14 @@ class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # Virtual machines # -class VirtualMachineViewSet(WritableSerializerMixin, CustomFieldModelViewSet): +class VirtualMachineViewSet(CustomFieldModelViewSet): queryset = VirtualMachine.objects.all() serializer_class = serializers.VirtualMachineSerializer write_serializer_class = serializers.WritableVirtualMachineSerializer filter_class = filters.VirtualMachineFilter -class InterfaceViewSet(WritableSerializerMixin, ModelViewSet): +class InterfaceViewSet(ModelViewSet): queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine') serializer_class = serializers.InterfaceSerializer write_serializer_class = serializers.WritableInterfaceSerializer From 593ae295e393553d98b4eb6faf9fbc6d9be4e4f4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Nov 2017 09:57:16 -0500 Subject: [PATCH 02/75] Removed prefix `parent` filter (see #1684) --- netbox/ipam/filters.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index f80374ca0..af31dca29 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -99,11 +99,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): method='search', label='Search', ) - # TODO: Deprecate in v2.3.0 - parent = django_filters.CharFilter( - method='search_within_include', - label='Parent prefix (deprecated)', - ) within = django_filters.CharFilter( method='search_within', label='Within prefix', @@ -172,17 +167,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): model = Prefix fields = ['family', 'is_pool'] - def search(self, queryset, name, value): - if not value.strip(): - return queryset - qs_filter = Q(description__icontains=value) - try: - prefix = str(IPNetwork(value.strip()).cidr) - qs_filter |= Q(prefix__net_contains_or_equals=prefix) - except (AddrFormatError, ValueError): - pass - return queryset.filter(qs_filter) - def search_within(self, queryset, name, value): value = value.strip() if not value: From c3e5106b04171ae2ac76c582ee71b9e2e05d4299 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Nov 2017 10:33:30 -0500 Subject: [PATCH 03/75] Restored search method on prefix filter --- netbox/ipam/filters.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index af31dca29..c8f87a894 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -167,6 +167,17 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): model = Prefix fields = ['family', 'is_pool'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(description__icontains=value) + try: + prefix = str(IPNetwork(value.strip()).cidr) + qs_filter |= Q(prefix__net_contains_or_equals=prefix) + except (AddrFormatError, ValueError): + pass + return queryset.filter(qs_filter) + def search_within(self, queryset, name, value): value = value.strip() if not value: From 4f2dc50b5c23c064a7137774e6c0fe25ec55299d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Nov 2017 13:48:33 -0500 Subject: [PATCH 04/75] Extended prefix 'available-ips' endpoint to accept multiple objects (related to #1553) --- netbox/ipam/api/views.py | 35 ++++++++++++++++++++++++----------- netbox/ipam/tests/test_api.py | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 3c8f3c43d..0e975146a 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -96,28 +96,41 @@ class PrefixViewSet(CustomFieldModelViewSet): if not request.user.has_perm('ipam.add_ipaddress'): raise PermissionDenied() - # Find the first available IP address in the prefix - try: - ipaddress = list(prefix.get_available_ips())[0] - except IndexError: + # Determine if the requested number of IPs is available + requested_count = len(request.data) if isinstance(request.data, list) else 1 + available_ips = list(prefix.get_available_ips()) + if len(available_ips) < requested_count: return Response( { - "detail": "There are no available IPs within this prefix ({})".format(prefix) + "detail": "An insufficient number of IP addresses are available within the prefix {} ({} " + "requested, {} available)".format(prefix, requested_count, len(available_ips)) }, status=status.HTTP_400_BAD_REQUEST ) - # Create the new IP address - data = request.data.copy() - data['address'] = '{}/{}'.format(ipaddress, prefix.prefix.prefixlen) - data['vrf'] = prefix.vrf.pk if prefix.vrf else None - serializer = serializers.WritableIPAddressSerializer(data=data) + # Deserializing multiple IP addresses + if isinstance(request.data, list): + request_data = list(request.data) # Need a mutable copy + for obj in request_data: + obj['address'] = available_ips.pop(0) + obj['vrf'] = prefix.vrf.pk if prefix.vrf else None + serializer = serializers.WritableIPAddressSerializer(data=request_data, many=True) + + # Deserializing a single IP address + else: + request_data = request.data.copy() # Need a mutable copy + request_data['address'] = available_ips.pop(0) + request_data['vrf'] = prefix.vrf.pk if prefix.vrf else None + serializer = serializers.WritableIPAddressSerializer(data=request_data) + + # Create the new IP address(es) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - # Determine the maximum amount of IPs to return + # Determine the maximum number of IPs to return else: try: limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT)) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index cba624a59..b39116b9f 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -365,7 +365,7 @@ class PrefixTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Prefix.objects.count(), 2) - def test_available_ips(self): + def test_list_available_ips(self): prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True) url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk}) @@ -380,12 +380,19 @@ class PrefixTest(HttpStatusMixin, APITestCase): response = self.client.get(url, **self.header) self.assertEqual(len(response.data), 6) # 8 - 2 because prefix.is_pool = False - # Create all six available IPs - for i in range(6): + def test_create_single_available_ip(self): + + prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), is_pool=True) + url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk}) + + # Create all four available IPs with individual requests + for i in range(1, 5): data = { 'description': 'Test IP {}'.format(i) } response = self.client.post(url, data, **self.header) + if response.status_code != status.HTTP_201_CREATED: + assert False, response.content self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(response.data['description'], data['description']) @@ -394,6 +401,27 @@ class PrefixTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertIn('detail', response.data) + def test_create_multiple_available_ips(self): + + prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True) + url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk}) + + # Try to create nine IPs (only eight are available) + data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 10)] # 9 IPs + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('detail', response.data) + + # Verify that no IPs were created (eight are still available) + response = self.client.get(url, **self.header) + self.assertEqual(len(response.data), 8) + + # Create all eight available IPs in a single request + data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 9)] # 8 IPs + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(len(response.data), 8) + class IPAddressTest(HttpStatusMixin, APITestCase): From e01e5e6b0e5e31cfdd44bdaafc2267881512a6b4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Nov 2017 13:54:35 -0500 Subject: [PATCH 05/75] Standardize on JSON data format for all POST/PUT test client requests --- netbox/circuits/tests/test_api.py | 16 ++-- netbox/dcim/tests/test_api.py | 100 ++++++++++++------------ netbox/extras/tests/test_api.py | 8 +- netbox/ipam/tests/test_api.py | 38 ++++----- netbox/secrets/tests/test_api.py | 8 +- netbox/tenancy/tests/test_api.py | 8 +- netbox/virtualization/tests/test_api.py | 16 ++-- 7 files changed, 97 insertions(+), 97 deletions(-) diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 0e588fe16..0a9ef0ab6 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -69,7 +69,7 @@ class ProviderTest(HttpStatusMixin, APITestCase): } url = reverse('circuits-api:provider-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Provider.objects.count(), 4) @@ -85,7 +85,7 @@ class ProviderTest(HttpStatusMixin, APITestCase): } url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Provider.objects.count(), 3) @@ -136,7 +136,7 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase): } url = reverse('circuits-api:circuittype-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(CircuitType.objects.count(), 4) @@ -152,7 +152,7 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase): } url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(CircuitType.objects.count(), 3) @@ -208,7 +208,7 @@ class CircuitTest(HttpStatusMixin, APITestCase): } url = reverse('circuits-api:circuit-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Circuit.objects.count(), 4) @@ -226,7 +226,7 @@ class CircuitTest(HttpStatusMixin, APITestCase): } url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Circuit.objects.count(), 3) @@ -293,7 +293,7 @@ class CircuitTerminationTest(HttpStatusMixin, APITestCase): } url = reverse('circuits-api:circuittermination-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(CircuitTermination.objects.count(), 4) @@ -313,7 +313,7 @@ class CircuitTerminationTest(HttpStatusMixin, APITestCase): } url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(CircuitTermination.objects.count(), 3) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index bec333974..68367e3ac 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -51,7 +51,7 @@ class RegionTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:region-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Region.objects.count(), 4) @@ -67,7 +67,7 @@ class RegionTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:region-detail', kwargs={'pk': self.region1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Region.objects.count(), 3) @@ -142,7 +142,7 @@ class SiteTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:site-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Site.objects.count(), 4) @@ -160,7 +160,7 @@ class SiteTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:site-detail', kwargs={'pk': self.site1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Site.objects.count(), 3) @@ -215,7 +215,7 @@ class RackGroupTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rackgroup-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(RackGroup.objects.count(), 4) @@ -233,7 +233,7 @@ class RackGroupTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rackgroup-detail', kwargs={'pk': self.rackgroup1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(RackGroup.objects.count(), 3) @@ -286,7 +286,7 @@ class RackRoleTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rackrole-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(RackRole.objects.count(), 4) @@ -304,7 +304,7 @@ class RackRoleTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rackrole-detail', kwargs={'pk': self.rackrole1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(RackRole.objects.count(), 3) @@ -377,7 +377,7 @@ class RackTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rack-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Rack.objects.count(), 4) @@ -397,7 +397,7 @@ class RackTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rack-detail', kwargs={'pk': self.rack1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Rack.objects.count(), 3) @@ -461,7 +461,7 @@ class RackReservationTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rackreservation-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(RackReservation.objects.count(), 4) @@ -481,7 +481,7 @@ class RackReservationTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rackreservation-detail', kwargs={'pk': self.rackreservation1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(RackReservation.objects.count(), 3) @@ -532,7 +532,7 @@ class ManufacturerTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:manufacturer-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Manufacturer.objects.count(), 4) @@ -548,7 +548,7 @@ class ManufacturerTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': self.manufacturer1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Manufacturer.objects.count(), 3) @@ -608,7 +608,7 @@ class DeviceTypeTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicetype-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(DeviceType.objects.count(), 4) @@ -626,7 +626,7 @@ class DeviceTypeTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.devicetype1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(DeviceType.objects.count(), 3) @@ -688,7 +688,7 @@ class ConsolePortTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleporttemplate-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(ConsolePortTemplate.objects.count(), 4) @@ -704,7 +704,7 @@ class ConsolePortTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleporttemplate-detail', kwargs={'pk': self.consoleporttemplate1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(ConsolePortTemplate.objects.count(), 3) @@ -764,7 +764,7 @@ class ConsoleServerPortTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleserverporttemplate-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(ConsoleServerPortTemplate.objects.count(), 4) @@ -780,7 +780,7 @@ class ConsoleServerPortTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleserverporttemplate-detail', kwargs={'pk': self.consoleserverporttemplate1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(ConsoleServerPortTemplate.objects.count(), 3) @@ -840,7 +840,7 @@ class PowerPortTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:powerporttemplate-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(PowerPortTemplate.objects.count(), 4) @@ -856,7 +856,7 @@ class PowerPortTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:powerporttemplate-detail', kwargs={'pk': self.powerporttemplate1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(PowerPortTemplate.objects.count(), 3) @@ -916,7 +916,7 @@ class PowerOutletTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:poweroutlettemplate-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(PowerOutletTemplate.objects.count(), 4) @@ -932,7 +932,7 @@ class PowerOutletTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:poweroutlettemplate-detail', kwargs={'pk': self.poweroutlettemplate1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(PowerOutletTemplate.objects.count(), 3) @@ -992,7 +992,7 @@ class InterfaceTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:interfacetemplate-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(InterfaceTemplate.objects.count(), 4) @@ -1008,7 +1008,7 @@ class InterfaceTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(InterfaceTemplate.objects.count(), 3) @@ -1068,7 +1068,7 @@ class DeviceBayTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicebaytemplate-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(DeviceBayTemplate.objects.count(), 4) @@ -1084,7 +1084,7 @@ class DeviceBayTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicebaytemplate-detail', kwargs={'pk': self.devicebaytemplate1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(DeviceBayTemplate.objects.count(), 3) @@ -1141,7 +1141,7 @@ class DeviceRoleTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicerole-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(DeviceRole.objects.count(), 4) @@ -1159,7 +1159,7 @@ class DeviceRoleTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicerole-detail', kwargs={'pk': self.devicerole1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(DeviceRole.objects.count(), 3) @@ -1211,7 +1211,7 @@ class PlatformTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:platform-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Platform.objects.count(), 4) @@ -1227,7 +1227,7 @@ class PlatformTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:platform-detail', kwargs={'pk': self.platform1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Platform.objects.count(), 3) @@ -1301,7 +1301,7 @@ class DeviceTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:device-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Device.objects.count(), 4) @@ -1321,7 +1321,7 @@ class DeviceTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Device.objects.count(), 3) @@ -1385,7 +1385,7 @@ class ConsolePortTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleport-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(ConsolePort.objects.count(), 4) @@ -1404,7 +1404,7 @@ class ConsolePortTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(ConsolePort.objects.count(), 3) @@ -1466,7 +1466,7 @@ class ConsoleServerPortTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleserverport-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(ConsoleServerPort.objects.count(), 4) @@ -1482,7 +1482,7 @@ class ConsoleServerPortTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleserverport-detail', kwargs={'pk': self.consoleserverport1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(ConsoleServerPort.objects.count(), 3) @@ -1543,7 +1543,7 @@ class PowerPortTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:powerport-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(PowerPort.objects.count(), 4) @@ -1562,7 +1562,7 @@ class PowerPortTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(PowerPort.objects.count(), 3) @@ -1624,7 +1624,7 @@ class PowerOutletTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:poweroutlet-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(PowerOutlet.objects.count(), 4) @@ -1640,7 +1640,7 @@ class PowerOutletTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:poweroutlet-detail', kwargs={'pk': self.poweroutlet1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(PowerOutlet.objects.count(), 3) @@ -1722,7 +1722,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:interface-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Interface.objects.count(), 4) @@ -1743,7 +1743,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Interface.objects.count(), 4) @@ -1814,7 +1814,7 @@ class DeviceBayTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicebay-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(DeviceBay.objects.count(), 4) @@ -1832,7 +1832,7 @@ class DeviceBayTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicebay-detail', kwargs={'pk': self.devicebay1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(DeviceBay.objects.count(), 3) @@ -1896,7 +1896,7 @@ class InventoryItemTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:inventoryitem-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(InventoryItem.objects.count(), 4) @@ -1916,7 +1916,7 @@ class InventoryItemTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:inventoryitem-detail', kwargs={'pk': self.inventoryitem1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(InventoryItem.objects.count(), 3) @@ -2081,7 +2081,7 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:interfaceconnection-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(InterfaceConnection.objects.count(), 4) @@ -2100,7 +2100,7 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(InterfaceConnection.objects.count(), 3) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index fde9c3185..46e0b3bda 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -54,7 +54,7 @@ class GraphTest(HttpStatusMixin, APITestCase): } url = reverse('extras-api:graph-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Graph.objects.count(), 4) @@ -72,7 +72,7 @@ class GraphTest(HttpStatusMixin, APITestCase): } url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Graph.objects.count(), 3) @@ -135,7 +135,7 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('extras-api:exporttemplate-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(ExportTemplate.objects.count(), 4) @@ -153,7 +153,7 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(ExportTemplate.objects.count(), 3) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index b39116b9f..55a69c7c3 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -47,7 +47,7 @@ class VRFTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:vrf-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(VRF.objects.count(), 4) @@ -63,7 +63,7 @@ class VRFTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(VRF.objects.count(), 3) @@ -114,7 +114,7 @@ class RIRTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:rir-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(RIR.objects.count(), 4) @@ -130,7 +130,7 @@ class RIRTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(RIR.objects.count(), 3) @@ -183,7 +183,7 @@ class AggregateTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:aggregate-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Aggregate.objects.count(), 4) @@ -199,7 +199,7 @@ class AggregateTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Aggregate.objects.count(), 3) @@ -250,7 +250,7 @@ class RoleTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:role-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Role.objects.count(), 4) @@ -266,7 +266,7 @@ class RoleTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Role.objects.count(), 3) @@ -324,7 +324,7 @@ class PrefixTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:prefix-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Prefix.objects.count(), 4) @@ -346,7 +346,7 @@ class PrefixTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Prefix.objects.count(), 3) @@ -390,7 +390,7 @@ class PrefixTest(HttpStatusMixin, APITestCase): data = { 'description': 'Test IP {}'.format(i) } - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) if response.status_code != status.HTTP_201_CREATED: assert False, response.content self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -458,7 +458,7 @@ class IPAddressTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:ipaddress-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(IPAddress.objects.count(), 4) @@ -474,7 +474,7 @@ class IPAddressTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(IPAddress.objects.count(), 3) @@ -525,7 +525,7 @@ class VLANGroupTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:vlangroup-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(VLANGroup.objects.count(), 4) @@ -541,7 +541,7 @@ class VLANGroupTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(VLANGroup.objects.count(), 3) @@ -592,7 +592,7 @@ class VLANTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:vlan-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(VLAN.objects.count(), 4) @@ -608,7 +608,7 @@ class VLANTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(VLAN.objects.count(), 3) @@ -677,7 +677,7 @@ class ServiceTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:service-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Service.objects.count(), 4) @@ -697,7 +697,7 @@ class ServiceTest(HttpStatusMixin, APITestCase): } url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Service.objects.count(), 3) diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 02b4d3e90..3a51959c1 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -86,7 +86,7 @@ class SecretRoleTest(HttpStatusMixin, APITestCase): } url = reverse('secrets-api:secretrole-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(SecretRole.objects.count(), 4) @@ -102,7 +102,7 @@ class SecretRoleTest(HttpStatusMixin, APITestCase): } url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(SecretRole.objects.count(), 3) @@ -191,7 +191,7 @@ class SecretTest(HttpStatusMixin, APITestCase): } url = reverse('secrets-api:secret-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(response.data['plaintext'], data['plaintext']) @@ -210,7 +210,7 @@ class SecretTest(HttpStatusMixin, APITestCase): } url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['plaintext'], data['plaintext']) diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 1ac05ea89..2930d464a 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -44,7 +44,7 @@ class TenantGroupTest(HttpStatusMixin, APITestCase): } url = reverse('tenancy-api:tenantgroup-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(TenantGroup.objects.count(), 4) @@ -60,7 +60,7 @@ class TenantGroupTest(HttpStatusMixin, APITestCase): } url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(TenantGroup.objects.count(), 3) @@ -114,7 +114,7 @@ class TenantTest(HttpStatusMixin, APITestCase): } url = reverse('tenancy-api:tenant-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Tenant.objects.count(), 4) @@ -132,7 +132,7 @@ class TenantTest(HttpStatusMixin, APITestCase): } url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Tenant.objects.count(), 3) diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index f83e0ea58..ee06ba31b 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -44,7 +44,7 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase): } url = reverse('virtualization-api:clustertype-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(ClusterType.objects.count(), 4) @@ -60,7 +60,7 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase): } url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(ClusterType.objects.count(), 3) @@ -111,7 +111,7 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase): } url = reverse('virtualization-api:clustergroup-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(ClusterGroup.objects.count(), 4) @@ -127,7 +127,7 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase): } url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(ClusterGroup.objects.count(), 3) @@ -182,7 +182,7 @@ class ClusterTest(HttpStatusMixin, APITestCase): } url = reverse('virtualization-api:cluster-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Cluster.objects.count(), 4) @@ -202,7 +202,7 @@ class ClusterTest(HttpStatusMixin, APITestCase): } url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Cluster.objects.count(), 3) @@ -258,7 +258,7 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase): } url = reverse('virtualization-api:virtualmachine-list') - response = self.client.post(url, data, **self.header) + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(VirtualMachine.objects.count(), 4) @@ -279,7 +279,7 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase): } url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk}) - response = self.client.put(url, data, **self.header) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(VirtualMachine.objects.count(), 3) From 5d46a112f808ff197af29c643418784075134bd6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Nov 2017 16:59:50 -0500 Subject: [PATCH 06/75] #1694: Initial work on "next available" prefix provisioning --- netbox/ipam/api/serializers.py | 14 +++++++ netbox/ipam/api/views.py | 59 +++++++++++++++++++++++++++++ netbox/ipam/models.py | 15 ++++++++ netbox/ipam/tests/test_api.py | 68 +++++++++++++++++++++++++++++++++- 4 files changed, 154 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e4e14e4e4..e520daa4c 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -237,6 +237,20 @@ class WritablePrefixSerializer(CustomFieldModelSerializer): ] +class AvailablePrefixSerializer(serializers.Serializer): + + def to_representation(self, instance): + if self.context.get('vrf'): + vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data + else: + vrf = None + return OrderedDict([ + ('family', instance.version), + ('prefix', str(instance)), + ('vrf', vrf), + ]) + + # # IP addresses # diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 0e975146a..b326b8ea6 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -80,6 +80,65 @@ class PrefixViewSet(CustomFieldModelViewSet): write_serializer_class = serializers.WritablePrefixSerializer filter_class = filters.PrefixFilter + @detail_route(url_path='available-prefixes', methods=['get', 'post']) + def available_prefixes(self, request, pk=None): + """ + A convenience method for returning available child prefixes within a parent. + """ + prefix = get_object_or_404(Prefix, pk=pk) + available_prefixes = prefix.get_available_prefixes() + + if request.method == 'POST': + + # Permissions check + if not request.user.has_perm('ipam.add_prefix'): + raise PermissionDenied() + + requested_prefixes = request.data if isinstance(request.data, list) else [request.data] + + # Allocate prefixes to the requested objects based on availability within the parent + for requested_prefix in requested_prefixes: + + # Find the first available prefix equal to or larger than the requested size + for available_prefix in available_prefixes.iter_cidrs(): + if requested_prefix['prefix_length'] >= available_prefix.prefixlen: + allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length']) + requested_prefix['prefix'] = allocated_prefix + requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None + break + else: + return Response( + { + "detail": "Insufficient space is available to accommodate the requested prefix size(s)" + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # Remove the allocated prefix from the list of available prefixes + available_prefixes.remove(allocated_prefix) + + # Initialize the serializer with a list or a single object depending on what was requested + if isinstance(request.data, list): + serializer = serializers.WritablePrefixSerializer(data=requested_prefixes, many=True) + else: + serializer = serializers.WritablePrefixSerializer(data=requested_prefixes[0]) + + # Create the new Prefix(es) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + else: + + serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={ + 'request': request, + 'vrf': prefix.vrf, + }) + + return Response(serializer.data) + @detail_route(url_path='available-ips', methods=['get', 'post']) def available_ips(self, request, pk=None): """ diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 6e4788840..f246cf7fe 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -281,6 +281,21 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): def get_duplicates(self): return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk) + def get_child_prefixes(self): + """ + Return all child Prefixes within this Prefix. + """ + return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf) + + def get_available_prefixes(self): + """ + Return all available prefixes within this Prefix. + """ + prefix = netaddr.IPSet(self.prefix) + child_prefixes = netaddr.IPSet([p.prefix for p in self.get_child_prefixes()]) + available_prefixes = prefix - child_prefixes + return available_prefixes + def get_child_ips(self): """ Return all IPAddresses within this Prefix. diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 55a69c7c3..0f07e34ce 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -365,6 +365,72 @@ class PrefixTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Prefix.objects.count(), 2) + def test_list_available_prefixes(self): + + prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24')) + Prefix.objects.create(prefix=IPNetwork('192.0.2.64/26')) + Prefix.objects.create(prefix=IPNetwork('192.0.2.192/27')) + url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk}) + + # Retrieve all available IPs + response = self.client.get(url, **self.header) + available_prefixes = ['192.0.2.0/26', '192.0.2.128/26', '192.0.2.224/27'] + for i, p in enumerate(response.data): + self.assertEqual(p['prefix'], available_prefixes[i]) + + def test_create_single_available_prefix(self): + + prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True) + url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk}) + + # Create four available prefixes with individual requests + prefixes_to_be_created = [ + '192.0.2.0/30', + '192.0.2.4/30', + '192.0.2.8/30', + '192.0.2.12/30', + ] + for i in range(4): + data = { + 'prefix_length': 30, + 'description': 'Test Prefix {}'.format(i + 1) + } + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(response.data['prefix'], prefixes_to_be_created[i]) + self.assertEqual(response.data['description'], data['description']) + + # Try to create one more prefix + response = self.client.post(url, {'prefix_length': 30}, **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('detail', response.data) + + def test_create_multiple_available_prefixes(self): + + prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True) + url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk}) + + # Try to create five /30s (only four are available) + data = [ + {'prefix_length': 30, 'description': 'Test Prefix 1'}, + {'prefix_length': 30, 'description': 'Test Prefix 2'}, + {'prefix_length': 30, 'description': 'Test Prefix 3'}, + {'prefix_length': 30, 'description': 'Test Prefix 4'}, + {'prefix_length': 30, 'description': 'Test Prefix 5'}, + ] + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('detail', response.data) + + # Verify that no prefixes were created (the entire /28 is still available) + response = self.client.get(url, **self.header) + self.assertEqual(response.data[0]['prefix'], '192.0.2.0/28') + + # Create four /30s in a single request + response = self.client.post(url, data[:4], format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(len(response.data), 4) + def test_list_available_ips(self): prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True) @@ -391,8 +457,6 @@ class PrefixTest(HttpStatusMixin, APITestCase): 'description': 'Test IP {}'.format(i) } response = self.client.post(url, data, format='json', **self.header) - if response.status_code != status.HTTP_201_CREATED: - assert False, response.content self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(response.data['description'], data['description']) From 5c1338207127f139fff587512cee991b8335e57e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 14 Nov 2017 15:07:13 -0500 Subject: [PATCH 07/75] Closes #1706: Added deprecation warning for Python 2 --- netbox/netbox/settings.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 557b8f067..cba139e56 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -1,6 +1,8 @@ import logging import os import socket +import sys +import warnings from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured @@ -12,6 +14,13 @@ except ImportError: "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation." ) +# Raise a deprecation warning for Python 2.x +if sys.version_info[0] < 3: + warnings.warn( + "Support for Python 2 will be removed in NetBox v2.5. Please consider migration to Python 3 at your earliest " + "opportunity. Guidance is available in the documentation at http://netbox.readthedocs.io/.", + DeprecationWarning + ) VERSION = '2.2.6-dev' From ba42ad211596725a359d6b70efbca4dfc32460db Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 14 Nov 2017 15:22:40 -0500 Subject: [PATCH 08/75] Merge branch '150-interface-vlans' into develop-2.3 --- netbox/dcim/api/serializers.py | 50 +++- netbox/dcim/constants.py | 9 + netbox/dcim/forms.py | 261 +++++++++++++++++- .../migrations/0050_interface_vlan_tagging.py | 32 +++ netbox/dcim/models.py | 14 + netbox/dcim/views.py | 1 + netbox/project-static/js/forms.js | 96 ++++--- netbox/templates/dcim/interface_edit.html | 28 ++ netbox/utilities/api.py | 5 + netbox/utilities/constants.py | 7 + netbox/utilities/views.py | 20 ++ netbox/virtualization/models.py | 5 + 12 files changed, 469 insertions(+), 59 deletions(-) create mode 100644 netbox/dcim/migrations/0050_interface_vlan_tagging.py create mode 100644 netbox/templates/dcim/interface_edit.html create mode 100644 netbox/utilities/constants.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 8f6b3ada8..0046a2be4 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -7,8 +7,8 @@ from rest_framework.validators import UniqueTogetherValidator from circuits.models import Circuit, CircuitTermination from dcim.constants import ( - CONNECTION_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, - RACK_WIDTH_CHOICES, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, + CONNECTION_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_MODE_CHOICES, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES, + RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, ) from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, @@ -17,7 +17,7 @@ from dcim.models import ( RackReservation, RackRole, Region, Site, ) from extras.api.customfields import CustomFieldModelSerializer -from ipam.models import IPAddress +from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer from virtualization.models import Cluster @@ -628,6 +628,15 @@ class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer): ] +# Cannot import ipam.api.NestedVLANSerializer due to circular dependency +class InterfaceVLANSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') + + class Meta: + model = VLAN + fields = ['id', 'url', 'vid', 'name', 'display_name'] + + class InterfaceSerializer(serializers.ModelSerializer): device = NestedDeviceSerializer() form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) @@ -635,12 +644,15 @@ class InterfaceSerializer(serializers.ModelSerializer): is_connected = serializers.SerializerMethodField(read_only=True) interface_connection = serializers.SerializerMethodField(read_only=True) circuit_termination = InterfaceCircuitTerminationSerializer() + untagged_vlan = InterfaceVLANSerializer() + mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES) + tagged_vlans = InterfaceVLANSerializer(many=True) class Meta: model = Interface fields = [ 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', - 'is_connected', 'interface_connection', 'circuit_termination', + 'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans', ] def get_is_connected(self, obj): @@ -685,7 +697,37 @@ class WritableInterfaceSerializer(ValidatedModelSerializer): model = Interface fields = [ 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', + 'mode', 'untagged_vlan', 'tagged_vlans', ] + ignore_validation_fields = [ + 'tagged_vlans' + ] + + def validate(self, data): + + # Get the device for later use + if self.instance: + device = self.instance.device + else: + device = data.get('device') + + # Validate VLANs belong to the device's site or global + # We have to do this here decause of the ManyToMany relationship + native_vlan = data.get('native_vlan') + if native_vlan: + if native_vlan.site != device.site and native_vlan.site is not None: + raise serializers.ValidationError("Native VLAN is invalid for the interface's device.") + + tagged_vlan_members = data.get('tagged_vlan_members') + if tagged_vlan_members: + for vlan in tagged_vlan_members: + if vlan.site != device.site and vlan.site is not None: + raise serializers.ValidationError("Tagged VLAN {} is invalid for the interface's device.".format(vlan)) + + # Enforce model validation + super(WritableInterfaceSerializer, self).validate(data) + + return data # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index b355e6c85..1948f32a2 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -193,6 +193,15 @@ WIRELESS_IFACE_TYPES = [ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES +IFACE_MODE_ACCESS = 100 +IFACE_MODE_TAGGED = 200 +IFACE_MODE_TAGGED_ALL = 300 +IFACE_MODE_CHOICES = [ + [IFACE_MODE_ACCESS, 'Access'], + [IFACE_MODE_TAGGED, 'Tagged'], + [IFACE_MODE_TAGGED_ALL, 'Tagged All'], +] + # Device statuses STATUS_OFFLINE = 0 STATUS_ACTIVE = 1 diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 1f5d50c4d..a8b4b1cf5 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -9,14 +9,15 @@ from django.db.models import Count, Q from mptt.forms import TreeNodeChoiceField from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm -from ipam.models import IPAddress +from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, - ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, - SlugField, FilterTreeNodeMultipleChoiceField, + APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, + BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, + CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, + FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, + FilterTreeNodeMultipleChoiceField, ) from virtualization.models import Cluster from .constants import ( @@ -31,6 +32,7 @@ from .models import ( Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, ) +from .constants import * DEVICE_BY_PK_RE = '{\d+\}' @@ -1601,11 +1603,59 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm): # Interfaces # -class InterfaceForm(BootstrapMixin, forms.ModelForm): +class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin): + + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + label='VLAN Site', + widget=forms.Select( + attrs={'filter-for': 'vlan_group', 'nullable': 'true'}, + ) + ) + vlan_group = ChainedModelChoiceField( + queryset=VLANGroup.objects.all(), + chains=( + ('site', 'site'), + ), + required=False, + label='VLAN group', + widget=APISelect( + attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'}, + api_url='/api/ipam/vlan-groups/?site_id={{site}}', + ) + ) + untagged_vlan = ChainedModelChoiceField( + queryset=VLAN.objects.all(), + chains=( + ('site', 'site'), + ('group', 'vlan_group'), + ), + required=False, + label='Untagged VLAN', + widget=APISelect( + api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', + ) + ) + tagged_vlans = ChainedModelMultipleChoiceField( + queryset=VLAN.objects.all(), + chains=( + ('site', 'site'), + ('group', 'vlan_group'), + ), + required=False, + label='Tagged VLANs', + widget=APISelectMultiple( + api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', + ) + ) class Meta: model = Interface - fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description'] + fields = [ + 'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', + 'description', 'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans', + ] widgets = { 'device': forms.HiddenInput(), } @@ -1618,13 +1668,65 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): self.fields['lag'].queryset = Interface.objects.order_naturally().filter( device_id=self.data['device'], form_factor=IFACE_FF_LAG ) + device = Device.objects.get(pk=self.data['device']) else: self.fields['lag'].queryset = Interface.objects.order_naturally().filter( device=self.instance.device, form_factor=IFACE_FF_LAG ) + device = self.instance.device + + # Limit the queryset for the site to only include the interface's device's site + if device and device.site: + self.fields['site'].queryset = Site.objects.filter(pk=device.site.id) + self.fields['site'].initial = None + else: + self.fields['site'].queryset = Site.objects.none() + self.fields['site'].initial = None + + # Limit the initial vlan choices + if self.is_bound: + filter_dict = { + 'group_id': self.data.get('vlan_group') or None, + 'site_id': self.data.get('site') or None, + } + elif self.initial.get('untagged_vlan'): + filter_dict = { + 'group_id': self.instance.untagged_vlan.group, + 'site_id': self.instance.untagged_vlan.site, + } + elif self.initial.get('tagged_vlans'): + filter_dict = { + 'group_id': self.instance.tagged_vlans.first().group, + 'site_id': self.instance.tagged_vlans.first().site, + } + else: + filter_dict = { + 'group_id': None, + 'site_id': None, + } + + self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict) + self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict) + + def clean_tagged_vlans(self): + """ + Becasue tagged_vlans is a many-to-many relationship, validation must be done in the form + """ + if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and self.cleaned_data['tagged_vlans']: + raise forms.ValidationError( + "An Access interface cannot have tagged VLANs." + ) + + if self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL and self.cleaned_data['tagged_vlans']: + raise forms.ValidationError( + "Interface mode Tagged All implies all VLANs are tagged. " + "Do not select any tagged VLANs." + ) + + return self.cleaned_data['tagged_vlans'] -class InterfaceCreateForm(ComponentForm): +class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) enabled = forms.BooleanField(required=False) @@ -1633,6 +1735,51 @@ class InterfaceCreateForm(ComponentForm): mac_address = MACAddressFormField(required=False, label='MAC Address') mgmt_only = forms.BooleanField(required=False, label='OOB Management') description = forms.CharField(max_length=100, required=False) + mode = forms.ChoiceField(choices=IFACE_MODE_CHOICES) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + label='VLAN Site', + widget=forms.Select( + attrs={'filter-for': 'vlan_group', 'nullable': 'true'}, + ) + ) + vlan_group = ChainedModelChoiceField( + queryset=VLANGroup.objects.all(), + chains=( + ('site', 'site'), + ), + required=False, + label='VLAN group', + widget=APISelect( + attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'}, + api_url='/api/ipam/vlan-groups/?site_id={{site}}', + ) + ) + untagged_vlan = ChainedModelChoiceField( + queryset=VLAN.objects.all(), + chains=( + ('site', 'site'), + ('group', 'vlan_group'), + ), + required=False, + label='Untagged VLAN', + widget=APISelect( + api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', + ) + ) + tagged_vlans = ChainedModelMultipleChoiceField( + queryset=VLAN.objects.all(), + chains=( + ('site', 'site'), + ('group', 'vlan_group'), + ), + required=False, + label='Tagged VLANs', + widget=APISelectMultiple( + api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', + ) + ) def __init__(self, *args, **kwargs): @@ -1650,8 +1797,41 @@ class InterfaceCreateForm(ComponentForm): else: self.fields['lag'].queryset = Interface.objects.none() + # Limit the queryset for the site to only include the interface's device's site + if self.parent is not None and self.parent.site: + self.fields['site'].queryset = Site.objects.filter(pk=self.parent.site.id) + self.fields['site'].initial = None + else: + self.fields['site'].queryset = Site.objects.none() + self.fields['site'].initial = None -class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): + # Limit the initial vlan choices + if self.is_bound: + filter_dict = { + 'group_id': self.data.get('vlan_group') or None, + 'site_id': self.data.get('site') or None, + } + elif self.initial.get('untagged_vlan'): + filter_dict = { + 'group_id': self.untagged_vlan.group, + 'site_id': self.untagged_vlan.site, + } + elif self.initial.get('tagged_vlans'): + filter_dict = { + 'group_id': self.tagged_vlans.first().group, + 'site_id': self.tagged_vlans.first().site, + } + else: + filter_dict = { + 'group_id': None, + 'site_id': None, + } + + self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict) + self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict) + + +class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput) form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) @@ -1660,9 +1840,54 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only') description = forms.CharField(max_length=100, required=False) + mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + label='VLAN Site', + widget=forms.Select( + attrs={'filter-for': 'vlan_group', 'nullable': 'true'}, + ) + ) + vlan_group = ChainedModelChoiceField( + queryset=VLANGroup.objects.all(), + chains=( + ('site', 'site'), + ), + required=False, + label='VLAN group', + widget=APISelect( + attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'}, + api_url='/api/ipam/vlan-groups/?site_id={{site}}', + ) + ) + untagged_vlan = ChainedModelChoiceField( + queryset=VLAN.objects.all(), + chains=( + ('site', 'site'), + ('group', 'vlan_group'), + ), + required=False, + label='Untagged VLAN', + widget=APISelect( + api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', + ) + ) + tagged_vlans = ChainedModelMultipleChoiceField( + queryset=VLAN.objects.all(), + chains=( + ('site', 'site'), + ('group', 'vlan_group'), + ), + required=False, + label='Tagged VLANs', + widget=APISelectMultiple( + api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', + ) + ) class Meta: - nullable_fields = ['lag', 'mtu', 'description'] + nullable_fields = ['lag', 'mtu', 'description', 'untagged_vlan', 'tagged_vlans'] def __init__(self, *args, **kwargs): super(InterfaceBulkEditForm, self).__init__(*args, **kwargs) @@ -1682,6 +1907,22 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): else: self.fields['lag'].choices = [] + # Limit the queryset for the site to only include the interface's device's site + if device and device.site: + self.fields['site'].queryset = Site.objects.filter(pk=device.site.id) + self.fields['site'].initial = None + else: + self.fields['site'].queryset = Site.objects.none() + self.fields['site'].initial = None + + filter_dict = { + 'group_id': None, + 'site_id': None, + } + + self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict) + self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict) + class InterfaceBulkDisconnectForm(ConfirmationForm): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) diff --git a/netbox/dcim/migrations/0050_interface_vlan_tagging.py b/netbox/dcim/migrations/0050_interface_vlan_tagging.py new file mode 100644 index 000000000..cb44054a7 --- /dev/null +++ b/netbox/dcim/migrations/0050_interface_vlan_tagging.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-11-10 20:10 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0020_ipaddress_add_role_carp'), + ('dcim', '0049_rackreservation_change_user'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='mode', + field=models.PositiveSmallIntegerField(choices=[[100, 'Access'], [200, 'Tagged'], [300, 'Tagged All']], default=100), + ), + migrations.AddField( + model_name='interface', + name='tagged_vlans', + field=models.ManyToManyField(blank=True, related_name='interfaces_as_tagged', to='ipam.VLAN', verbose_name='Tagged VLANs'), + ), + migrations.AddField( + model_name='interface', + name='untagged_vlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces_as_untagged', to='ipam.VLAN', verbose_name='Untagged VLAN'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 84d4dc39c..1012924b7 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1244,6 +1244,20 @@ class Interface(models.Model): help_text="This interface is used only for out-of-band management" ) description = models.CharField(max_length=100, blank=True) + mode = models.PositiveSmallIntegerField(choices=IFACE_MODE_CHOICES, default=IFACE_MODE_ACCESS) + untagged_vlan = models.ForeignKey( + to='ipam.VLAN', + null=True, + blank=True, + verbose_name='Untagged VLAN', + related_name='interfaces_as_untagged' + ) + tagged_vlans = models.ManyToManyField( + to='ipam.VLAN', + blank=True, + verbose_name='Tagged VLANs', + related_name='interfaces_as_tagged' + ) objects = InterfaceQuerySet.as_manager() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 674fa5b8f..a24720bce 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1485,6 +1485,7 @@ class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): model = Interface parent_field = 'device' model_form = forms.InterfaceForm + template_name = 'dcim/interface_edit.html' class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 945a94d45..50ee20e16 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -71,59 +71,65 @@ $(document).ready(function() { $('select[filter-for]').change(function() { // Resolve child field by ID specified in parent - var child_name = $(this).attr('filter-for'); - var child_field = $('#id_' + child_name); - var child_selected = child_field.val(); + var child_names = $(this).attr('filter-for'); + var parent = this; - // Wipe out any existing options within the child field and create a default option - child_field.empty(); - if (!child_field.attr('multiple')) { - child_field.append($("").attr("value", "").text("---------")); - } + // allow more than one child + $.each(child_names.split(" "), function(_, child_name){ - if ($(this).val() || $(this).attr('nullable') == 'true') { - var api_url = child_field.attr('api-url') + '&limit=1000'; - var disabled_indicator = child_field.attr('disabled-indicator'); - var initial_value = child_field.attr('initial'); - var display_field = child_field.attr('display-field') || 'name'; + var child_field = $('#id_' + child_name); + var child_selected = child_field.val(); - // Determine the filter fields needed to make an API call - var filter_regex = /\{\{([a-z_]+)\}\}/g; - var match; - var rendered_url = api_url; - while (match = filter_regex.exec(api_url)) { - var filter_field = $('#id_' + match[1]); - if (filter_field.val()) { - rendered_url = rendered_url.replace(match[0], filter_field.val()); - } else if (filter_field.attr('nullable') == 'true') { - rendered_url = rendered_url.replace(match[0], '0'); - } + // Wipe out any existing options within the child field and create a default option + child_field.empty(); + if (!child_field.attr('multiple')) { + child_field.append($("").attr("value", "").text("---------")); } - // If all URL variables have been replaced, make the API call - if (rendered_url.search('{{') < 0) { - console.log(child_name + ": Fetching " + rendered_url); - $.ajax({ - url: rendered_url, - dataType: 'json', - 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"); - } else if (choice.id == child_selected) { - option.attr("selected", "selected"); - } - child_field.append(option); - }); + if ($(parent).val() || $(parent).attr('nullable') == 'true') { + var api_url = child_field.attr('api-url') + '&limit=1000'; + var disabled_indicator = child_field.attr('disabled-indicator'); + var initial_value = child_field.attr('initial'); + var display_field = child_field.attr('display-field') || 'name'; + + // Determine the filter fields needed to make an API call + var filter_regex = /\{\{([a-z_]+)\}\}/g; + var match; + var rendered_url = api_url; + while (match = filter_regex.exec(api_url)) { + var filter_field = $('#id_' + match[1]); + if (filter_field.val()) { + rendered_url = rendered_url.replace(match[0], filter_field.val()); + } else if (filter_field.attr('nullable') == 'true') { + rendered_url = rendered_url.replace(match[0], '0'); } - }); + } + + // If all URL variables have been replaced, make the API call + if (rendered_url.search('{{') < 0) { + console.log(child_name + ": Fetching " + rendered_url); + $.ajax({ + url: rendered_url, + dataType: 'json', + 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"); + } else if (choice.id == child_selected) { + option.attr("selected", "selected"); + } + child_field.append(option); + }); + } + }); + } + } - } - - // Trigger change event in case the child field is the parent of another field - child_field.change(); + // Trigger change event in case the child field is the parent of another field + child_field.change(); + }); }); }); diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html new file mode 100644 index 000000000..648d73151 --- /dev/null +++ b/netbox/templates/dcim/interface_edit.html @@ -0,0 +1,28 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Interface
+
+ {% render_field form.name %} + {% render_field form.form_factor %} + {% render_field form.enabled %} + {% render_field form.lag %} + {% render_field form.mac_address %} + {% render_field form.mtu %} + {% render_field form.mgmt_only %} + {% render_field form.description %} +
+
+
+
802.1Q Encapsulation
+
+ {% render_field form.mode %} + {% render_field form.site %} + {% render_field form.vlan_group %} + {% render_field form.untagged_vlan %} + {% render_field form.tagged_vlans %} +
+
+{% endblock %} diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 4f5ce4471..237ffd723 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -48,6 +48,11 @@ class ValidatedModelSerializer(ModelSerializer): attrs = data.copy() attrs.pop('custom_fields', None) + # remove any fields marked for no validation + ignore_validation_fields = getattr(self.Meta, 'ignore_validation_fields', []) + for field in ignore_validation_fields: + attrs.pop(field) + # Run clean() on an instance of the model if self.instance is None: instance = self.Meta.model(**attrs) diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py new file mode 100644 index 000000000..1cb3999ef --- /dev/null +++ b/netbox/utilities/constants.py @@ -0,0 +1,7 @@ +from utilities.forms import ChainedModelMultipleChoiceField + + +# Fields which are used on ManyToMany relationships +M2M_FIELD_TYPES = [ + ChainedModelMultipleChoiceField, +] diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index d8ba3712a..eda51eff4 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -766,6 +766,26 @@ class ComponentCreateView(View): if not form.errors: self.model.objects.bulk_create(new_components) + + # ManyToMany relations are bulk created via the through model + m2m_fields = [field for field in component_form.fields if type(component_form.fields[field]) in M2M_FIELD_TYPES] + if m2m_fields: + for field in m2m_fields: + field_links = [] + for new_component in new_components: + for related_obj in component_form.cleaned_data[field]: + # The through model columns are the id's of our M2M relation objects + through_kwargs = {} + new_component_column = new_component.__class__.__name__ + '_id' + related_obj_column = related_obj.__class__.__name__ + '_id' + through_kwargs.update({ + new_component_column.lower(): new_component.id, + related_obj_column.lower(): related_obj.id + }) + field_link = getattr(self.model, field).through(**through_kwargs) + field_links.append(field_link) + getattr(self.model, field).through.objects.bulk_create(field_links) + messages.success(request, "Added {} {} to {}.".format( len(new_components), self.model._meta.verbose_name_plural, parent )) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index edb35f4cb..6aa2575a9 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -267,3 +267,8 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): return self.primary_ip4 else: return None + + def site(self): + # used when a child compent (eg Interface) needs to know its parent's site but + # the parent could be either a device or a virtual machine + return self.cluster.site From 04ba57cb385dbf4e334fd529a0a9a16e7dcc02b8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 14 Nov 2017 16:15:23 -0500 Subject: [PATCH 09/75] Fixed up validation of Interface VLAN assignments --- netbox/dcim/api/serializers.py | 35 ++++++++++------------------------ netbox/dcim/models.py | 7 +++++++ netbox/utilities/api.py | 5 ----- 3 files changed, 17 insertions(+), 30 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 0046a2be4..1f2f3b9ed 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -699,35 +699,20 @@ class WritableInterfaceSerializer(ValidatedModelSerializer): 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', ] - ignore_validation_fields = [ - 'tagged_vlans' - ] def validate(self, data): - # Get the device for later use - if self.instance: - device = self.instance.device - else: - device = data.get('device') + # Validate that all untagged VLANs either belong to the same site as the Interface's parent Deivce or + # VirtualMachine, or are global. + parent = self.instance.parent if self.instance else data.get('device') or data.get('virtual_machine') + for vlan in data.get('tagged_vlans', []): + if vlan.site not in [parent, None]: + raise serializers.ValidationError( + "Tagged VLAN {} must belong to the same site as the interface's parent device/VM, or it must be " + "global".format(vlan) + ) - # Validate VLANs belong to the device's site or global - # We have to do this here decause of the ManyToMany relationship - native_vlan = data.get('native_vlan') - if native_vlan: - if native_vlan.site != device.site and native_vlan.site is not None: - raise serializers.ValidationError("Native VLAN is invalid for the interface's device.") - - tagged_vlan_members = data.get('tagged_vlan_members') - if tagged_vlan_members: - for vlan in tagged_vlan_members: - if vlan.site != device.site and vlan.site is not None: - raise serializers.ValidationError("Tagged VLAN {} is invalid for the interface's device.".format(vlan)) - - # Enforce model validation - super(WritableInterfaceSerializer, self).validate(data) - - return data + return super(WritableInterfaceSerializer, self).validate(data) # diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 1012924b7..1993e9143 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1318,6 +1318,13 @@ class Interface(models.Model): ) }) + # Validate untagged VLAN + if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]: + raise ValidationError({ + 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent " + "device/VM, or it must be global".format(self.untagged_vlan) + }) + @property def parent(self): return self.device or self.virtual_machine diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 237ffd723..4f5ce4471 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -48,11 +48,6 @@ class ValidatedModelSerializer(ModelSerializer): attrs = data.copy() attrs.pop('custom_fields', None) - # remove any fields marked for no validation - ignore_validation_fields = getattr(self.Meta, 'ignore_validation_fields', []) - for field in ignore_validation_fields: - attrs.pop(field) - # Run clean() on an instance of the model if self.instance is None: instance = self.Meta.model(**attrs) From b5a51aced37db1dbca839498de9dfca83832c544 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Nov 2017 12:21:52 -0500 Subject: [PATCH 10/75] Fixes #1645: Simplified interface serialzier for IP addresses and optimized API view queryset --- netbox/ipam/api/serializers.py | 18 +++++++++++++++--- netbox/ipam/api/views.py | 4 ++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e520daa4c..75fbfbe49 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -3,9 +3,11 @@ from __future__ import unicode_literals from collections import OrderedDict from rest_framework import serializers +from rest_framework.reverse import reverse from rest_framework.validators import UniqueTogetherValidator from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer +from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from ipam.constants import ( IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES, @@ -255,15 +257,25 @@ class AvailablePrefixSerializer(serializers.Serializer): # IP addresses # -class IPAddressInterfaceSerializer(InterfaceSerializer): +class IPAddressInterfaceSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField() # We're imitating a HyperlinkedIdentityField here + device = NestedDeviceSerializer() virtual_machine = NestedVirtualMachineSerializer() class Meta(InterfaceSerializer.Meta): + model = Interface fields = [ - 'id', 'device', 'virtual_machine', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', - 'mgmt_only', 'description', 'is_connected', 'interface_connection', 'circuit_termination', + 'id', 'url', 'device', 'virtual_machine', 'name', ] + def get_url(self, obj): + """ + Return a link to the Interface via either the DCIM API if the parent is a Device, or via the virtualization API + if the parent is a VirtualMachine. + """ + url_name = 'dcim-api:interface-detail' if obj.device else 'virtualization-api:interface-detail' + return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request']) + class IPAddressSerializer(CustomFieldModelSerializer): vrf = NestedVRFSerializer() diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index b326b8ea6..2fe42a9ff 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -219,9 +219,9 @@ class PrefixViewSet(CustomFieldModelViewSet): class IPAddressViewSet(CustomFieldModelViewSet): queryset = IPAddress.objects.select_related( - 'vrf__tenant', 'tenant', 'nat_inside' + 'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine' ).prefetch_related( - 'interface__device', 'interface__virtual_machine' + 'nat_outside' ) serializer_class = serializers.IPAddressSerializer write_serializer_class = serializers.WritableIPAddressSerializer From db0ef95fe32f811e720bb309d58f2aa6156ba476 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Nov 2017 13:52:14 -0500 Subject: [PATCH 11/75] Cleaned up bulk IP provisioning a bit --- netbox/ipam/api/views.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 2fe42a9ff..f6a55b618 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -94,6 +94,7 @@ class PrefixViewSet(CustomFieldModelViewSet): if not request.user.has_perm('ipam.add_prefix'): raise PermissionDenied() + # Normalize to a list of objects requested_prefixes = request.data if isinstance(request.data, list) else [request.data] # Allocate prefixes to the requested objects based on availability within the parent @@ -155,32 +156,30 @@ class PrefixViewSet(CustomFieldModelViewSet): if not request.user.has_perm('ipam.add_ipaddress'): raise PermissionDenied() + # Normalize to a list of objects + requested_ips = request.data if isinstance(request.data, list) else [request.data] + # Determine if the requested number of IPs is available - requested_count = len(request.data) if isinstance(request.data, list) else 1 available_ips = list(prefix.get_available_ips()) - if len(available_ips) < requested_count: + if len(available_ips) < len(requested_ips): return Response( { "detail": "An insufficient number of IP addresses are available within the prefix {} ({} " - "requested, {} available)".format(prefix, requested_count, len(available_ips)) + "requested, {} available)".format(prefix, len(requested_ips), len(available_ips)) }, status=status.HTTP_400_BAD_REQUEST ) - # Deserializing multiple IP addresses - if isinstance(request.data, list): - request_data = list(request.data) # Need a mutable copy - for obj in request_data: - obj['address'] = available_ips.pop(0) - obj['vrf'] = prefix.vrf.pk if prefix.vrf else None - serializer = serializers.WritableIPAddressSerializer(data=request_data, many=True) + # Assign addresses from the list of available IPs and copy VRF assignment from the parent prefix + for requested_ip in requested_ips: + requested_ip['address'] = available_ips.pop(0) + requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None - # Deserializing a single IP address + # Initialize the serializer with a list or a single object depending on what was requested + if isinstance(request.data, list): + serializer = serializers.WritableIPAddressSerializer(data=requested_ips, many=True) else: - request_data = request.data.copy() # Need a mutable copy - request_data['address'] = available_ips.pop(0) - request_data['vrf'] = prefix.vrf.pk if prefix.vrf else None - serializer = serializers.WritableIPAddressSerializer(data=request_data) + serializer = serializers.WritableIPAddressSerializer(data=requested_ips[0]) # Create the new IP address(es) if serializer.is_valid(): From fbd39da8ca6b2bec4634e7d0eff63ad697771102 Mon Sep 17 00:00:00 2001 From: Nicholas Totsch Date: Wed, 15 Nov 2017 12:54:49 -0600 Subject: [PATCH 12/75] Add Tenancy to Rack Reservations; Fixes #1592 (#1672) * fixed prefix header to represent new serial "vlan_vid" * shows option in creation now * fixed visibility on rack page * cleanup * Added view to Tenant page * Moved migration for update from #1666 and fixed tenant enumeration in FilterForm * Fixed conflict #1 * Fixed filters from merge and made migration merge * added tenant to api * Fixed migrations problem * Added Tenant to bulkedit option --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/filters.py | 10 +++++++++ netbox/dcim/forms.py | 14 ++++++++---- .../migrations/0050_rackreservation_tenant.py | 22 +++++++++++++++++++ netbox/dcim/models.py | 1 + netbox/dcim/tables.py | 3 ++- netbox/dcim/views.py | 8 +++---- netbox/templates/dcim/rack.html | 2 ++ netbox/templates/tenancy/tenant.html | 4 ++++ netbox/tenancy/views.py | 3 ++- 10 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 netbox/dcim/migrations/0050_rackreservation_tenant.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1f2f3b9ed..cbbde86ed 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -218,7 +218,7 @@ class RackReservationSerializer(serializers.ModelSerializer): class Meta: model = RackReservation - fields = ['id', 'rack', 'units', 'created', 'user', 'description'] + fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description'] class WritableRackReservationSerializer(ValidatedModelSerializer): diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index e466723bd..c7f1992f3 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -208,6 +208,16 @@ class RackReservationFilter(django_filters.FilterSet): to_field_name='slug', label='Group', ) + tenant_id = django_filters.ModelMultipleChoiceFilter( + queryset=Tenant.objects.all(), + label='Tenant (ID)', + ) + tenant = django_filters.ModelMultipleChoiceFilter( + name='tenant__slug', + queryset=Tenant.objects.all(), + to_field_name='slug', + label='Tenant (slug)', + ) user_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), label='User (ID)', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index a8b4b1cf5..9d6306d4d 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -379,13 +379,13 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): # Rack reservations # -class RackReservationForm(BootstrapMixin, forms.ModelForm): +class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10})) user = forms.ModelChoiceField(queryset=User.objects.order_by('username')) class Meta: model = RackReservation - fields = ['units', 'user', 'description'] + fields = ['units', 'user', 'tenant_group', 'tenant', 'description'] def __init__(self, *args, **kwargs): @@ -415,11 +415,17 @@ class RackReservationFilterForm(BootstrapMixin, forms.Form): label='Rack group', null_option=(0, 'None') ) + tenant = FilterChoiceField( + queryset=Tenant.objects.annotate(filter_count=Count('rackreservations')), + to_field_name='slug', + null_option=(0, 'None') + ) class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput) user = forms.ModelChoiceField(queryset=User.objects.order_by('username'), required=False) + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) description = forms.CharField(max_length=100, required=False) class Meta: @@ -805,10 +811,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): pk = self.instance.pk if self.instance.pk else None try: if self.is_bound and self.data.get('rack') and str(self.data.get('face')): - position_choices = Rack.objects.get(pk=self.data['rack'])\ + position_choices = Rack.objects.get(pk=self.data['rack']) \ .get_rack_units(face=self.data.get('face'), exclude=pk) elif self.initial.get('rack') and str(self.initial.get('face')): - position_choices = Rack.objects.get(pk=self.initial['rack'])\ + position_choices = Rack.objects.get(pk=self.initial['rack']) \ .get_rack_units(face=self.initial.get('face'), exclude=pk) else: position_choices = [] diff --git a/netbox/dcim/migrations/0050_rackreservation_tenant.py b/netbox/dcim/migrations/0050_rackreservation_tenant.py new file mode 100644 index 000000000..39da00f1f --- /dev/null +++ b/netbox/dcim/migrations/0050_rackreservation_tenant.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-10-30 20:43 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0003_unicode_literals'), + ('dcim', '0049_rackreservation_change_user'), + ] + + operations = [ + migrations.AddField( + model_name='rackreservation', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='rackreservations', to='tenancy.Tenant'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 1993e9143..eb4cb9b28 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -417,6 +417,7 @@ class RackReservation(models.Model): rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE) units = ArrayField(models.PositiveSmallIntegerField()) created = models.DateTimeField(auto_now_add=True) + tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='rackreservations', on_delete=models.PROTECT) user = models.ForeignKey(User, on_delete=models.PROTECT) description = models.CharField(max_length=100) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 3d1e79360..061b3bd9d 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -244,6 +244,7 @@ class RackImportTable(BaseTable): class RackReservationTable(BaseTable): pk = ToggleColumn() + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')]) unit_list = tables.Column(orderable=False, verbose_name='Units') actions = tables.TemplateColumn( @@ -252,7 +253,7 @@ class RackReservationTable(BaseTable): class Meta(BaseTable.Meta): model = RackReservation - fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'description', 'actions') + fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions') # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a24720bce..2f55d073d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -326,7 +326,7 @@ class RackView(View): 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)\ + nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True) \ .select_related('device_type__manufacturer') next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first() prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first() @@ -1783,7 +1783,7 @@ class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView # class ConsoleConnectionsListView(ObjectListView): - queryset = ConsolePort.objects.select_related('device', 'cs_port__device').filter(cs_port__isnull=False)\ + queryset = ConsolePort.objects.select_related('device', 'cs_port__device').filter(cs_port__isnull=False) \ .order_by('cs_port__device__name', 'cs_port__name') filter = filters.ConsoleConnectionFilter filter_form = forms.ConsoleConnectionFilterForm @@ -1792,7 +1792,7 @@ class ConsoleConnectionsListView(ObjectListView): class PowerConnectionsListView(ObjectListView): - queryset = PowerPort.objects.select_related('device', 'power_outlet__device').filter(power_outlet__isnull=False)\ + queryset = PowerPort.objects.select_related('device', 'power_outlet__device').filter(power_outlet__isnull=False) \ .order_by('power_outlet__device__name', 'power_outlet__name') filter = filters.PowerConnectionFilter filter_form = forms.PowerConnectionFilterForm @@ -1801,7 +1801,7 @@ class PowerConnectionsListView(ObjectListView): class InterfaceConnectionsListView(ObjectListView): - queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')\ + queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device') \ .order_by('interface_a__device__name', 'interface_a__name') filter = filters.InterfaceConnectionFilter filter_form = forms.InterfaceConnectionFilterForm diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 05585348f..2a6bc2f47 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -233,12 +233,14 @@ + {% for resv in reservations %} + - +
UnitsTenant Description
{{ resv.unit_list }}{{ resv.tenant }} {{ resv.description }}
{{ resv.user }} · {{ resv.created }} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index c19195246..e985ed8f9 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -100,6 +100,10 @@

{{ stats.rack_count }}

Racks

+
+

{{ stats.rackreservation_count }}

+

Rack Reservations

+

{{ stats.device_count }}

Devices

diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 33df6a5ca..99c4acc8a 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -7,7 +7,7 @@ from django.urls import reverse from django.views.generic import View from circuits.models import Circuit -from dcim.models import Site, Rack, Device +from dcim.models import Site, Rack, Device, RackReservation from ipam.models import IPAddress, Prefix, VLAN, VRF from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, @@ -75,6 +75,7 @@ class TenantView(View): stats = { 'site_count': Site.objects.filter(tenant=tenant).count(), 'rack_count': Rack.objects.filter(tenant=tenant).count(), + 'rackreservation_count': RackReservation.objects.filter(tenant=tenant).count(), 'device_count': Device.objects.filter(tenant=tenant).count(), 'vrf_count': VRF.objects.filter(tenant=tenant).count(), 'prefix_count': Prefix.objects.filter( From 81852de1fa968e5f8609a9f9f081355502722169 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Nov 2017 13:57:19 -0500 Subject: [PATCH 13/75] Resolved migration collision from #1672 --- ...ckreservation_tenant.py => 0051_rackreservation_tenant.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename netbox/dcim/migrations/{0050_rackreservation_tenant.py => 0051_rackreservation_tenant.py} (84%) diff --git a/netbox/dcim/migrations/0050_rackreservation_tenant.py b/netbox/dcim/migrations/0051_rackreservation_tenant.py similarity index 84% rename from netbox/dcim/migrations/0050_rackreservation_tenant.py rename to netbox/dcim/migrations/0051_rackreservation_tenant.py index 39da00f1f..90a551eb8 100644 --- a/netbox/dcim/migrations/0050_rackreservation_tenant.py +++ b/netbox/dcim/migrations/0051_rackreservation_tenant.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.6 on 2017-10-30 20:43 +# Generated by Django 1.11.6 on 2017-11-15 18:56 from __future__ import unicode_literals from django.db import migrations, models @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ ('tenancy', '0003_unicode_literals'), - ('dcim', '0049_rackreservation_change_user'), + ('dcim', '0050_interface_vlan_tagging'), ] operations = [ From e56797737dbba1ef39826209764731d0c65a4299 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Nov 2017 14:06:58 -0500 Subject: [PATCH 14/75] A bit of cosmetic cleanup from #1672 --- netbox/templates/dcim/rack.html | 8 +++++++- netbox/templates/tenancy/tenant.html | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 2a6bc2f47..056bc66fc 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -240,7 +240,13 @@ {% for resv in reservations %}
{{ resv.unit_list }}{{ resv.tenant }} + {% if resv.tenant %} + {{ resv.tenant }} + {% else %} + None + {% endif %} + {{ resv.description }}
{{ resv.user }} · {{ resv.created }} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index e985ed8f9..d5eb7df98 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -102,7 +102,7 @@

{{ stats.rackreservation_count }}

-

Rack Reservations

+

Rack reservations

{{ stats.device_count }}

From 1c095708053f966a95e97b68e3be31dff232da48 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Nov 2017 14:15:44 -0500 Subject: [PATCH 15/75] Added nested representations of user and tenant to the rack reservation serializer --- netbox/dcim/api/serializers.py | 3 +++ netbox/dcim/api/views.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index cbbde86ed..9c827912b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -19,6 +19,7 @@ from dcim.models import ( from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer +from users.api.serializers import NestedUserSerializer from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer from virtualization.models import Cluster @@ -215,6 +216,8 @@ class RackUnitSerializer(serializers.Serializer): class RackReservationSerializer(serializers.ModelSerializer): rack = NestedRackSerializer() + user= NestedUserSerializer() + tenant = NestedTenantSerializer() class Meta: model = RackReservation diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 30d0eab82..7185198b1 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -130,7 +130,7 @@ class RackViewSet(CustomFieldModelViewSet): # class RackReservationViewSet(ModelViewSet): - queryset = RackReservation.objects.select_related('rack') + queryset = RackReservation.objects.select_related('rack', 'user', 'tenant') serializer_class = serializers.RackReservationSerializer write_serializer_class = serializers.WritableRackReservationSerializer filter_class = filters.RackReservationFilter From 7e475511b677a99e05570bca6356698289fdf97b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Nov 2017 12:06:52 -0500 Subject: [PATCH 16/75] Fixed version number --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 1196348c6..ac1aba492 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.2.7-dev' +VERSION = '2.3.0-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From 55e07c1c9a90eafa77c38c3633a45c28db40794d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Nov 2017 16:47:26 -0500 Subject: [PATCH 17/75] Initial work on virtual chassis support --- netbox/dcim/api/serializers.py | 51 ++++++++++++++- netbox/dcim/api/urls.py | 4 ++ netbox/dcim/api/views.py | 20 +++++- netbox/dcim/forms.py | 13 +++- .../dcim/migrations/0052_virtual_chassis.py | 48 ++++++++++++++ netbox/dcim/models.py | 62 +++++++++++++++++++ netbox/dcim/tables.py | 27 +++++++- netbox/dcim/urls.py | 4 ++ netbox/dcim/views.py | 21 ++++++- .../templates/dcim/virtualchassis_list.html | 11 ++++ 10 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 netbox/dcim/migrations/0052_virtual_chassis.py create mode 100644 netbox/templates/dcim/virtualchassis_list.html diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9c827912b..5804480a0 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -14,7 +14,7 @@ from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, + RackReservation, RackRole, Region, Site, VirtualChassis, VCMembership ) from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress, VLAN @@ -799,3 +799,52 @@ class WritableInterfaceConnectionSerializer(ValidatedModelSerializer): class Meta: model = InterfaceConnection fields = ['id', 'interface_a', 'interface_b', 'connection_status'] + + +# +# Virtual chassis +# + +class VirtualChassisSerializer(serializers.ModelSerializer): + site = NestedSiteSerializer() + master = NestedDeviceSerializer() + + class Meta: + model = VirtualChassis + fields = ['id', 'site', 'domain', 'master'] + + +class NestedVirtualChassisSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') + + class Meta: + model = VirtualChassis + fields = ['id', 'url'] + + +class WritableVirtualChassisSerializer(ValidatedModelSerializer): + + class Meta: + model = VirtualChassis + fields = ['id', 'site', 'domain', 'master'] + + +# +# Virtual chassis memberships +# + +class VCMembershipSerializer(serializers.ModelSerializer): + virtual_chassis = NestedVirtualChassisSerializer() + device = NestedDeviceSerializer() + + class Meta: + model = VCMembership + fields = ['id', 'virtual_chassis', 'device', 'master_enabled', 'position', 'priority'] + + +class WritableVCMembershipSerializer(serializers.ModelSerializer): + virtual_chassis = serializers.PrimaryKeyRelatedField(queryset=VirtualChassis.objects.all(), required=False) + + class Meta: + model = VCMembership + fields = ['id', 'virtual_chassis', 'device', 'master_enabled', 'position', 'priority'] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index a03432c61..91ef531ff 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -60,6 +60,10 @@ router.register(r'console-connections', views.ConsoleConnectionViewSet, base_nam router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections') router.register(r'interface-connections', views.InterfaceConnectionViewSet) +# Virtual chassis +router.register(r'virtual-chassis', views.VirtualChassisViewSet) +router.register(r'vc-memberships', views.VCMembershipViewSet) + # Miscellaneous router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device') diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 7185198b1..e782172a2 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -15,7 +15,7 @@ from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, + RackReservation, RackRole, Region, Site, VCMembership, VirtualChassis ) from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet @@ -396,6 +396,24 @@ class InterfaceConnectionViewSet(ModelViewSet): filter_class = filters.InterfaceConnectionFilter +# +# Virtual chassis +# + +class VirtualChassisViewSet(ModelViewSet): + queryset = VirtualChassis.objects.select_related('master') + serializer_class = serializers.VirtualChassisSerializer + write_serializer_class = serializers.WritableVirtualChassisSerializer + # filter_class = filters.VirtualChassisFilter + + +class VCMembershipViewSet(ModelViewSet): + queryset = VCMembership.objects.select_related('virtual_chassis', 'device') + serializer_class = serializers.VCMembershipSerializer + write_serializer_class = serializers.WritableVCMembershipSerializer + # filter_class = filters.VCMembershipFilter + + # # Miscellaneous # diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9d6306d4d..d803c4d04 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -30,7 +30,7 @@ from .models import ( DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, - RackRole, Region, Site, + RackRole, Region, Site, VirtualChassis ) from .constants import * @@ -2170,3 +2170,14 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): class Meta: model = InventoryItem fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description'] + + +# +# Virtual chassis +# + +class VirtualChassisForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = VirtualChassis + fields = ['domain'] diff --git a/netbox/dcim/migrations/0052_virtual_chassis.py b/netbox/dcim/migrations/0052_virtual_chassis.py new file mode 100644 index 000000000..96f38d5c9 --- /dev/null +++ b/netbox/dcim/migrations/0052_virtual_chassis.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-11-17 20:39 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0051_rackreservation_tenant'), + ] + + operations = [ + migrations.CreateModel( + name='VCMembership', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('master_enabled', models.BooleanField(default=True)), + ('position', models.PositiveSmallIntegerField(validators=[django.core.validators.MaxValueValidator(255)])), + ('priority', models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)])), + ('device', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='vc_membership', to='dcim.Device')), + ], + options={ + 'verbose_name': 'VC membership', + 'ordering': ['virtual_chassis', 'position'], + }, + ), + migrations.CreateModel( + name='VirtualChassis', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('domain', models.CharField(blank=True, max_length=30)), + ('master', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device')), + ], + ), + migrations.AddField( + model_name='vcmembership', + name='virtual_chassis', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='dcim.VirtualChassis'), + ), + migrations.AlterUniqueTogether( + name='vcmembership', + unique_together=set([('virtual_chassis', 'position')]), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 6324b785a..2d3857c62 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1479,3 +1479,65 @@ class InventoryItem(models.Model): def __str__(self): return self.name + + +# +# Virtual chassis +# + +@python_2_unicode_compatible +class VirtualChassis(models.Model): + """ + A collection of Devices which operate with a shared control plane (e.g. a switch stack). + """ + domain = models.CharField( + max_length=30, + blank=True + ) + master = models.OneToOneField( + to='Device', + on_delete=models.PROTECT, + related_name='vc_master_for' + ) + + def get_absolute_url(self): + return "{}?virtual_chassis={}".format(reverse('dcim:device_list'), self.pk) + + def clean(self): + + # Check that the master Device is not already assigned to a VirtualChassis. + if VCMembership.objects.filter(device=self.master).exclude(virtual_chassis=self): + raise ValidationError("The master device is already assigned to a different virtual chassis.") + + +@python_2_unicode_compatible +class VCMembership(models.Model): + """ + An attachment of a physical Device to a VirtualChassis. + """ + virtual_chassis = models.ForeignKey( + to='VirtualChassis', + on_delete=models.CASCADE, + related_name='memberships' + ) + device = models.OneToOneField( + to='Device', + on_delete=models.CASCADE, + related_name='vc_membership' + ) + master_enabled = models.BooleanField( + default=True + ) + position = models.PositiveSmallIntegerField( + validators=[MaxValueValidator(255)] + ) + priority = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MaxValueValidator(255)] + ) + + class Meta: + ordering = ['virtual_chassis', 'position'] + unique_together = ['virtual_chassis', 'position'] + verbose_name = 'VC membership' diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 061b3bd9d..4fdbe1ea3 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -7,7 +7,7 @@ from utilities.tables import BaseTable, ToggleColumn from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutlet, - PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site, + PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site, VirtualChassis ) REGION_LINK = """ @@ -111,6 +111,12 @@ UTILIZATION_GRAPH = """ {% utilization_graph value %} """ +VIRTUALCHASSIS_ACTIONS = """ +{% if perms.dcim.change_virtualchassis %} + +{% endif %} +""" + # # Regions @@ -523,3 +529,22 @@ class InterfaceConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = Interface fields = ('device_a', 'interface_a', 'device_b', 'interface_b') + + +# +# Virtual chassis +# + +class VirtualChassisTable(BaseTable): + pk = ToggleColumn() + master = tables.LinkColumn() + member_count = tables.Column(verbose_name='Members') + actions = tables.TemplateColumn( + template_code=VIRTUALCHASSIS_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = VirtualChassis + fields = ('pk', 'master', 'domain', 'member_count', 'actions') diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index a15774569..2cc9ede89 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -207,4 +207,8 @@ 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'), + # Virtual chassis + url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), + url(r'^virtual-chassis/(?P\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), + ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2f55d073d..156647f30 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -31,7 +31,7 @@ from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, + RackReservation, RackRole, Region, Site, VirtualChassis ) @@ -1829,3 +1829,22 @@ class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_inventoryitem' model = InventoryItem parent_field = 'device' + + +# +# Virtual chassis +# + +class VirtualChassisListView(ObjectListView): + queryset = VirtualChassis.objects.annotate(member_count=Count('memberships')) + table = tables.VirtualChassisTable + template_name = 'dcim/virtualchassis_list.html' + + +class VirtualChassisEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_virtualchassis' + model = VirtualChassis + model_form = forms.VirtualChassisForm + + def get_return_url(self, request, obj): + return reverse('dcim:virtualchassis_list') diff --git a/netbox/templates/dcim/virtualchassis_list.html b/netbox/templates/dcim/virtualchassis_list.html new file mode 100644 index 000000000..f6fe1045c --- /dev/null +++ b/netbox/templates/dcim/virtualchassis_list.html @@ -0,0 +1,11 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +

{% block title %}Virtual Chassis{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' %} +
+
+{% endblock %} From 3b801d43bcb1e4959307e5156ce30abd30fe072f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Nov 2017 15:59:13 -0500 Subject: [PATCH 18/75] Moved VC master designation to membership model --- netbox/dcim/api/serializers.py | 25 +++++++++++------ netbox/dcim/api/views.py | 20 +++++++++++-- netbox/dcim/apps.py | 3 ++ netbox/dcim/filters.py | 9 +++++- .../dcim/migrations/0052_virtual_chassis.py | 5 ++-- netbox/dcim/models.py | 28 +++++++++---------- netbox/dcim/signals.py | 16 +++++++++++ 7 files changed, 77 insertions(+), 29 deletions(-) create mode 100644 netbox/dcim/signals.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 5804480a0..0b51ec609 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -806,12 +806,10 @@ class WritableInterfaceConnectionSerializer(ValidatedModelSerializer): # class VirtualChassisSerializer(serializers.ModelSerializer): - site = NestedSiteSerializer() - master = NestedDeviceSerializer() class Meta: model = VirtualChassis - fields = ['id', 'site', 'domain', 'master'] + fields = ['id', 'domain'] class NestedVirtualChassisSerializer(serializers.ModelSerializer): @@ -826,7 +824,7 @@ class WritableVirtualChassisSerializer(ValidatedModelSerializer): class Meta: model = VirtualChassis - fields = ['id', 'site', 'domain', 'master'] + fields = ['id', 'domain'] # @@ -839,12 +837,23 @@ class VCMembershipSerializer(serializers.ModelSerializer): class Meta: model = VCMembership - fields = ['id', 'virtual_chassis', 'device', 'master_enabled', 'position', 'priority'] + fields = ['id', 'virtual_chassis', 'device', 'position', 'is_master', 'priority'] -class WritableVCMembershipSerializer(serializers.ModelSerializer): - virtual_chassis = serializers.PrimaryKeyRelatedField(queryset=VirtualChassis.objects.all(), required=False) +class WritableVCMembershipSerializer(ValidatedModelSerializer): class Meta: model = VCMembership - fields = ['id', 'virtual_chassis', 'device', 'master_enabled', 'position', 'priority'] + fields = ['id', 'virtual_chassis', 'device', 'position', 'is_master', 'priority'] + + def validate(self, data): + + # Validate uniqueness of (virtual_chassis, position) + validator = UniqueTogetherValidator(queryset=VCMembership.objects.all(), fields=('virtual_chassis', 'position')) + validator.set_context(self) + validator(data) + + # Enforce model validation + super(WritableVCMembershipSerializer, self).validate(data) + + return data diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e782172a2..a3ef98a15 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals from collections import OrderedDict from django.conf import settings +from django.db import transaction from django.http import HttpResponseBadRequest, HttpResponseForbidden from django.shortcuts import get_object_or_404 from rest_framework.decorators import detail_route @@ -401,17 +402,30 @@ class InterfaceConnectionViewSet(ModelViewSet): # class VirtualChassisViewSet(ModelViewSet): - queryset = VirtualChassis.objects.select_related('master') + queryset = VirtualChassis.objects.all() serializer_class = serializers.VirtualChassisSerializer write_serializer_class = serializers.WritableVirtualChassisSerializer - # filter_class = filters.VirtualChassisFilter class VCMembershipViewSet(ModelViewSet): queryset = VCMembership.objects.select_related('virtual_chassis', 'device') serializer_class = serializers.VCMembershipSerializer write_serializer_class = serializers.WritableVCMembershipSerializer - # filter_class = filters.VCMembershipFilter + filter_class = filters.VCMembershipFilter + + def create(self, request, *args, **kwargs): + + with transaction.atomic(): + + # Automatically create a new VirtualChassis for new VCMemberships with no VC specified + virtual_chassis = request.data.get('virtual_chassis', None) + is_master = request.data.get('is_master', False) + if not virtual_chassis and is_master: + vc = VirtualChassis() + vc.save() + request.data['virtual_chassis'] = vc.pk + + return super(VCMembershipViewSet, self).create(request, *args, **kwargs) # diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index fb1f4ee39..ef3158508 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -6,3 +6,6 @@ from django.apps import AppConfig class DCIMConfig(AppConfig): name = "dcim" verbose_name = "DCIM" + + def ready(self): + import dcim.signals diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index c7f1992f3..d434871d6 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -17,7 +17,7 @@ from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, + RackReservation, RackRole, Region, Site, VCMembership, ) @@ -631,6 +631,13 @@ class InventoryItemFilter(DeviceComponentFilterSet): fields = ['name', 'part_id', 'serial', 'discovered'] +class VCMembershipFilter(django_filters.FilterSet): + + class Meta: + model = VCMembership + fields = ['virtual_chassis'] + + class ConsoleConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( method='filter_site', diff --git a/netbox/dcim/migrations/0052_virtual_chassis.py b/netbox/dcim/migrations/0052_virtual_chassis.py index 96f38d5c9..db10b2510 100644 --- a/netbox/dcim/migrations/0052_virtual_chassis.py +++ b/netbox/dcim/migrations/0052_virtual_chassis.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.6 on 2017-11-17 20:39 +# Generated by Django 1.11.6 on 2017-11-27 17:27 from __future__ import unicode_literals import django.core.validators @@ -18,8 +18,8 @@ class Migration(migrations.Migration): name='VCMembership', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('master_enabled', models.BooleanField(default=True)), ('position', models.PositiveSmallIntegerField(validators=[django.core.validators.MaxValueValidator(255)])), + ('is_master', models.BooleanField(default=False)), ('priority', models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)])), ('device', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='vc_membership', to='dcim.Device')), ], @@ -33,7 +33,6 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('domain', models.CharField(blank=True, max_length=30)), - ('master', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device')), ], ), migrations.AddField( diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 2d3857c62..24fe183e6 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1494,21 +1494,10 @@ class VirtualChassis(models.Model): max_length=30, blank=True ) - master = models.OneToOneField( - to='Device', - on_delete=models.PROTECT, - related_name='vc_master_for' - ) def get_absolute_url(self): return "{}?virtual_chassis={}".format(reverse('dcim:device_list'), self.pk) - def clean(self): - - # Check that the master Device is not already assigned to a VirtualChassis. - if VCMembership.objects.filter(device=self.master).exclude(virtual_chassis=self): - raise ValidationError("The master device is already assigned to a different virtual chassis.") - @python_2_unicode_compatible class VCMembership(models.Model): @@ -1525,12 +1514,12 @@ class VCMembership(models.Model): on_delete=models.CASCADE, related_name='vc_membership' ) - master_enabled = models.BooleanField( - default=True - ) position = models.PositiveSmallIntegerField( validators=[MaxValueValidator(255)] ) + is_master = models.BooleanField( + default=False + ) priority = models.PositiveSmallIntegerField( blank=True, null=True, @@ -1541,3 +1530,14 @@ class VCMembership(models.Model): ordering = ['virtual_chassis', 'position'] unique_together = ['virtual_chassis', 'position'] verbose_name = 'VC membership' + + def clean(self): + + # Check for master conflicts + if self.virtual_chassis and self.is_master: + master_conflict = VCMembership.objects.filter(virtual_chassis=self.virtual_chassis).first() + if master_conflict: + raise ValidationError({ + 'virtual_chassis': "{} has already been designated as the master for this virtual chassis. It must " + "be demoted before a new master can be assigned.".format(master_conflict.device) + }) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py new file mode 100644 index 000000000..ca33dd251 --- /dev/null +++ b/netbox/dcim/signals.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals + +from django.db.models.signals import post_delete +from django.dispatch import receiver + +from .models import VCMembership + + +@receiver(post_delete, sender=VCMembership) +def delete_empty_vc(instance, **kwargs): + """ + When the last VCMembership of a VirtualChassis has been deleted, delete the VirtualChassis as well. + """ + virtual_chassis = instance.virtual_chassis + if not VCMembership.objects.filter(virtual_chassis=virtual_chassis).exists(): + virtual_chassis.delete() From 5f914130233d8eb8040ae422f8f9811b05f49c2b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Nov 2017 12:58:36 -0500 Subject: [PATCH 19/75] Added initial UI views for virtual chassis assignment --- netbox/dcim/forms.py | 25 +++++++- netbox/dcim/models.py | 9 ++- netbox/dcim/signals.py | 7 ++- netbox/dcim/urls.py | 2 + netbox/dcim/views.py | 60 ++++++++++++++++++- netbox/templates/dcim/device.html | 33 ++++++++++ netbox/templates/dcim/inc/device_table.html | 5 ++ netbox/templates/dcim/virtualchassis_add.html | 56 +++++++++++++++++ 8 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 netbox/templates/dcim/virtualchassis_add.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index d803c4d04..529d9c8d8 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -30,7 +30,7 @@ from .models import ( DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, - RackRole, Region, Site, VirtualChassis + RackRole, Region, Site, VCMembership, VirtualChassis ) from .constants import * @@ -2181,3 +2181,26 @@ class VirtualChassisForm(BootstrapMixin, forms.ModelForm): class Meta: model = VirtualChassis fields = ['domain'] + + +class DeviceSelectionForm(forms.Form): + pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + + +class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm): + master = forms.ModelChoiceField(queryset=Device.objects.all()) + + class Meta: + model = VirtualChassis + fields = ['master', 'domain'] + + def __init__(self, candidate_pks, *args, **kwargs): + super(VirtualChassisCreateForm, self).__init__(*args, **kwargs) + self.fields['master'].queryset = Device.objects.filter(pk__in=candidate_pks) + + +class VCMembershipForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = VCMembership + fields = ['device', 'position', 'priority'] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 24fe183e6..a396251ba 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1030,6 +1030,13 @@ class Device(CreatedUpdatedModel, CustomFieldModel): else: return None + @property + def virtual_chassis(self): + try: + return VCMembership.objects.get(device=self).virtual_chassis + except VCMembership.DoesNotExist: + return None + def get_children(self): """ Return the set of child Devices installed in DeviceBays within this Device. @@ -1534,7 +1541,7 @@ class VCMembership(models.Model): def clean(self): # Check for master conflicts - if self.virtual_chassis and self.is_master: + if getattr(self, 'virtual_chassis', None) and self.is_master: master_conflict = VCMembership.objects.filter(virtual_chassis=self.virtual_chassis).first() if master_conflict: raise ValidationError({ diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index ca33dd251..0e0de8f71 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -11,6 +11,7 @@ def delete_empty_vc(instance, **kwargs): """ When the last VCMembership of a VirtualChassis has been deleted, delete the VirtualChassis as well. """ - virtual_chassis = instance.virtual_chassis - if not VCMembership.objects.filter(virtual_chassis=virtual_chassis).exists(): - virtual_chassis.delete() + pass + # virtual_chassis = instance.virtual_chassis + # if not VCMembership.objects.filter(virtual_chassis=virtual_chassis).exists(): + # virtual_chassis.delete() diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 2cc9ede89..cde30f11f 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -209,6 +209,8 @@ urlpatterns = [ # Virtual chassis url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), + url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), url(r'^virtual-chassis/(?P\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), + url(r'^virtual-chassis/(?P\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 156647f30..083f6442b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -6,7 +6,9 @@ from django.contrib import messages from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.paginator import EmptyPage, PageNotAnInteger +from django.db import transaction from django.db.models import Count, Q +from django.forms import ModelChoiceField, modelformset_factory from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -31,7 +33,7 @@ from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VirtualChassis + RackReservation, RackRole, Region, Site, VCMembership, VirtualChassis, ) @@ -832,6 +834,9 @@ class DeviceView(View): services = Service.objects.filter(device=device) secrets = device.secrets.all() + # Find virtual chassis memberships + vc_memberships = VCMembership.objects.filter(virtual_chassis=device.virtual_chassis).select_related('device') + # Find up to ten devices in the same site with the same functional role for quick reference. related_devices = Device.objects.filter( site=device.site, device_role=device.device_role @@ -854,6 +859,7 @@ class DeviceView(View): 'device_bays': device_bays, 'services': services, 'secrets': secrets, + 'vc_memberships': vc_memberships, 'related_devices': related_devices, 'show_graphs': show_graphs, }) @@ -1841,6 +1847,52 @@ class VirtualChassisListView(ObjectListView): template_name = 'dcim/virtualchassis_list.html' +class VirtualChassisCreateView(PermissionRequiredMixin, View): + permission_required = 'dcim.add_virtualchassis' + + def post(self, request): + + # Get the list of devices being added to a VirtualChassis + pk_form = forms.DeviceSelectionForm(request.POST) + pk_form.full_clean() + device_list = pk_form.cleaned_data['pk'] + + # Generate a custom VCMembershipForm where the device field is limited to only the selected devices + class _VCMembershipForm(forms.VCMembershipForm): + device = ModelChoiceField(queryset=Device.objects.filter(pk__in=device_list)) + + VCMembershipFormSet = modelformset_factory(model=VCMembership, form=_VCMembershipForm, extra=len(device_list)) + + if '_create' in request.POST: + + vc_form = forms.VirtualChassisCreateForm(device_list, request.POST) + formset = VCMembershipFormSet(request.POST) + + if vc_form.is_valid() and formset.is_valid(): + with transaction.atomic(): + virtual_chassis = vc_form.save() + vc_memberships = formset.save(commit=False) + for vcm in vc_memberships: + vcm.virtual_chassis = virtual_chassis + if vcm.device == vc_form.cleaned_data['master']: + vcm.is_master = True + vcm.save() + return redirect(vc_form.cleaned_data['master'].get_absolute_url()) + + else: + + vc_form = forms.VirtualChassisCreateForm(device_list) + initial_data = [{'device': pk, 'position': i} for i, pk in enumerate(device_list, start=1)] + formset = VCMembershipFormSet(queryset=VCMembership.objects.none(), initial=initial_data) + + return render(request, 'dcim/virtualchassis_add.html', { + 'pk_form': pk_form, + 'vc_form': vc_form, + 'formset': formset, + 'return_url': reverse('dcim:device_list'), + }) + + class VirtualChassisEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_virtualchassis' model = VirtualChassis @@ -1848,3 +1900,9 @@ class VirtualChassisEditView(PermissionRequiredMixin, ObjectEditView): def get_return_url(self, request, obj): return reverse('dcim:virtualchassis_list') + + +class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_virtualchassis' + model = VirtualChassis + default_return_url = 'dcim:device_list' diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 549b93465..dedc8ec54 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -98,6 +98,39 @@
+ {% if vc_memberships %} +
+
+ Virtual Chassis +
+ + + + + + + + {% for vcm in vc_memberships %} + + + + + + + {% endfor %} +
DevicePositionMasterPriority
+ {{ vcm.device }} + {{ vcm.position }}{{ vcm.is_master }}{{ vcm.priority|default:"" }} +
+ {% if perms.dcim.delete_virtualchassis %} + + {% endif %} +
+ {% endif %}
Management diff --git a/netbox/templates/dcim/inc/device_table.html b/netbox/templates/dcim/inc/device_table.html index 33f7e93aa..68570fdf3 100644 --- a/netbox/templates/dcim/inc/device_table.html +++ b/netbox/templates/dcim/inc/device_table.html @@ -16,4 +16,9 @@
{% endif %} + {% if perms.dcim.add_virtualchassis %} + + {% endif %} {% endblock %} diff --git a/netbox/templates/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html new file mode 100644 index 000000000..9623fcd05 --- /dev/null +++ b/netbox/templates/dcim/virtualchassis_add.html @@ -0,0 +1,56 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block content %} +
+ {% csrf_token %} + {{ pk_form.pk }} + {{ formset.management_form }} +
+
+

{% block title %}New Virtual Chassis{% endblock %}

+ {% if vc_form.non_field_errors %} +
+
Errors
+
+ {{ vc_form.non_field_errors }} +
+
+ {% endif %} +
+
Virtual Chassis
+
+ {% render_form vc_form %} +
+
+
+
Members
+ + + + + + + + + + {% for form in formset %} + + + + + + {% endfor %} + +
DevicePositionPriority
{{ form.device }}{{ form.position }}{{ form.priority }}
+
+
+
+
+
+ + Cancel +
+
+
+{% endblock %} From 859f89101e0ee15b45d318f58fbe5e711ec8c4a0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 7 Dec 2017 15:36:08 -0500 Subject: [PATCH 20/75] Fixes #1727: Added missing import for M2M_FIELD_TYPES --- netbox/utilities/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index eda51eff4..93aa2220f 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -22,6 +22,7 @@ from django_tables2 import RequestConfig from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction from utilities.forms import BootstrapMixin, CSVDataField +from .constants import M2M_FIELD_TYPES from .error_handlers import handle_protectederror from .forms import ConfirmationForm from .paginator import EnhancedPaginator From a85b3aa69f66031a128764b20f2c215cc12838ab Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 7 Dec 2017 17:05:03 -0500 Subject: [PATCH 21/75] Added a form to edit virtual chassis --- netbox/dcim/forms.py | 20 +++++++++++++++++++ netbox/dcim/models.py | 8 ++++++++ netbox/dcim/views.py | 4 +--- netbox/templates/dcim/device.html | 18 ++++++++++------- .../templates/dcim/virtualchassis_edit.html | 11 ++++++++++ 5 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 netbox/templates/dcim/virtualchassis_edit.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 529d9c8d8..e5631a04c 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2177,11 +2177,31 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): # class VirtualChassisForm(BootstrapMixin, forms.ModelForm): + master = forms.ModelChoiceField(queryset=Device.objects.all()) class Meta: model = VirtualChassis fields = ['domain'] + def __init__(self, *args, **kwargs): + super(VirtualChassisForm, self).__init__(*args, **kwargs) + + if self.instance: + vc_memberships = self.instance.memberships.all() + self.fields['master'].queryset = Device.objects.filter(pk__in=[vcm.device_id for vcm in vc_memberships]) + self.initial['master'] = self.instance.master + + def save(self, commit=True): + instance = super(VirtualChassisForm, self).save(commit=commit) + + # Update the master membership if it has been changed + master = self.cleaned_data['master'] + if instance.pk and instance.master != master: + VCMembership.objects.filter(virtual_chassis=self.instance).update(is_master=False) + VCMembership.objects.filter(virtual_chassis=self.instance, device=master).update(is_master=True) + + return instance + class DeviceSelectionForm(forms.Form): pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index b4f78dacc..ec267c297 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1503,9 +1503,17 @@ class VirtualChassis(models.Model): blank=True ) + def __str__(self): + return self.master.name + def get_absolute_url(self): return "{}?virtual_chassis={}".format(reverse('dcim:device_list'), self.pk) + @property + def master(self): + master_vcm = VCMembership.objects.filter(virtual_chassis=self, is_master=True).first() + return master_vcm.device if master_vcm else None + @python_2_unicode_compatible class VCMembership(models.Model): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 7f17af435..bc90be536 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1895,9 +1895,7 @@ class VirtualChassisEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_virtualchassis' model = VirtualChassis model_form = forms.VirtualChassisForm - - def get_return_url(self, request, obj): - return reverse('dcim:virtualchassis_list') + template_name = 'dcim/virtualchassis_edit.html' class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView): diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index dedc8ec54..a1f2576fb 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -116,19 +116,23 @@ {{ vcm.device }} {{ vcm.position }} - {{ vcm.is_master }} + {% if vcm.is_master %}{% endif %} {{ vcm.priority|default:"" }} - {% endfor %} - {% if perms.dcim.delete_virtualchassis %} -
{% endif %}
diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html new file mode 100644 index 000000000..a2627c0b3 --- /dev/null +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -0,0 +1,11 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
{{ obj_type|capfirst }}
+
+ {% render_form form %} +
+
+{% endblock %} From da2bff691b20cdc8d51276af1259677ee48916d0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 8 Dec 2017 12:51:52 -0500 Subject: [PATCH 22/75] Added views for editing/deleting VCMemberships --- netbox/dcim/forms.py | 6 ++- netbox/dcim/models.py | 20 ++++++--- netbox/dcim/urls.py | 4 ++ netbox/dcim/views.py | 19 ++++++++ .../templates/dcim/virtualchassis_edit.html | 43 ++++++++++++++++--- 5 files changed, 80 insertions(+), 12 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e5631a04c..f82787865 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2219,8 +2219,12 @@ class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm): self.fields['master'].queryset = Device.objects.filter(pk__in=candidate_pks) +# +# VC memberships +# + class VCMembershipForm(BootstrapMixin, forms.ModelForm): class Meta: model = VCMembership - fields = ['device', 'position', 'priority'] + fields = ['position', 'priority'] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index ec267c297..6b912c627 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1507,7 +1507,7 @@ class VirtualChassis(models.Model): return self.master.name def get_absolute_url(self): - return "{}?virtual_chassis={}".format(reverse('dcim:device_list'), self.pk) + return self.master.get_absolute_url() @property def master(self): @@ -1547,13 +1547,21 @@ class VCMembership(models.Model): unique_together = ['virtual_chassis', 'position'] verbose_name = 'VC membership' + def __str__(self): + return self.device.name + def clean(self): + # We have to call this here because it won't be called by VCMembershipForm + self.validate_unique() + # Check for master conflicts if getattr(self, 'virtual_chassis', None) and self.is_master: - master_conflict = VCMembership.objects.filter(virtual_chassis=self.virtual_chassis).first() + master_conflict = VCMembership.objects.filter( + virtual_chassis=self.virtual_chassis, is_master=True + ).exclude(pk=self.pk).first() if master_conflict: - raise ValidationError({ - 'virtual_chassis': "{} has already been designated as the master for this virtual chassis. It must " - "be demoted before a new master can be assigned.".format(master_conflict.device) - }) + raise ValidationError( + "{} has already been designated as the master for this virtual chassis. It must be demoted before " + "a new master can be assigned.".format(master_conflict.device) + ) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index cde30f11f..10f3aafde 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -213,4 +213,8 @@ urlpatterns = [ url(r'^virtual-chassis/(?P\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), url(r'^virtual-chassis/(?P\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), + # VC memberships + url(r'^vc-memberships/(?P\d+)/edit/$', views.VCMembershipEditView.as_view(), name='vcmembership_edit'), + url(r'^vc-memberships/(?P\d+)/delete/$', views.VCMembershipDeleteView.as_view(), name='vcmembership_delete'), + ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index bc90be536..1d20a6b97 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1859,6 +1859,10 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View): class _VCMembershipForm(forms.VCMembershipForm): device = ModelChoiceField(queryset=Device.objects.filter(pk__in=device_list)) + class Meta: + model = VCMembership + fields = ['device', 'position', 'priority'] + VCMembershipFormSet = modelformset_factory(model=VCMembership, form=_VCMembershipForm, extra=len(device_list)) if '_create' in request.POST: @@ -1902,3 +1906,18 @@ class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_virtualchassis' model = VirtualChassis default_return_url = 'dcim:device_list' + + +# +# VC memberships +# + +class VCMembershipEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_vcmembership' + model = VCMembership + model_form = forms.VCMembershipForm + + +class VCMembershipDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_vcmembership' + model = VCMembership diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index a2627c0b3..8e9724e17 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -1,11 +1,44 @@ {% extends 'utilities/obj_edit.html' %} {% load form_helpers %} -{% block form %} -
-
{{ obj_type|capfirst }}
-
- {% render_form form %} +{% block content %} + {{ block.super }} +
+
+

Memberships

+
+ + + + + + + + + {% for vcm in form.instance.memberships.all %} + + + + + + + + {% endfor %} +
DevicePositionMasterPriority
+ {{ vcm.device }} + {{ vcm.position }}{% if vcm.is_master %}{% endif %}{{ vcm.priority|default:"" }} + {% if perms.dcim.change_vcmembership %} + + Edit + + {% endif %} + {% if perms.dcim.delete_vcmembership %} + + Delete + + {% endif %} +
+
{% endblock %} From 911ce3f047b790dc37f69d99e53b5b9d812ea034 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Dec 2017 15:24:03 -0500 Subject: [PATCH 23/75] Display member interfaces when viewing VC master device --- netbox/dcim/views.py | 32 ++++++++++++++++++------ netbox/templates/dcim/inc/interface.html | 6 +++-- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 1d20a6b97..f72c2d71a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -809,31 +809,49 @@ class DeviceView(View): device = get_object_or_404(Device.objects.select_related( 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' ), pk=pk) + + # Find virtual chassis memberships + vc_memberships = VCMembership.objects.filter(virtual_chassis=device.virtual_chassis).select_related('device') + vc_peer_ids = [vcm.device_id for vcm in vc_memberships] + + # Console ports console_ports = natsorted( ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name') ) + + # Console server ports cs_ports = ConsoleServerPort.objects.filter(device=device).select_related('connected_console') + + # Power ports power_ports = natsorted( PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name') ) + + # Power outlets power_outlets = PowerOutlet.objects.filter(device=device).select_related('connected_port') + + # Interfaces + interfaces_filter = Q(device=device) + if hasattr(device, 'vc_membership') and device.vc_membership.is_master: + interfaces_filter |= Q(device_id__in=vc_peer_ids, mgmt_only=False) interfaces = Interface.objects.order_naturally( device.device_type.interface_ordering - ).filter( - device=device ).select_related( 'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', 'circuit_termination__circuit' - ).prefetch_related('ip_addresses') + ).filter(interfaces_filter).prefetch_related('ip_addresses') + + # Device bays device_bays = natsorted( DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'), key=attrgetter('name') ) - services = Service.objects.filter(device=device) - secrets = device.secrets.all() - # Find virtual chassis memberships - vc_memberships = VCMembership.objects.filter(virtual_chassis=device.virtual_chassis).select_related('device') + # Services + services = Service.objects.filter(device=device) + + # Secrets + secrets = device.secrets.all() # Find up to ten devices in the same site with the same functional role for quick reference. related_devices = Device.objects.filter( diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index b0c35a0e9..e43956c98 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -1,9 +1,11 @@ - {# Checkbox #} + {# Checkbox (exclude VC members) #} {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - + {% if iface.device == device %} + + {% endif %} {% endif %} From 67a30fdf91520d864e4cd45909f1679d5ffc10eb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Dec 2017 15:31:35 -0500 Subject: [PATCH 24/75] Added virtual_chassis_id API filter for interfaces --- netbox/dcim/filters.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index d434871d6..e63de6f9a 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -17,7 +17,7 @@ from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VCMembership, + RackReservation, RackRole, Region, Site, VirtualChassis, VCMembership, ) @@ -569,6 +569,11 @@ class InterfaceFilter(django_filters.FilterSet): method='_mac_address', label='MAC address', ) + virtual_chassis_id = django_filters.NumberFilter( + method='_virtual_chassis_id', + name='pk', + label='Virtual chassis (ID)', + ) class Meta: model = Interface @@ -601,6 +606,14 @@ class InterfaceFilter(django_filters.FilterSet): except AddrFormatError: return queryset.none() + def _virtual_chassis_id(self, queryset, name, value): + try: + virtual_chassis = VirtualChassis.objects.get(**{name: value}) + ordering = virtual_chassis.master.device_type.interface_ordering + return queryset.filter(device__vc_membership__virtual_chassis=virtual_chassis).order_naturally(ordering) + except VirtualChassis.DoesNotExist: + return queryset.none() + class DeviceBayFilter(DeviceComponentFilterSet): From 153409d37e25dc7628a53043c567907cd17a8d64 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Dec 2017 15:57:49 -0500 Subject: [PATCH 25/75] Obsoleted ComponentEditView and ComponentDeleteView --- netbox/dcim/models.py | 21 +++++++++++ netbox/dcim/views.py | 46 +++++++++--------------- netbox/templates/dcim/inc/interface.html | 8 ++--- netbox/utilities/views.py | 14 -------- netbox/virtualization/views.py | 10 +++--- 5 files changed, 45 insertions(+), 54 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 6b912c627..23b9c0acd 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1084,6 +1084,9 @@ class ConsolePort(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + # Used for connections export def to_csv(self): return csv_format([ @@ -1125,6 +1128,9 @@ class ConsoleServerPort(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + def clean(self): # Check that the parent device's DeviceType is a console server @@ -1161,6 +1167,9 @@ class PowerPort(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + # Used for connections export def to_csv(self): return csv_format([ @@ -1202,6 +1211,9 @@ class PowerOutlet(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + def clean(self): # Check that the parent device's DeviceType is a PDU @@ -1281,6 +1293,9 @@ class Interface(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.parent.get_absolute_url() + def clean(self): # Check that the parent device's DeviceType is a network device @@ -1443,6 +1458,9 @@ class DeviceBay(models.Model): def __str__(self): return '{} - {}'.format(self.device.name, self.name) + def get_absolute_url(self): + return self.device.get_absolute_url() + def clean(self): # Validate that the parent Device can have DeviceBays @@ -1488,6 +1506,9 @@ class InventoryItem(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + # # Virtual chassis diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f72c2d71a..7e635e979 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -24,8 +24,8 @@ from ipam.models import Prefix, Service, VLAN from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, - ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView, + ObjectEditView, ObjectListView, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -1098,17 +1098,15 @@ def consoleport_disconnect(request, pk): }) -class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView): +class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleport' model = ConsolePort - parent_field = 'device' model_form = forms.ConsolePortForm -class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_consoleport' model = ConsolePort - parent_field = 'device' class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1218,17 +1216,15 @@ def consoleserverport_disconnect(request, pk): }) -class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView): +class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleserverport' model = ConsoleServerPort - parent_field = 'device' model_form = forms.ConsoleServerPortForm -class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_consoleserverport' model = ConsoleServerPort - parent_field = 'device' class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1337,17 +1333,15 @@ def powerport_disconnect(request, pk): }) -class PowerPortEditView(PermissionRequiredMixin, ComponentEditView): +class PowerPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_powerport' model = PowerPort - parent_field = 'device' model_form = forms.PowerPortForm -class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_powerport' model = PowerPort - parent_field = 'device' class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1457,17 +1451,15 @@ def poweroutlet_disconnect(request, pk): }) -class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView): +class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_poweroutlet' model = PowerOutlet - parent_field = 'device' model_form = forms.PowerOutletForm -class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_poweroutlet' model = PowerOutlet - parent_field = 'device' class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1502,18 +1494,16 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): +class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_interface' model = Interface - parent_field = 'device' model_form = forms.InterfaceForm template_name = 'dcim/interface_edit.html' -class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_interface' model = Interface - parent_field = 'device' class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1557,17 +1547,15 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView): +class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_devicebay' model = DeviceBay - parent_field = 'device' model_form = forms.DeviceBayForm -class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_devicebay' model = DeviceBay - parent_field = 'device' @permission_required('dcim.change_devicebay') @@ -1835,10 +1823,9 @@ class InterfaceConnectionsListView(ObjectListView): # Inventory items # -class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): +class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_inventoryitem' model = InventoryItem - parent_field = 'device' model_form = forms.InventoryItemForm def alter_obj(self, obj, request, url_args, url_kwargs): @@ -1847,10 +1834,9 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): return obj -class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_inventoryitem' model = InventoryItem - parent_field = 'device' # diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index e43956c98..b48d6ee6f 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -107,16 +107,16 @@ - + {% else %} - + {% endif %} {% endif %} - + {% endif %} @@ -126,7 +126,7 @@ {% else %} - + {% endif %} diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index eda51eff4..40e48c877 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -802,20 +802,6 @@ class ComponentCreateView(View): }) -class ComponentEditView(ObjectEditView): - parent_field = None - - def get_return_url(self, request, obj): - return getattr(obj, self.parent_field).get_absolute_url() - - -class ComponentDeleteView(ObjectDeleteView): - parent_field = None - - def get_return_url(self, request, obj): - return getattr(obj, self.parent_field).get_absolute_url() - - class BulkComponentCreateView(View): """ Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 4f2981748..6f897bed6 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -11,8 +11,8 @@ from dcim.models import Device, Interface from dcim.tables import DeviceTable from ipam.models import Service from utilities.views import ( - BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, - ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView, + ObjectEditView, ObjectListView, ) from . import filters, forms, tables from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -331,17 +331,15 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'virtualization/virtualmachine_component_add.html' -class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): +class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_interface' model = Interface - parent_field = 'virtual_machine' model_form = forms.InterfaceForm -class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_interface' model = Interface - parent_field = 'virtual_machine' class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): From 70d235f99e49fda28e4d29b019a9a2bedf6ad08f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Dec 2017 17:21:43 -0500 Subject: [PATCH 26/75] Added virtual chassis tests --- netbox/dcim/tests/test_api.py | 250 +++++++++++++++++++++++++++++++++- 1 file changed, 248 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index f3529a28f..2cdd197b7 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -5,12 +5,12 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase -from dcim.constants import IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT +from dcim.constants import IFACE_FF_1GE_FIXED, IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, + RackReservation, RackRole, Region, Site, VCMembership, VirtualChassis, ) from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from users.models import Token @@ -2158,3 +2158,249 @@ class ConnectedDeviceTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['name'], self.device1.name) + + +class VirtualChassisTest(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.vc1 = VirtualChassis.objects.create(domain='test-domain-1') + self.vc2 = VirtualChassis.objects.create(domain='test-domain-2') + + def test_get_virtualchassis(self): + + url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['domain'], self.vc1.domain) + + def test_list_virtualchassis(self): + + url = reverse('dcim-api:virtualchassis-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 2) + + def test_create_virtualchassis(self): + + data = { + 'domain': 'test-domain-3', + } + + url = reverse('dcim-api:virtualchassis-list') + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + vc3 = VirtualChassis.objects.get(pk=response.data['id']) + self.assertEqual(vc3.domain, data['domain']) + + def test_update_virtualchassis(self): + + data = { + 'domain': 'test-domain-x', + } + + url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(VirtualChassis.objects.count(), 2) + vc1 = VirtualChassis.objects.get(pk=response.data['id']) + self.assertEqual(vc1.domain, data['domain']) + + def test_delete_virtualchassis(self): + + url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(VirtualChassis.objects.count(), 1) + + +class VCMembershipTest(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)} + + site = Site.objects.create(name='Test Site', slug='test-site') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type', slug='test-device-type' + ) + device_role = DeviceRole.objects.create( + name='Test Device Role', slug='test-device-role', color='ff0000' + ) + + # Create 9 member Devices with 12 interfaces each + self.device1 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch1', site=site + ) + self.device2 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch2', site=site + ) + self.device3 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch3', site=site + ) + self.device4 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch4', site=site + ) + self.device5 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch5', site=site + ) + self.device6 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch6', site=site + ) + self.device7 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch7', site=site + ) + self.device8 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch8', site=site + ) + self.device9 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch9', site=site + ) + for i in range(0, 13): + Interface.objects.create(device=self.device1, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device2, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device3, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device4, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device5, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device6, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device7, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device8, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device9, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + + # Create two VirtualChassis with three members each + self.vc1 = VirtualChassis.objects.create(domain='test-domain-1') + self.vc2 = VirtualChassis.objects.create(domain='test-domain-2') + self.vcm1 = VCMembership.objects.create( + virtual_chassis=self.vc1, device=self.device1, position=1, priority=10, is_master=True + ) + self.vcm2 = VCMembership.objects.create( + virtual_chassis=self.vc1, device=self.device2, position=2, priority=20 + ) + self.vcm3 = VCMembership.objects.create( + virtual_chassis=self.vc1, device=self.device3, position=3, priority=30 + ) + self.vcm4 = VCMembership.objects.create( + virtual_chassis=self.vc2, device=self.device4, position=1, priority=10, is_master=True + ) + self.vcm5 = VCMembership.objects.create( + virtual_chassis=self.vc2, device=self.device5, position=2, priority=20 + ) + self.vcm6 = VCMembership.objects.create( + virtual_chassis=self.vc2, device=self.device6, position=3, priority=30 + ) + + def test_get_vcmembership(self): + + url = reverse('dcim-api:vcmembership-detail', kwargs={'pk': self.vcm1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['virtual_chassis']['id'], self.vc1.pk) + self.assertEqual(response.data['device']['id'], self.device1.pk) + self.assertEqual(response.data['position'], 1) + self.assertEqual(response.data['is_master'], True) + self.assertEqual(response.data['priority'], 10) + + def test_list_vcmemberships(self): + + url = reverse('dcim-api:vcmembership-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 6) + + def test_create_vcmembership(self): + + url = reverse('dcim-api:vcmembership-list') + + # Try creating the first membership without is_master. This should fail. + data ={ + 'device': self.device7.pk, + 'position': 1, + 'priority': 10, + } + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + # Add is_master=True and try again. This should succeed. + data.update({ + 'is_master': True, + }) + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + virtualchassis_id = VirtualChassis.objects.get(pk=response.data['virtual_chassis']).pk + + # Try adding a second member with the same position + data = { + 'virtual_chassis': virtualchassis_id, + 'device': self.device8.pk, + 'position': 1, + 'priority': 20, + } + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + # Try adding a second member with is_master=True + data['is_master'] = True + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + # Add a second member (valid) + del(data['is_master']) + data['position'] = 2 + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + + # Add a third member (valid) + data = { + 'virtual_chassis': virtualchassis_id, + 'device': self.device9.pk, + 'position': 3, + 'priority': 30, + } + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + + self.assertEqual(VCMembership.objects.count(), 9) + + def test_update_vcmembership(self): + + data = { + 'virtual_chassis': self.vc2.pk, + 'device': self.device7.pk, + 'position': 9, + 'priority': 90, + } + + url = reverse('dcim-api:vcmembership-detail', kwargs={'pk': self.vcm3.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + vcm3 = VCMembership.objects.get(pk=response.data['id']) + self.assertEqual(vcm3.virtual_chassis.pk, data['virtual_chassis']) + self.assertEqual(vcm3.device.pk, data['device']) + self.assertEqual(vcm3.position, data['position']) + self.assertEqual(vcm3.priority, data['priority']) + + def test_delete_vcmembership(self): + + url = reverse('dcim-api:vcmembership-detail', kwargs={'pk': self.vcm3.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(VCMembership.objects.count(), 5) From 4871682dc671276892bb875abf8c95d257f2b054 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Dec 2017 16:08:46 -0500 Subject: [PATCH 27/75] Allow designating primary IPs assigned to a device's peer VC members --- netbox/dcim/forms.py | 22 ++++++++--------- netbox/dcim/models.py | 56 +++++++++++++++++++++++++------------------ 2 files changed, 43 insertions(+), 35 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f82787865..b28a1f118 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -773,26 +773,24 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): # Compile list of choices for primary IPv4 and IPv6 addresses for family in [4, 6]: ip_choices = [(None, '---------')] + + # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member + interface_ids = self.instance.vc_interfaces.values('pk') + # Collect interface IPs interface_ips = IPAddress.objects.select_related('interface').filter( - family=family, interface__device=self.instance + family=family, interface_id__in=interface_ids ) if interface_ips: - ip_choices.append( - ('Interface IPs', [ - (ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips - ]) - ) + ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips] + ip_choices.append(('Interface IPs', ip_list)) # Collect NAT IPs nat_ips = IPAddress.objects.select_related('nat_inside').filter( - family=family, nat_inside__interface__device=self.instance + family=family, nat_inside__interface__in=interface_ids ) if nat_ips: - ip_choices.append( - ('NAT IPs', [ - (ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips - ]) - ) + ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips] + ip_choices.append(('NAT IPs', ip_list)) self.fields['primary_ip{}'.format(family)].choices = ip_choices # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 23b9c0acd..12fd2fe83 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -923,29 +923,28 @@ class Device(CreatedUpdatedModel, CustomFieldModel): except DeviceType.DoesNotExist: pass - # Validate primary IPv4 address - if self.primary_ip4 and ( - self.primary_ip4.interface is None or - self.primary_ip4.interface.device != self - ) and ( - self.primary_ip4.nat_inside.interface is None or - self.primary_ip4.nat_inside.interface.device != self - ): - raise ValidationError({ - 'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip4), - }) - - # Validate primary IPv6 address - if self.primary_ip6 and ( - self.primary_ip6.interface is None or - self.primary_ip6.interface.device != self - ) and ( - self.primary_ip6.nat_inside.interface is None or - self.primary_ip6.nat_inside.interface.device != self - ): - raise ValidationError({ - 'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6), - }) + # Validate primary IP addresses + vc_interfaces = self.vc_interfaces.all() + if self.primary_ip4: + if self.primary_ip4.interface in vc_interfaces: + pass + elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces: + pass + else: + raise ValidationError({ + 'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format( + self.primary_ip4), + }) + if self.primary_ip6: + if self.primary_ip6.interface in vc_interfaces: + pass + elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces: + pass + else: + raise ValidationError({ + 'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format( + self.primary_ip6), + }) # A Device can only be assigned to a Cluster in the same Site (or no Site) if self.cluster and self.cluster.site is not None and self.cluster.site != self.site: @@ -1042,6 +1041,17 @@ class Device(CreatedUpdatedModel, CustomFieldModel): except VCMembership.DoesNotExist: return None + @property + def vc_interfaces(self): + """ + Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another + Device belonging to the same virtual chassis. + """ + if hasattr(self, 'vc_membership') and self.vc_membership.is_master: + return Interface.objects.filter(device__vc_membership__virtual_chassis=self.vc_membership.virtual_chassis) + else: + return self.interfaces.all() + def get_children(self): """ Return the set of child Devices installed in DeviceBays within this Device. From d41f4d2db3a96cfe036262a0a7ac05be46489019 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Dec 2017 16:22:49 -0500 Subject: [PATCH 28/75] Return all VC member interfaces when filtering for the master device; remove virtual_chassis_id filter --- netbox/dcim/filters.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index e63de6f9a..e038da5ca 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -569,11 +569,6 @@ class InterfaceFilter(django_filters.FilterSet): method='_mac_address', label='MAC address', ) - virtual_chassis_id = django_filters.NumberFilter( - method='_virtual_chassis_id', - name='pk', - label='Virtual chassis (ID)', - ) class Meta: model = Interface @@ -582,8 +577,9 @@ class InterfaceFilter(django_filters.FilterSet): def filter_device(self, queryset, name, value): try: device = Device.objects.select_related('device_type').get(**{name: value}) + vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')] ordering = device.device_type.interface_ordering - return queryset.filter(device=device).order_naturally(ordering) + return queryset.filter(pk__in=vc_interface_ids).order_naturally(ordering) except Device.DoesNotExist: return queryset.none() @@ -606,14 +602,6 @@ class InterfaceFilter(django_filters.FilterSet): except AddrFormatError: return queryset.none() - def _virtual_chassis_id(self, queryset, name, value): - try: - virtual_chassis = VirtualChassis.objects.get(**{name: value}) - ordering = virtual_chassis.master.device_type.interface_ordering - return queryset.filter(device__vc_membership__virtual_chassis=virtual_chassis).order_naturally(ordering) - except VirtualChassis.DoesNotExist: - return queryset.none() - class DeviceBayFilter(DeviceComponentFilterSet): From 022c360964e345e8a825fccd860868293c74af1e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Dec 2017 16:44:44 -0500 Subject: [PATCH 29/75] Ignore VC member interfaces where mgmt_only=True --- netbox/dcim/models.py | 6 +++--- netbox/dcim/views.py | 8 ++------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 12fd2fe83..9a86cf8c3 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1047,10 +1047,10 @@ class Device(CreatedUpdatedModel, CustomFieldModel): Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another Device belonging to the same virtual chassis. """ + filter = Q(device=self) if hasattr(self, 'vc_membership') and self.vc_membership.is_master: - return Interface.objects.filter(device__vc_membership__virtual_chassis=self.vc_membership.virtual_chassis) - else: - return self.interfaces.all() + filter |= Q(device__vc_membership__virtual_chassis=self.vc_membership.virtual_chassis, mgmt_only=False) + return Interface.objects.filter(filter) def get_children(self): """ diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 7e635e979..4e9cdf521 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -812,7 +812,6 @@ class DeviceView(View): # Find virtual chassis memberships vc_memberships = VCMembership.objects.filter(virtual_chassis=device.virtual_chassis).select_related('device') - vc_peer_ids = [vcm.device_id for vcm in vc_memberships] # Console ports console_ports = natsorted( @@ -831,15 +830,12 @@ class DeviceView(View): power_outlets = PowerOutlet.objects.filter(device=device).select_related('connected_port') # Interfaces - interfaces_filter = Q(device=device) - if hasattr(device, 'vc_membership') and device.vc_membership.is_master: - interfaces_filter |= Q(device_id__in=vc_peer_ids, mgmt_only=False) - interfaces = Interface.objects.order_naturally( + interfaces = device.vc_interfaces.order_naturally( device.device_type.interface_ordering ).select_related( 'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', 'circuit_termination__circuit' - ).filter(interfaces_filter).prefetch_related('ip_addresses') + ).prefetch_related('ip_addresses') # Device bays device_bays = natsorted( From ca7147a0a77c7b359d83f9fadaee8d5254ea1d7f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Dec 2017 16:52:49 -0500 Subject: [PATCH 30/75] PEP8 fixes --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/tests/test_api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 0b51ec609..0d9b61964 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -216,7 +216,7 @@ class RackUnitSerializer(serializers.Serializer): class RackReservationSerializer(serializers.ModelSerializer): rack = NestedRackSerializer() - user= NestedUserSerializer() + user = NestedUserSerializer() tenant = NestedTenantSerializer() class Meta: diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 2cdd197b7..2d096244f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2329,7 +2329,7 @@ class VCMembershipTest(HttpStatusMixin, APITestCase): url = reverse('dcim-api:vcmembership-list') # Try creating the first membership without is_master. This should fail. - data ={ + data = { 'device': self.device7.pk, 'position': 1, 'priority': 10, From 9984238f2a396550cc1af0843cf3356d8f778745 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 19 Dec 2017 16:15:26 -0500 Subject: [PATCH 31/75] Closes #1744: Allow associating a platform with a specific manufacturer --- netbox/dcim/api/serializers.py | 12 +++++-- netbox/dcim/api/views.py | 1 + netbox/dcim/filters.py | 11 ++++++ netbox/dcim/forms.py | 10 ++++-- .../migrations/0053_platform_manufacturer.py | 26 ++++++++++++++ netbox/dcim/models.py | 35 +++++++++++++++---- netbox/dcim/tables.py | 12 ++++--- netbox/dcim/views.py | 5 ++- 8 files changed, 97 insertions(+), 15 deletions(-) create mode 100644 netbox/dcim/migrations/0053_platform_manufacturer.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 0d9b61964..19985132c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -426,11 +426,12 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer): # Platforms # -class PlatformSerializer(ValidatedModelSerializer): +class PlatformSerializer(serializers.ModelSerializer): + manufacturer = NestedManufacturerSerializer() class Meta: model = Platform - fields = ['id', 'name', 'slug', 'napalm_driver', 'rpc_client'] + fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client'] class NestedPlatformSerializer(serializers.ModelSerializer): @@ -441,6 +442,13 @@ class NestedPlatformSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] +class WritablePlatformSerializer(ValidatedModelSerializer): + + class Meta: + model = Platform + fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client'] + + # # Devices # diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index a3ef98a15..924e39d73 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -225,6 +225,7 @@ class DeviceRoleViewSet(ModelViewSet): class PlatformViewSet(ModelViewSet): queryset = Platform.objects.all() serializer_class = serializers.PlatformSerializer + write_serializer_class = serializers.WritablePlatformSerializer filter_class = filters.PlatformFilter diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index e038da5ca..f12f8e3b9 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -344,6 +344,17 @@ class DeviceRoleFilter(django_filters.FilterSet): class PlatformFilter(django_filters.FilterSet): + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + name='manufacturer', + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + name='manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) class Meta: model = Platform diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index b28a1f118..1a7c3837b 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -677,7 +677,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): class Meta: model = Platform - fields = ['name', 'slug', 'napalm_driver', 'rpc_client'] + fields = ['name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client'] class PlatformCSVForm(forms.ModelForm): @@ -685,9 +685,10 @@ class PlatformCSVForm(forms.ModelForm): class Meta: model = Platform - fields = ['name', 'slug', 'napalm_driver'] + fields = ['name', 'slug', 'manufacturer', 'napalm_driver'] help_texts = { 'name': 'Platform name', + 'manufacturer': 'Manufacturer name', } @@ -797,6 +798,11 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): # can be flipped from one face to another. self.fields['position'].widget.attrs['api-url'] += '&exclude={}'.format(self.instance.pk) + # Limit platform by manufacturer + self.fields['platform'].queryset = Platform.objects.filter( + Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer) + ) + else: # An object that doesn't exist yet can't have any IPs assigned to it diff --git a/netbox/dcim/migrations/0053_platform_manufacturer.py b/netbox/dcim/migrations/0053_platform_manufacturer.py new file mode 100644 index 000000000..62797716e --- /dev/null +++ b/netbox/dcim/migrations/0053_platform_manufacturer.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-12-19 20:56 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0052_virtual_chassis'), + ] + + operations = [ + migrations.AddField( + model_name='platform', + name='manufacturer', + field=models.ForeignKey(blank=True, help_text='Optionally limit this platform to devices of a certain manufacturer', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='platforms', to='dcim.Manufacturer'), + ), + migrations.AlterField( + model_name='platform', + name='napalm_driver', + field=models.CharField(blank=True, help_text='The name of the NAPALM driver to use when interacting with devices', max_length=50, verbose_name='NAPALM driver'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 9a86cf8c3..b35d0b078 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -768,16 +768,31 @@ class DeviceRole(models.Model): @python_2_unicode_compatible class Platform(models.Model): """ - Platform refers to the software or firmware running on a Device; for example, "Cisco IOS-XR" or "Juniper Junos". + Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by - specifying an remote procedure call (RPC) client. + specifying a NAPALM driver. """ name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) - napalm_driver = models.CharField(max_length=50, blank=True, verbose_name='NAPALM driver', - help_text="The name of the NAPALM driver to use when interacting with devices.") - rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True, - verbose_name='Legacy RPC client') + manufacturer = models.ForeignKey( + to='Manufacturer', + related_name='platforms', + blank=True, + null=True, + help_text="Optionally limit this platform to devices of a certain manufacturer" + ) + napalm_driver = models.CharField( + max_length=50, + blank=True, + verbose_name='NAPALM driver', + help_text="The name of the NAPALM driver to use when interacting with devices" + ) + rpc_client = models.CharField( + max_length=30, + choices=RPC_CLIENT_CHOICES, + blank=True, + verbose_name="Legacy RPC client" + ) class Meta: ordering = ['name'] @@ -946,6 +961,14 @@ class Device(CreatedUpdatedModel, CustomFieldModel): self.primary_ip6), }) + # Validate manufacturer/platform + if self.device_type and self.platform: + if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer: + raise ValidationError({ + 'platform': "The assigned platform is limited to {} device types, but this device's type belongs " + "to {}.".format(self.platform.manufacturer, self.device_type.manufacturer) + }) + # A Device can only be assigned to a Cluster in the same Site (or no Site) if self.cluster and self.cluster.site is not None and self.cluster.site != self.site: raise ValidationError({ diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index c416f3c70..de99a98bd 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -271,13 +271,14 @@ class ManufacturerTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn(verbose_name='Name') devicetype_count = tables.Column(verbose_name='Device Types') + platform_count = tables.Column(verbose_name='Platforms') slug = tables.Column(verbose_name='Slug') actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') class Meta(BaseTable.Meta): model = Manufacturer - fields = ('pk', 'name', 'devicetype_count', 'slug', 'actions') + fields = ('pk', 'name', 'devicetype_count', 'platform_count', 'slug', 'actions') # @@ -389,12 +390,15 @@ class PlatformTable(BaseTable): name = tables.LinkColumn(verbose_name='Name') device_count = tables.Column(verbose_name='Devices') slug = tables.Column(verbose_name='Slug') - actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}}, - verbose_name='') + actions = tables.TemplateColumn( + template_code=PLATFORM_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) class Meta(BaseTable.Meta): model = Platform - fields = ('pk', 'name', 'device_count', 'slug', 'napalm_driver', 'actions') + fields = ('pk', 'name', 'manufacturer', 'device_count', 'slug', 'napalm_driver', 'actions') # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4e9cdf521..bc3443a7a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -453,7 +453,10 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class ManufacturerListView(ObjectListView): - queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types')) + queryset = Manufacturer.objects.annotate( + devicetype_count=Count('device_types', distinct=True), + platform_count=Count('platforms', distinct=True), + ) table = tables.ManufacturerTable template_name = 'dcim/manufacturer_list.html' From b20258c66e4b6715782f9f79dace2333b817a5cd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 19 Dec 2017 17:24:14 -0500 Subject: [PATCH 32/75] Closes #1283: Added a time zone field to the site model --- netbox/dcim/api/serializers.py | 14 +++++++------ netbox/dcim/forms.py | 8 ++++--- netbox/dcim/migrations/0054_site_time_zone.py | 21 +++++++++++++++++++ netbox/dcim/models.py | 6 +++++- netbox/netbox/settings.py | 1 + netbox/templates/dcim/site.html | 12 +++++++++++ netbox/templates/dcim/site_edit.html | 1 + netbox/utilities/api.py | 18 ++++++++++++++++ netbox/utilities/templatetags/helpers.py | 11 ++++++++++ requirements.txt | 1 + 10 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 netbox/dcim/migrations/0054_site_time_zone.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 19985132c..ed9cf318f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -20,7 +20,7 @@ from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer from users.api.serializers import NestedUserSerializer -from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from utilities.api import ChoiceFieldSerializer, TimeZoneField, ValidatedModelSerializer from virtualization.models import Cluster @@ -58,13 +58,14 @@ class WritableRegionSerializer(ValidatedModelSerializer): class SiteSerializer(CustomFieldModelSerializer): region = NestedRegionSerializer() tenant = NestedTenantSerializer() + time_zone = TimeZoneField(required=False) class Meta: model = Site 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', + 'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'physical_address', + 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', + 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits', ] @@ -77,12 +78,13 @@ class NestedSiteSerializer(serializers.ModelSerializer): class WritableSiteSerializer(CustomFieldModelSerializer): + time_zone = TimeZoneField(required=False) class Meta: model = Site fields = [ - 'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', - 'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', + 'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'physical_address', + 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', ] diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 1a7c3837b..2bca68bd3 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import User from django.contrib.postgres.forms.array import SimpleArrayField from django.db.models import Count, Q from mptt.forms import TreeNodeChoiceField +from timezone_field import TimeZoneFormField from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress, VLAN, VLANGroup @@ -96,7 +97,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): model = Site fields = [ 'name', 'slug', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'physical_address', - 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', + 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'time_zone', 'comments', ] widgets = { 'physical_address': SmallTextarea(attrs={'rows': 3}), @@ -135,7 +136,7 @@ class SiteCSVForm(forms.ModelForm): model = Site fields = [ 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', - 'contact_name', 'contact_phone', 'contact_email', 'comments', + 'contact_name', 'contact_phone', 'contact_email', 'time_zone', 'comments', ] help_texts = { 'name': 'Site name', @@ -149,9 +150,10 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): 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') + time_zone = TimeZoneFormField(required=False) class Meta: - nullable_fields = ['region', 'tenant', 'asn'] + nullable_fields = ['region', 'tenant', 'asn', 'time_zone'] class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): diff --git a/netbox/dcim/migrations/0054_site_time_zone.py b/netbox/dcim/migrations/0054_site_time_zone.py new file mode 100644 index 000000000..f599cb155 --- /dev/null +++ b/netbox/dcim/migrations/0054_site_time_zone.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-12-19 21:53 +from __future__ import unicode_literals + +from django.db import migrations +import timezone_field.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0053_platform_manufacturer'), + ] + + operations = [ + migrations.AddField( + model_name='site', + name='time_zone', + field=timezone_field.fields.TimeZoneField(blank=True), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index b35d0b078..8fb58a081 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -14,6 +14,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible from mptt.models import MPTTModel, TreeForeignKey +from timezone_field import TimeZoneField from circuits.models import Circuit from extras.models import CustomFieldModel, CustomFieldValue, ImageAttachment @@ -86,6 +87,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): 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') + time_zone = TimeZoneField(blank=True) physical_address = models.CharField(max_length=200, blank=True) shipping_address = models.CharField(max_length=200, blank=True) contact_name = models.CharField(max_length=50, blank=True) @@ -98,7 +100,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel): objects = SiteManager() csv_headers = [ - 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email', + 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'contact_name', 'contact_phone', + 'contact_email', ] class Meta: @@ -118,6 +121,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): self.tenant.name if self.tenant else None, self.facility, self.asn, + self.time_zone, self.contact_name, self.contact_phone, self.contact_email, diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index ac1aba492..7a3f6cbec 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -134,6 +134,7 @@ INSTALLED_APPS = ( 'mptt', 'rest_framework', 'rest_framework_swagger', + 'timezone_field', 'circuits', 'dcim', 'ipam', diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index efc98c3d0..c61f007df 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -1,5 +1,6 @@ {% extends '_base.html' %} {% load static from staticfiles %} +{% load tz %} {% load helpers %} {% block content %} @@ -105,6 +106,17 @@ {% endif %} + + Time Zone + + {% if site.time_zone %} + {{ site.time_zone }} (UTC {{ site.time_zone|tzoffset }})
+ Site time: {% timezone site.time_zone %}{% now "SHORT_DATETIME_FORMAT" %}{% endtimezone %} + {% else %} + N/A + {% endif %} + +
diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index a1c13075a..21b78f229 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -10,6 +10,7 @@ {% render_field form.region %} {% render_field form.facility %} {% render_field form.asn %} + {% render_field form.time_zone %}
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 4f5ce4471..9dccdcc9d 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from collections import OrderedDict +import pytz from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -97,6 +98,23 @@ class ContentTypeFieldSerializer(Field): raise ValidationError("Invalid content type") +class TimeZoneField(Field): + """ + Represent a pytz time zone. + """ + + def to_representation(self, obj): + return obj.zone if obj else None + + def to_internal_value(self, data): + if not data: + return "" + try: + return pytz.timezone(str(data)) + except pytz.exceptions.UnknownTimeZoneError: + raise ValidationError('Invalid time zone "{}"'.format(data)) + + # # Viewsets # diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 2af936885..2dd726195 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,5 +1,8 @@ from __future__ import unicode_literals +import datetime +import pytz + from django import template from django.utils.safestring import mark_safe from markdown import markdown @@ -117,6 +120,14 @@ def example_choices(field, arg=3): return ', '.join(examples) or 'None' +@register.filter() +def tzoffset(value): + """ + Returns the hour offset of a given time zone using the current time. + """ + return datetime.datetime.now(value).strftime('%z') + + # # Tags # diff --git a/requirements.txt b/requirements.txt index 303d2ad47..a65988307 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ django-filter>=1.1.0 django-mptt==0.8.7 django-rest-swagger>=2.1.0 django-tables2>=1.10.0 +django-timezone-field>=2.0 djangorestframework>=3.6.4 graphviz>=0.6 Markdown>=2.6.7 From b65d9943976526af174308da6a8bd0b95b81d448 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 Dec 2017 13:04:00 -0500 Subject: [PATCH 33/75] Fixes #1136: Enforce model validation during bulk update --- netbox/utilities/views.py | 119 +++++++++++++++----------------------- 1 file changed, 48 insertions(+), 71 deletions(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index ad4966b13..e7ee4bdb7 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -9,10 +9,10 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import transaction, IntegrityError from django.db.models import ProtectedError -from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, TypedChoiceField +from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render -from django.template import TemplateSyntaxError +from django.template.exceptions import TemplateSyntaxError from django.urls import reverse from django.utils.html import escape from django.utils.http import is_safe_url @@ -514,31 +514,55 @@ class BulkEditView(View): custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk'] - - # Update standard fields. If a field is listed in _nullify, delete its value. nullified_fields = request.POST.getlist('_nullify') - fields_to_update = {} - for field in standard_fields: - if field in form.nullable_fields and field in nullified_fields: - if isinstance(form.fields[field], CharField): - fields_to_update[field] = '' - else: - fields_to_update[field] = None - elif form.cleaned_data[field] not in (None, ''): - fields_to_update[field] = form.cleaned_data[field] - updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - # Update custom fields for objects - if custom_fields: - objs_updated = self.update_custom_fields(pk_list, form, custom_fields, nullified_fields) - if objs_updated and not updated_count: - updated_count = objs_updated + try: - if updated_count: - msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural) - messages.success(self.request, msg) - UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg) - return redirect(return_url) + with transaction.atomic(): + + updated_count = 0 + for obj in self.cls.objects.filter(pk__in=pk_list): + + # Update standard fields. If a field is listed in _nullify, delete its value. + for name in standard_fields: + if name in form.nullable_fields and name in nullified_fields: + setattr(obj, name, '' if isinstance(form.fields[name], CharField) else None) + elif form.cleaned_data[name] not in (None, ''): + setattr(obj, name, form.cleaned_data[name]) + obj.full_clean() + obj.save() + + # Update custom fields + obj_type = ContentType.objects.get_for_model(self.cls) + for name in custom_fields: + field = form.fields[name].model + if name in form.nullable_fields and name in nullified_fields: + CustomFieldValue.objects.filter( + field=field, obj_type=obj_type, obj_id=obj.pk + ).delete() + elif form.cleaned_data[name] not in [None, '']: + try: + cfv = CustomFieldValue.objects.get( + field=field, obj_type=obj_type, obj_id=obj.pk + ) + except CustomFieldValue.DoesNotExist: + cfv = CustomFieldValue( + field=field, obj_type=obj_type, obj_id=obj.pk + ) + cfv.value = form.cleaned_data[name] + cfv.save() + + updated_count += 1 + + if updated_count: + msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural) + messages.success(self.request, msg) + UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg) + + return redirect(return_url) + + except ValidationError as e: + messages.error(self.request, "{} failed validation: {}".format(obj, e)) else: initial_data = request.POST.copy() @@ -559,53 +583,6 @@ class BulkEditView(View): 'return_url': return_url, }) - def update_custom_fields(self, pk_list, form, fields, nullified_fields): - obj_type = ContentType.objects.get_for_model(self.cls) - objs_updated = False - - for name in fields: - - field = form.fields[name].model - - # Setting the field to null - if name in form.nullable_fields and name in nullified_fields: - - # Delete all CustomFieldValues for instances of this field belonging to the selected objects. - CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list).delete() - objs_updated = True - - # Updating the value of the field - elif form.cleaned_data[name] not in [None, '']: - - # Check for zero value (bulk editing) - if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0: - serialized_value = field.serialize_value(None) - else: - serialized_value = field.serialize_value(form.cleaned_data[name]) - - # Gather any pre-existing CustomFieldValues for the objects being edited. - existing_cfvs = CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list) - - # Determine which objects have an existing CFV to update and which need a new CFV created. - update_list = [cfv['obj_id'] for cfv in existing_cfvs.values()] - create_list = list(set(pk_list) - set(update_list)) - - # Creating/updating CFVs - if serialized_value: - existing_cfvs.update(serialized_value=serialized_value) - CustomFieldValue.objects.bulk_create([ - CustomFieldValue(field=field, obj_type=obj_type, obj_id=pk, serialized_value=serialized_value) - for pk in create_list - ]) - - # Deleting CFVs - else: - existing_cfvs.delete() - - objs_updated = True - - return len(pk_list) if objs_updated else 0 - class BulkDeleteView(View): """ From 063e79451f8b4b5838a6e188fe2c8fbab577d7e4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 Dec 2017 10:49:40 -0500 Subject: [PATCH 34/75] Closes #1321: Added created and last_updated fields for relevant models to their API serializers --- netbox/circuits/api/serializers.py | 8 ++--- netbox/dcim/api/serializers.py | 11 ++++--- netbox/ipam/api/serializers.py | 40 +++++++++++++++++------- netbox/secrets/api/serializers.py | 2 +- netbox/tenancy/api/serializers.py | 4 +-- netbox/virtualization/api/serializers.py | 8 ++--- 6 files changed, 46 insertions(+), 27 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index d2432374f..51a5ec878 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -19,7 +19,7 @@ class ProviderSerializer(CustomFieldModelSerializer): model = Provider fields = [ 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - 'custom_fields', + 'custom_fields', 'created', 'last_updated', ] @@ -37,7 +37,7 @@ class WritableProviderSerializer(CustomFieldModelSerializer): model = Provider fields = [ 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - 'custom_fields', + 'custom_fields', 'created', 'last_updated', ] @@ -73,7 +73,7 @@ class CircuitSerializer(CustomFieldModelSerializer): model = Circuit fields = [ 'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', - 'custom_fields', + 'custom_fields', 'created', 'last_updated', ] @@ -91,7 +91,7 @@ class WritableCircuitSerializer(CustomFieldModelSerializer): model = Circuit fields = [ 'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', - 'custom_fields', + 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index ed9cf318f..a58fc3369 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -65,7 +65,8 @@ class SiteSerializer(CustomFieldModelSerializer): fields = [ 'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', - 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits', + 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', + 'count_circuits', ] @@ -85,6 +86,7 @@ class WritableSiteSerializer(CustomFieldModelSerializer): fields = [ 'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', + 'created', 'last_updated', ] @@ -150,7 +152,7 @@ class RackSerializer(CustomFieldModelSerializer): model = Rack fields = [ 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', - 'u_height', 'desc_units', 'comments', 'custom_fields', + 'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated', ] @@ -168,7 +170,7 @@ class WritableRackSerializer(CustomFieldModelSerializer): model = Rack fields = [ 'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height', - 'desc_units', 'comments', 'custom_fields', + 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated', ] # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This # prevents facility_id from being interpreted as a required field. @@ -493,7 +495,7 @@ class DeviceSerializer(CustomFieldModelSerializer): 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', - 'cluster', 'comments', 'custom_fields', + 'cluster', 'comments', 'custom_fields', 'created', 'last_updated', ] def get_parent_device(self, obj): @@ -514,6 +516,7 @@ class WritableDeviceSerializer(CustomFieldModelSerializer): fields = [ 'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'comments', 'custom_fields', + 'created', 'last_updated', ] validators = [] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 75fbfbe49..2eca51895 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -27,7 +27,10 @@ class VRFSerializer(CustomFieldModelSerializer): class Meta: model = VRF - fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields'] + fields = [ + 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields', 'created', + 'last_updated', + ] class NestedVRFSerializer(serializers.ModelSerializer): @@ -42,7 +45,9 @@ class WritableVRFSerializer(CustomFieldModelSerializer): class Meta: model = VRF - fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields'] + fields = [ + 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields', 'created', 'last_updated', + ] # @@ -92,7 +97,9 @@ class AggregateSerializer(CustomFieldModelSerializer): class Meta: model = Aggregate - fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields'] + fields = [ + 'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated', + ] class NestedAggregateSerializer(serializers.ModelSerializer): @@ -107,7 +114,7 @@ class WritableAggregateSerializer(CustomFieldModelSerializer): class Meta: model = Aggregate - fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields'] + fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated'] # @@ -167,7 +174,7 @@ class VLANSerializer(CustomFieldModelSerializer): model = VLAN fields = [ 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name', - 'custom_fields', + 'custom_fields', 'created', 'last_updated', ] @@ -183,7 +190,10 @@ class WritableVLANSerializer(CustomFieldModelSerializer): class Meta: model = VLAN - fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields'] + fields = [ + 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields', 'created', + 'last_updated', + ] validators = [] def validate(self, data): @@ -217,7 +227,7 @@ class PrefixSerializer(CustomFieldModelSerializer): model = Prefix fields = [ 'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', - 'custom_fields', + 'custom_fields', 'created', 'last_updated', ] @@ -235,7 +245,7 @@ class WritablePrefixSerializer(CustomFieldModelSerializer): model = Prefix fields = [ 'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', - 'custom_fields', + 'custom_fields', 'created', 'last_updated', ] @@ -288,7 +298,7 @@ class IPAddressSerializer(CustomFieldModelSerializer): model = IPAddress fields = [ 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside', - 'nat_outside', 'custom_fields', + 'nat_outside', 'custom_fields', 'created', 'last_updated', ] @@ -310,7 +320,7 @@ class WritableIPAddressSerializer(CustomFieldModelSerializer): model = IPAddress fields = [ 'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside', - 'custom_fields', + 'custom_fields', 'created', 'last_updated', ] @@ -340,7 +350,10 @@ class ServiceSerializer(serializers.ModelSerializer): class Meta: model = Service - fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description'] + fields = [ + 'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created', + 'last_updated', + ] # TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError. @@ -348,4 +361,7 @@ class WritableServiceSerializer(serializers.ModelSerializer): class Meta: model = Service - fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description'] + fields = [ + 'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created', + 'last_updated', + ] diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index b7c4bac9a..6eb84efaa 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -45,7 +45,7 @@ class WritableSecretSerializer(serializers.ModelSerializer): class Meta: model = Secret - fields = ['id', 'device', 'role', 'name', 'plaintext'] + fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated'] validators = [] def validate(self, data): diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index a52ac2c60..454e41c52 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -35,7 +35,7 @@ class TenantSerializer(CustomFieldModelSerializer): class Meta: model = Tenant - fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields'] + fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated'] class NestedTenantSerializer(serializers.ModelSerializer): @@ -50,4 +50,4 @@ class WritableTenantSerializer(CustomFieldModelSerializer): class Meta: model = Tenant - fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields'] + fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated'] diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 078df19b6..6268c0227 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -62,7 +62,7 @@ class ClusterSerializer(CustomFieldModelSerializer): class Meta: model = Cluster - fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields'] + fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated'] class NestedClusterSerializer(serializers.ModelSerializer): @@ -77,7 +77,7 @@ class WritableClusterSerializer(CustomFieldModelSerializer): class Meta: model = Cluster - fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields'] + fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated'] # @@ -107,7 +107,7 @@ class VirtualMachineSerializer(CustomFieldModelSerializer): model = VirtualMachine fields = [ 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'vcpus', 'memory', 'disk', 'comments', 'custom_fields', + 'vcpus', 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated', ] @@ -125,7 +125,7 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer): model = VirtualMachine fields = [ 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', - 'memory', 'disk', 'comments', 'custom_fields', + 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated', ] From d84e5d1839cc275becd7ab02d7ba03e842a03e20 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 Dec 2017 13:29:02 -0500 Subject: [PATCH 35/75] Cleaned up component tables and checkbox toggling --- netbox/dcim/views.py | 12 +- netbox/project-static/css/base.css | 2 +- netbox/project-static/js/forms.js | 31 ++- netbox/templates/dcim/device.html | 176 ++++++++---------- netbox/templates/dcim/inc/devicebay.html | 4 +- .../dcim/inc/devicetype_component_table.html | 13 -- netbox/templates/dcim/inc/interface.html | 2 +- .../virtualization/virtualmachine.html | 50 +++-- netbox/utilities/tables.py | 2 +- 9 files changed, 123 insertions(+), 169 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index bc3443a7a..27e048e50 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -510,29 +510,29 @@ class DeviceTypeView(View): # Component tables consoleport_table = tables.ConsolePortTemplateTable( natsorted(ConsolePortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), - show_header=False + orderable=False ) consoleserverport_table = tables.ConsoleServerPortTemplateTable( natsorted(ConsoleServerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), - show_header=False + orderable=False ) powerport_table = tables.PowerPortTemplateTable( natsorted(PowerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), - show_header=False + orderable=False ) poweroutlet_table = tables.PowerOutletTemplateTable( natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), - show_header=False + orderable=False ) interface_table = tables.InterfaceTemplateTable( list(InterfaceTemplate.objects.order_naturally( devicetype.interface_ordering ).filter(device_type=devicetype)), - show_header=False + orderable=False ) devicebay_table = tables.DeviceBayTemplateTable( natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), - show_header=False + orderable=False ) if request.user.has_perm('dcim.change_devicetype'): consoleport_table.columns.show('pk') diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 9bf583dd9..bd1570827 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -121,7 +121,7 @@ input[name="pk"] { } /* Tables */ -.table > tbody > tr > th.pk, .table > tbody > tr > td.pk { +th.pk, td.pk { padding-bottom: 6px; padding-top: 10px; width: 30px; diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 50ee20e16..f0208df7b 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -1,14 +1,24 @@ $(document).ready(function() { - // "Toggle all" checkbox (table header) - $('#toggle_all').click(function() { - $('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked')); + // "Toggle" checkbox for object lists (PK column) + $('input:checkbox.toggle').click(function() { + $(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked')); + + // Show the "select all" box if present if ($(this).is(':checked')) { $('#select_all_box').removeClass('hidden'); } else { $('#select_all').prop('checked', false); } }); + + // Uncheck the "toggle" and "select all" checkboxes if an item is unchecked + $('input:checkbox[name=pk]').click(function (event) { + if (!$(this).attr('checked')) { + $('input:checkbox.toggle, #select_all').prop('checked', false); + } + }); + // Enable hidden buttons when "select all" is checked $('#select_all').click(function() { if ($(this).is(':checked')) { @@ -17,21 +27,6 @@ $(document).ready(function() { $('#select_all_box').find('button').prop('disabled', 'disabled'); } }); - // Uncheck the "toggle all" checkbox if an item is unchecked - $('input:checkbox[name=pk]').click(function (event) { - if (!$(this).attr('checked')) { - $('#select_all, #toggle_all').prop('checked', false); - } - }); - - // Simple "Toggle all" button (panel) - $('button.toggle').click(function() { - var selected = $(this).attr('selected'); - $(this).closest('form').find('input:checkbox[name=pk]').prop('checked', !selected); - $(this).attr('selected', !selected); - $(this).children('span').toggleClass('glyphicon-unchecked glyphicon-check'); - return false; - }); // Slugify function slugify(s, num_chars) { diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index a1f2576fb..59169fc65 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -376,27 +376,27 @@
Device Bays -
- {% if perms.dcim.change_devicebay and device_bays|length > 1 %} - - {% endif %} - {% if perms.dcim.add_devicebay and device_bays|length > 10 %} - - Add device bays - - {% endif %} -
- - {% for devicebay in device_bays %} - {% include 'dcim/inc/devicebay.html' %} - {% empty %} +
+ - + {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} + + {% endif %} + + + - {% endfor %} + + + {% for devicebay in device_bays %} + {% include 'dcim/inc/devicebay.html' %} + {% empty %} + + + + {% endfor %} +
No device bays definedNameInstalled Device
— No device bays defined —
{% if perms.dcim.add_devicebay or perms.dcim.delete_devicebay %}
- - - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - - {% endif %} - - - - - - - - - {% for iface in interfaces %} - {% include 'dcim/inc/interface.html' %} - {% empty %} +
NameLAGDescriptionMTUMAC AddressConnection
+ - + {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + + {% endif %} + + + + + + + - {% endfor %} + + + {% for iface in interfaces %} + {% include 'dcim/inc/interface.html' %} + {% empty %} + + + + {% endfor %} +
No interfaces definedNameLAGDescriptionMTUMAC AddressConnection
— No interfaces defined —
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}