diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 2109f0784..099676181 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -23,12 +23,12 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, + BulkRenameForm, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) -from virtualization.models import Cluster, ClusterGroup, VirtualMachine +from virtualization.models import Cluster, ClusterGroup from .choices import * from .constants import * from .models import ( @@ -150,30 +150,6 @@ class LabeledComponentForm(BootstrapMixin, forms.Form): }, code='label_pattern_mismatch') -class BulkRenameForm(forms.Form): - """ - An extendable form to be used for renaming device components in bulk. - """ - find = forms.CharField() - replace = forms.CharField() - use_regex = forms.BooleanField( - required=False, - initial=True, - label='Use regular expressions' - ) - - def clean(self): - - # Validate regular expression in "find" field - if self.cleaned_data['use_regex']: - try: - re.compile(self.cleaned_data['find']) - except re.error: - raise forms.ValidationError({ - 'find': "Invalid regular expression" - }) - - # # Fields # @@ -1816,18 +1792,20 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 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') + interface_ids = self.instance.vc_interfaces.values_list('pk', flat=True) # Collect interface IPs interface_ips = IPAddress.objects.prefetch_related('interface').filter( - address__family=family, interface_id__in=interface_ids + address__family=family, + interface__in=interface_ids ) if 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.prefetch_related('nat_inside').filter( - address__family=family, nat_inside__interface__in=interface_ids + address__family=family, + nat_inside__interface__in=interface_ids ) if nat_ips: ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips] @@ -2961,12 +2939,6 @@ class InterfaceBulkDisconnectForm(ConfirmationForm): class InterfaceCSVForm(CSVModelForm): device = CSVModelChoiceField( queryset=Device.objects.all(), - required=False, - to_field_name='name' - ) - virtual_machine = CSVModelChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, to_field_name='name' ) lag = CSVModelChoiceField( diff --git a/netbox/dcim/migrations/0109_interface_remove_vm.py b/netbox/dcim/migrations/0109_interface_remove_vm.py new file mode 100644 index 000000000..97a84a43e --- /dev/null +++ b/netbox/dcim/migrations/0109_interface_remove_vm.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.6 on 2020-06-22 16:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0108_add_tags'), + ('virtualization', '0016_replicate_interfaces'), + ] + + operations = [ + migrations.RemoveField( + model_name='interface', + name='virtual_machine', + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 236979b4a..d8a5f028c 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -35,11 +35,12 @@ from .device_component_templates import ( PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from .device_components import ( - CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet, - PowerPort, RearPort, + BaseInterface, CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, + PowerOutlet, PowerPort, RearPort, ) __all__ = ( + 'BaseInterface', 'Cable', 'CableTermination', 'ConsolePort', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index a626c055f..30a276c7d 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -19,7 +19,6 @@ from utilities.ordering import naturalize_interface from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar from utilities.utils import serialize_object -from virtualization.choices import VMInterfaceTypeChoices __all__ = ( @@ -53,18 +52,12 @@ class ComponentModel(models.Model): return self.name def to_objectchange(self, action): - # Annotate the parent Device/VM - try: - parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None) - except ObjectDoesNotExist: - # The parent device/VM has already been deleted - parent = None - + # Annotate the parent Device return ObjectChange( changed_object=self, object_repr=str(self), action=action, - related_object=parent, + related_object=self.device, object_data=serialize_object(self) ) @@ -592,26 +585,7 @@ class PowerOutlet(CableTermination, ComponentModel): # Interfaces # -@extras_features('graphs', 'export_templates', 'webhooks') -class Interface(CableTermination, ComponentModel): - """ - A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other - Interface. - """ - device = models.ForeignKey( - to='Device', - on_delete=models.CASCADE, - related_name='interfaces', - null=True, - blank=True - ) - virtual_machine = models.ForeignKey( - to='virtualization.VirtualMachine', - on_delete=models.CASCADE, - related_name='interfaces', - null=True, - blank=True - ) +class BaseInterface(models.Model): name = models.CharField( max_length=64 ) @@ -621,6 +595,42 @@ class Interface(CableTermination, ComponentModel): max_length=100, blank=True ) + enabled = models.BooleanField( + default=True + ) + mac_address = MACAddressField( + null=True, + blank=True, + verbose_name='MAC Address' + ) + mtu = models.PositiveIntegerField( + blank=True, + null=True, + validators=[MinValueValidator(1), MaxValueValidator(65536)], + verbose_name='MTU' + ) + mode = models.CharField( + max_length=50, + choices=InterfaceModeChoices, + blank=True + ) + + class Meta: + abstract = True + + +@extras_features('graphs', 'export_templates', 'webhooks') +class Interface(CableTermination, ComponentModel, BaseInterface): + """ + A network interface within a Device. A physical Interface can connect to exactly one other Interface. + """ + device = models.ForeignKey( + to='Device', + on_delete=models.CASCADE, + related_name='interfaces', + null=True, + blank=True + ) label = models.CharField( max_length=64, blank=True, @@ -656,30 +666,11 @@ class Interface(CableTermination, ComponentModel): max_length=50, choices=InterfaceTypeChoices ) - enabled = models.BooleanField( - default=True - ) - mac_address = MACAddressField( - null=True, - blank=True, - verbose_name='MAC Address' - ) - mtu = models.PositiveIntegerField( - blank=True, - null=True, - validators=[MinValueValidator(1), MaxValueValidator(65536)], - verbose_name='MTU' - ) mgmt_only = models.BooleanField( default=False, verbose_name='OOB Management', help_text='This interface is used only for out-of-band management' ) - mode = models.CharField( - max_length=50, - choices=InterfaceModeChoices, - blank=True - ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, @@ -694,15 +685,19 @@ class Interface(CableTermination, ComponentModel): blank=True, verbose_name='Tagged VLANs' ) + ip_addresses = GenericRelation( + to='ipam.IPAddress', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='interface' + ) tags = TaggableManager(through=TaggedItem) csv_headers = [ - 'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', - 'description', 'mode', + 'device', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode', ] class Meta: - # TODO: ordering and unique_together should include virtual_machine ordering = ('device', CollateAsChar('_name')) unique_together = ('device', 'name') @@ -712,7 +707,6 @@ class Interface(CableTermination, ComponentModel): def to_csv(self): return ( self.device.identifier if self.device else None, - self.virtual_machine.name if self.virtual_machine else None, self.name, self.lag.name if self.lag else None, self.get_type_display(), @@ -726,18 +720,6 @@ class Interface(CableTermination, ComponentModel): def clean(self): - # An Interface must belong to a Device *or* to a VirtualMachine - if self.device and self.virtual_machine: - raise ValidationError("An interface cannot belong to both a device and a virtual machine.") - if not self.device and not self.virtual_machine: - raise ValidationError("An interface must belong to either a device or a virtual machine.") - - # VM interfaces must be virtual - if self.virtual_machine and self.type not in VMInterfaceTypeChoices.values(): - raise ValidationError({ - 'type': "Invalid interface type for a virtual machine: {}".format(self.type) - }) - # Virtual interfaces cannot be connected if self.type in NONCONNECTABLE_IFACE_TYPES and ( self.cable or getattr(self, 'circuit_termination', False) @@ -773,7 +755,7 @@ class Interface(CableTermination, ComponentModel): 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) + "device, or it must be global".format(self.untagged_vlan) }) def save(self, *args, **kwargs): @@ -788,21 +770,6 @@ class Interface(CableTermination, ComponentModel): return super().save(*args, **kwargs) - def to_objectchange(self, action): - # Annotate the parent Device/VM - try: - parent_obj = self.device or self.virtual_machine - except ObjectDoesNotExist: - parent_obj = None - - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - related_object=parent_obj, - object_data=serialize_object(self) - ) - @property def connected_endpoint(self): """ @@ -841,7 +808,7 @@ class Interface(CableTermination, ComponentModel): @property def parent(self): - return self.device or self.virtual_machine + return self.device @property def is_connectable(self): diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 1589a7f6d..82b2414ad 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -598,17 +598,11 @@ class InterfaceImportTable(BaseTable): viewname='dcim:device', args=[Accessor('device.pk')] ) - virtual_machine = tables.LinkColumn( - viewname='virtualization:virtualmachine', - args=[Accessor('virtual_machine.pk')], - verbose_name='Virtual Machine' - ) class Meta(BaseTable.Meta): model = Interface fields = ( - 'device', 'virtual_machine', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu', - 'mgmt_only', 'mode', + 'device', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'mode', ) empty_text = False @@ -863,6 +857,7 @@ class DeviceImportTable(BaseTable): class DeviceComponentDetailTable(BaseTable): pk = ToggleColumn() + device = tables.LinkColumn() name = tables.Column(order_by=('_name',)) cable = tables.LinkColumn() @@ -881,7 +876,6 @@ class ConsolePortTable(BaseTable): class ConsolePortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta): pass @@ -896,7 +890,6 @@ class ConsoleServerPortTable(BaseTable): class ConsoleServerPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta): pass @@ -911,7 +904,6 @@ class PowerPortTable(BaseTable): class PowerPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta): pass @@ -926,7 +918,6 @@ class PowerOutletTable(BaseTable): class PowerOutletDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta): pass @@ -940,14 +931,11 @@ class InterfaceTable(BaseTable): class InterfaceDetailTable(DeviceComponentDetailTable): - parent = tables.LinkColumn(order_by=('device', 'virtual_machine')) - name = tables.LinkColumn() enabled = BooleanColumn() - class Meta(InterfaceTable.Meta): - order_by = ('parent', 'name') - fields = ('pk', 'parent', 'name', 'label', 'enabled', 'type', 'description', 'cable') - sequence = ('pk', 'parent', 'name', 'label', 'enabled', 'type', 'description', 'cable') + class Meta(DeviceComponentDetailTable.Meta, InterfaceTable.Meta): + fields = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description', 'cable') + sequence = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description', 'cable') class FrontPortTable(BaseTable): @@ -960,7 +948,6 @@ class FrontPortTable(BaseTable): class FrontPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta): pass @@ -976,7 +963,6 @@ class RearPortTable(BaseTable): class RearPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta): pass @@ -991,7 +977,6 @@ class DeviceBayTable(BaseTable): class DeviceBayDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() installed_device = tables.LinkColumn() class Meta(DeviceBayTable.Meta): diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index 6c261f025..d4504d586 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -1254,8 +1254,8 @@ class DeviceTestCase(TestCase): # Assign primary IPs for filtering ipaddresses = ( - IPAddress(address='192.0.2.1/24', interface=interfaces[0]), - IPAddress(address='192.0.2.2/24', interface=interfaces[1]), + IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]), + IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]), ) IPAddress.objects.bulk_create(ipaddresses) Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0]) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 6aad18bd3..ba32a7643 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,5 +1,4 @@ from collections import OrderedDict -import re from django.conf import settings from django.contrib import messages @@ -25,8 +24,9 @@ from utilities.paginator import EnhancedPaginator from utilities.permissions import get_permission_for_model from utilities.utils import csv_format from utilities.views import ( - BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin, - ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectPermissionRequiredMixin, + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView, + GetReturnURLMixin, ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + ObjectPermissionRequiredMixin, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -41,58 +41,6 @@ from .models import ( ) -class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): - """ - An extendable view for renaming device components in bulk. - """ - queryset = None - form = None - template_name = 'dcim/bulk_rename.html' - - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'change') - - def post(self, request): - - if '_preview' in request.POST or '_apply' in request.POST: - form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')}) - selected_objects = self.queryset.filter(pk__in=form.initial['pk']) - - if form.is_valid(): - for obj in selected_objects: - find = form.cleaned_data['find'] - replace = form.cleaned_data['replace'] - if form.cleaned_data['use_regex']: - try: - obj.new_name = re.sub(find, replace, obj.name) - # Catch regex group reference errors - except re.error: - obj.new_name = obj.name - else: - obj.new_name = obj.name.replace(find, replace) - - if '_apply' in request.POST: - for obj in selected_objects: - obj.name = obj.new_name - obj.save() - messages.success(request, "Renamed {} {}".format( - len(selected_objects), - self.queryset.model._meta.verbose_name_plural - )) - return redirect(self.get_return_url(request)) - - else: - form = self.form(initial={'pk': request.POST.getlist('pk')}) - selected_objects = self.queryset.filter(pk__in=form.initial['pk']) - - return render(request, self.template_name, { - 'form': form, - 'obj_type_plural': self.queryset.model._meta.verbose_name_plural, - 'selected_objects': selected_objects, - 'return_url': self.get_return_url(request), - }) - - class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ An extendable view for disconnection console/power/interface components in bulk. diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e92006096..d7f70f113 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,5 +1,7 @@ from collections import OrderedDict +from django.contrib.contenttypes.models import ContentType +from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from rest_framework.reverse import reverse from rest_framework.validators import UniqueTogetherValidator @@ -9,10 +11,12 @@ from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.choices import * +from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import ( - ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, + ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, + get_serializer_for_model, ) from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from .nested_serializers import * @@ -228,18 +232,31 @@ class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=IPAddressStatusChoices, required=False) role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False) - interface = IPAddressInterfaceSerializer(required=False, allow_null=True) + assigned_object_type = ContentTypeField( + queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS), + required=False + ) + assigned_object = serializers.SerializerMethodField(read_only=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_outside = NestedIPAddressSerializer(read_only=True) class Meta: model = IPAddress fields = [ - 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'nat_inside', - 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id', + 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', ] read_only_fields = ['family'] + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_assigned_object(self, obj): + if obj.assigned_object is None: + return None + serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.assigned_object, context=context).data + class AvailableIPSerializer(serializers.Serializer): """ diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 60bfade24..0f84ee772 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -233,8 +233,7 @@ class PrefixViewSet(CustomFieldModelViewSet): class IPAddressViewSet(CustomFieldModelViewSet): queryset = IPAddress.objects.prefetch_related( - 'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine', - 'nat_outside', 'tags', + 'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', ) serializer_class = serializers.IPAddressSerializer filterset_class = filters.IPAddressFilterSet diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 41075e54a..1ad355aec 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -1,3 +1,5 @@ +from django.db.models import Q + from .choices import IPAddressRoleChoices # BGP ASN bounds @@ -29,6 +31,11 @@ PREFIX_LENGTH_MAX = 127 # IPv6 # IPAddresses # +IPADDRESS_ASSIGNMENT_MODELS = Q( + Q(app_label='dcim', model='interface') | + Q(app_label='virtualization', model='vminterface') +) + IPADDRESS_MASK_LENGTH_MIN = 1 IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6 diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 7662d5825..d6561ceb7 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -11,7 +11,7 @@ from utilities.filters import ( BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter, ) -from virtualization.models import VirtualMachine +from virtualization.models import VirtualMachine, VMInterface from .choices import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF @@ -309,27 +309,38 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, field_name='pk', label='Device (ID)', ) - virtual_machine_id = django_filters.ModelMultipleChoiceFilter( - field_name='interface__virtual_machine', - queryset=VirtualMachine.objects.unrestricted(), - label='Virtual machine (ID)', - ) - virtual_machine = django_filters.ModelMultipleChoiceFilter( - field_name='interface__virtual_machine__name', - queryset=VirtualMachine.objects.unrestricted(), - to_field_name='name', + virtual_machine = MultiValueCharFilter( + method='filter_virtual_machine', + field_name='name', label='Virtual machine (name)', ) + virtual_machine_id = MultiValueNumberFilter( + method='filter_virtual_machine', + field_name='pk', + label='Virtual machine (ID)', + ) interface = django_filters.ModelMultipleChoiceFilter( field_name='interface__name', queryset=Interface.objects.unrestricted(), to_field_name='name', - label='Interface (ID)', + label='Interface (name)', ) interface_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface', queryset=Interface.objects.unrestricted(), label='Interface (ID)', ) + vminterface = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface__name', + queryset=VMInterface.objects.unrestricted(), + to_field_name='name', + label='VM interface (name)', + ) + vminterface_id = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface', + queryset=VMInterface.objects.unrestricted(), + label='VM interface (ID)', + ) assigned_to_interface = django_filters.BooleanFilter( method='_assigned_to_interface', label='Is assigned to an interface', @@ -379,17 +390,29 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, return queryset.filter(address__net_mask_length=value) def filter_device(self, queryset, name, value): - try: - devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value}) - vc_interface_ids = [] - for device in devices: - vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')]) - return queryset.filter(interface_id__in=vc_interface_ids) - except Device.DoesNotExist: + devices = Device.objects.filter(**{'{}__in'.format(name): value}) + if not devices.exists(): return queryset.none() + interface_ids = [] + for device in devices: + interface_ids.extend(device.vc_interfaces.values_list('id', flat=True)) + return queryset.filter( + interface__in=interface_ids + ) + + def filter_virtual_machine(self, queryset, name, value): + virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value}) + if not virtual_machines.exists(): + return queryset.none() + interface_ids = [] + for vm in virtual_machines: + interface_ids.extend(vm.interfaces.values_list('id', flat=True)) + return queryset.filter( + vminterface__in=interface_ids + ) def _assigned_to_interface(self, queryset, name, value): - return queryset.exclude(interface__isnull=value) + return queryset.exclude(assigned_object_id__isnull=value) class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index b332bf33f..596d353bd 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -14,7 +14,7 @@ from utilities.forms import ( ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) -from virtualization.models import VirtualMachine +from virtualization.models import VirtualMachine, VMInterface from .choices import * from .constants import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF @@ -522,10 +522,33 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) # class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm): - interface = forms.ModelChoiceField( + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + widget=APISelect( + filter_for={ + 'interface': 'device_id' + } + ) + ) + interface = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False ) + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + widget=APISelect( + filter_for={ + 'vminterface': 'virtual_machine_id' + } + ) + ) + vminterface = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + label='Interface' + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -597,8 +620,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'interface', 'primary_for_parent', - 'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags', + 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack', + 'nat_inside', 'tenant_group', 'tenant', 'tags', ] widgets = { 'status': StaticSelect2(), @@ -610,32 +633,26 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel # Initialize helper selectors instance = kwargs.get('instance') initial = kwargs.get('initial', {}).copy() - if instance and instance.nat_inside and instance.nat_inside.device is not None: - initial['nat_site'] = instance.nat_inside.device.site - initial['nat_rack'] = instance.nat_inside.device.rack - initial['nat_device'] = instance.nat_inside.device + if instance: + if type(instance.assigned_object) is Interface: + initial['device'] = instance.assigned_object.device + initial['interface'] = instance.assigned_object + elif type(instance.assigned_object) is VMInterface: + initial['virtual_machine'] = instance.assigned_object.virtual_machine + initial['vminterface'] = instance.assigned_object + if instance.nat_inside and instance.nat_inside.device is not None: + initial['nat_site'] = instance.nat_inside.device.site + initial['nat_rack'] = instance.nat_inside.device.rack + initial['nat_device'] = instance.nat_inside.device kwargs['initial'] = initial super().__init__(*args, **kwargs) self.fields['vrf'].empty_label = 'Global' - # Limit interface selections to those belonging to the parent device/VM - if self.instance and self.instance.interface: - self.fields['interface'].queryset = Interface.objects.filter( - device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine - ).prefetch_related( - 'device__primary_ip4', - 'device__primary_ip6', - 'virtual_machine__primary_ip4', - 'virtual_machine__primary_ip6', - ) # We prefetch the primary address fields to ensure cache invalidation does not balk on the save() - else: - self.fields['interface'].choices = [] - # Initialize primary_for_parent if IP address is already assigned - if self.instance.pk and self.instance.interface is not None: - parent = self.instance.interface.parent + if self.instance.pk and self.instance.assigned_object: + parent = self.instance.assigned_object.parent if ( self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk @@ -645,32 +662,39 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel def clean(self): super().clean() + # Cannot select both a device interface and a VM interface + if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'): + raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface") + # Primary IP assignment is only available if an interface has been assigned. - if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'): + interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') + if self.cleaned_data.get('primary_for_parent') and not interface: self.add_error( 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs." ) def save(self, *args, **kwargs): + # Set assigned object + interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') + if interface: + self.instance.assigned_object = interface + ipaddress = super().save(*args, **kwargs) # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. - if self.cleaned_data['primary_for_parent']: - parent = self.cleaned_data['interface'].parent + if interface and self.cleaned_data['primary_for_parent']: if ipaddress.address.version == 4: - parent.primary_ip4 = ipaddress + interface.parent.primary_ip4 = ipaddress else: - parent.primary_ip6 = ipaddress - parent.save() - elif self.cleaned_data['interface']: - parent = self.cleaned_data['interface'].parent - if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress: - parent.primary_ip4 = None - parent.save() - elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress: - parent.primary_ip6 = None - parent.save() + interface.primary_ip6 = ipaddress + interface.parent.save() + elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress: + interface.parent.primary_ip4 = None + interface.parent.save() + elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress: + interface.parent.primary_ip4 = None + interface.parent.save() return ipaddress @@ -742,7 +766,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): help_text='Parent VM of assigned interface (if any)' ) interface = CSVModelChoiceField( - queryset=Interface.objects.all(), + queryset=Interface.objects.none(), # Can also refer to VMInterface required=False, to_field_name='name', help_text='Assigned interface' @@ -761,21 +785,17 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): if data: - # Limit interface queryset by assigned device or virtual machine + # Limit interface queryset by assigned device if data.get('device'): - params = { - f"device__{self.fields['device'].to_field_name}": data.get('device') - } + self.fields['interface'].queryset = Interface.objects.filter( + **{f"device__{self.fields['device'].to_field_name}": data['device']} + ) + + # Limit interface queryset by assigned device elif data.get('virtual_machine'): - params = { - f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data.get('virtual_machine') - } - else: - params = { - 'device': None, - 'virtual_machine': None, - } - self.fields['interface'].queryset = self.fields['interface'].queryset.filter(**params) + self.fields['interface'].queryset = VMInterface.objects.filter( + **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']} + ) def clean(self): super().clean() @@ -790,6 +810,10 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): def save(self, *args, **kwargs): + # Set interface assignment + if self.cleaned_data['interface']: + self.instance.assigned_object = self.cleaned_data['interface'] + ipaddress = super().save(*args, **kwargs) # Set as primary for device/VM @@ -1194,13 +1218,12 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm): # Limit IP address choices to those assigned to interfaces of the parent device/VM if self.instance.device: - vc_interface_ids = [i['id'] for i in self.instance.device.vc_interfaces.values('id')] self.fields['ipaddresses'].queryset = IPAddress.objects.filter( - interface_id__in=vc_interface_ids + interface__in=self.instance.device.vc_interfaces.values_list('id', flat=True) ) elif self.instance.virtual_machine: self.fields['ipaddresses'].queryset = IPAddress.objects.filter( - interface__virtual_machine=self.instance.virtual_machine + vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True) ) else: self.fields['ipaddresses'].choices = [] diff --git a/netbox/ipam/migrations/0037_ipaddress_assignment.py b/netbox/ipam/migrations/0037_ipaddress_assignment.py new file mode 100644 index 000000000..6139d41d6 --- /dev/null +++ b/netbox/ipam/migrations/0037_ipaddress_assignment.py @@ -0,0 +1,40 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def set_assigned_object_type(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + IPAddress = apps.get_model('ipam', 'IPAddress') + + device_ct = ContentType.objects.get(app_label='dcim', model='interface').pk + IPAddress.objects.update(assigned_object_type=device_ct) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('ipam', '0036_standardize_description'), + ] + + operations = [ + migrations.RenameField( + model_name='ipaddress', + old_name='interface', + new_name='assigned_object_id', + ), + migrations.AlterField( + model_name='ipaddress', + name='assigned_object_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='ipaddress', + name='assigned_object_type', + field=models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType'), + preserve_default=False, + ), + migrations.RunPython( + code=set_assigned_object_type + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index b99a6c919..ff564de6e 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,10 +1,11 @@ import netaddr from django.conf import settings -from django.contrib.contenttypes.fields import GenericRelation -from django.core.exceptions import ValidationError, ObjectDoesNotExist +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import F, Q +from django.db.models import F from django.urls import reverse from taggit.managers import TaggableManager @@ -14,7 +15,7 @@ from extras.utils import extras_features from utilities.models import ChangeLoggedModel from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object -from virtualization.models import VirtualMachine +from virtualization.models import VirtualMachine, VMInterface from .choices import * from .constants import * from .fields import IPNetworkField, IPAddressField @@ -606,13 +607,22 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): blank=True, help_text='The functional role of this IP' ) - interface = models.ForeignKey( - to='dcim.Interface', - on_delete=models.CASCADE, - related_name='ip_addresses', + assigned_object_type = models.ForeignKey( + to=ContentType, + limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS, + on_delete=models.PROTECT, + related_name='+', blank=True, null=True ) + assigned_object_id = models.PositiveIntegerField( + blank=True, + null=True + ) + assigned_object = GenericForeignKey( + ct_field='assigned_object_type', + fk_field='assigned_object_id' + ) nat_inside = models.OneToOneField( to='self', on_delete=models.SET_NULL, @@ -643,11 +653,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): objects = IPAddressManager() csv_headers = [ - 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', + 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id', 'is_primary', 'dns_name', 'description', ] clone_fields = [ - 'vrf', 'tenant', 'status', 'role', 'description', 'interface', + 'vrf', 'tenant', 'status', 'role', 'description', ] STATUS_CLASS_MAP = { @@ -707,32 +717,31 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): ) }) - if self.pk: - - # Check for primary IP assignment that doesn't match the assigned device/VM + # Check for primary IP assignment that doesn't match the assigned device/VM + if self.pk and type(self.assigned_object) is Interface: device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() if device: - if self.interface is None: + if self.assigned_object is None: raise ValidationError({ - 'interface': "IP address is primary for device {} but not assigned".format(device) + 'interface': f"IP address is primary for device {device} but not assigned to an interface" }) - elif (device.primary_ip4 == self or device.primary_ip6 == self) and self.interface.device != device: + elif self.assigned_object.device != device: raise ValidationError({ - 'interface': "IP address is primary for device {} but assigned to {} ({})".format( - device, self.interface.device, self.interface - ) + 'interface': f"IP address is primary for device {device} but assigned to " + f"{self.assigned_object.device} ({self.assigned_object})" }) + elif self.pk and type(self.assigned_object) is VMInterface: vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() if vm: - if self.interface is None: + if self.assigned_object is None: raise ValidationError({ - 'interface': "IP address is primary for virtual machine {} but not assigned".format(vm) + 'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an " + f"interface" }) - elif (vm.primary_ip4 == self or vm.primary_ip6 == self) and self.interface.virtual_machine != vm: + elif self.interface.virtual_machine != vm: raise ValidationError({ - 'interface': "IP address is primary for virtual machine {} but assigned to {} ({})".format( - vm, self.interface.virtual_machine, self.interface - ) + 'vminterface': f"IP address is primary for virtual machine {vm} but assigned to " + f"{self.assigned_object.virtual_machine} ({self.assigned_object})" }) def save(self, *args, **kwargs): @@ -743,29 +752,27 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): super().save(*args, **kwargs) def to_objectchange(self, action): - # Annotate the assigned Interface (if any) - try: - parent_obj = self.interface - except ObjectDoesNotExist: - parent_obj = None - + # Annotate the assigned object, if any return ObjectChange( changed_object=self, object_repr=str(self), action=action, - related_object=parent_obj, + related_object=self.assigned_object, object_data=serialize_object(self) ) def to_csv(self): # Determine if this IP is primary for a Device + is_primary = False if self.address.version == 4 and getattr(self, 'primary_ip4_for', False): is_primary = True elif self.address.version == 6 and getattr(self, 'primary_ip6_for', False): is_primary = True - else: - is_primary = False + + obj_type = None + if self.assigned_object_type: + obj_type = f'{self.assigned_object_type.app_label}.{self.assigned_object_type.model}' return ( self.address, @@ -773,9 +780,8 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): self.tenant.name if self.tenant else None, self.get_status_display(), self.get_role_display(), - self.device.identifier if self.device else None, - self.virtual_machine.name if self.virtual_machine else None, - self.interface.name if self.interface else None, + obj_type, + self.assigned_object_id, is_primary, self.dns_name, self.description, @@ -796,18 +802,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): self.address.prefixlen = value mask_length = property(fset=_set_mask_length) - @property - def device(self): - if self.interface: - return self.interface.device - return None - - @property - def virtual_machine(self): - if self.interface: - return self.interface.virtual_machine - return None - def get_status_class(self): return self.STATUS_CLASS_MAP.get(self.status) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index ca48c2951..f1855327b 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -92,14 +92,6 @@ IPADDRESS_ASSIGN_LINK = """ {% endif %} """ -IPADDRESS_PARENT = """ -{% if record.interface %} - {{ record.interface.parent }} -{% else %} - — -{% endif %} -""" - VRF_LINK = """ {% if record.vrf %} {{ record.vrf }} @@ -168,7 +160,7 @@ VLAN_MEMBER_UNTAGGED = """ VLAN_MEMBER_ACTIONS = """ {% if perms.dcim.change_interface %} - + {% endif %} """ @@ -431,18 +423,14 @@ class IPAddressTable(BaseTable): tenant = tables.TemplateColumn( template_code=TENANT_LINK ) - parent = tables.TemplateColumn( - template_code=IPADDRESS_PARENT, - orderable=False - ) - interface = tables.Column( - orderable=False + assigned = tables.BooleanColumn( + accessor='assigned_object_id' ) class Meta(BaseTable.Meta): model = IPAddress fields = ( - 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description', + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', ) row_attrs = { 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', @@ -465,11 +453,11 @@ class IPAddressDetailTable(IPAddressTable): class Meta(IPAddressTable.Meta): fields = ( - 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'dns_name', + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description', 'tags', ) default_columns = ( - 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description', + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', ) @@ -481,17 +469,13 @@ class IPAddressAssignTable(BaseTable): status = tables.TemplateColumn( template_code=STATUS_LABEL ) - parent = tables.TemplateColumn( - template_code=IPADDRESS_PARENT, - orderable=False - ) - interface = tables.Column( + assigned_object = tables.Column( orderable=False ) class Meta(BaseTable.Meta): model = IPAddress - fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description') + fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'description') orderable = False diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 785f5f2c5..560313f0a 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -4,7 +4,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, from ipam.choices import * from ipam.filters import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from virtualization.models import Cluster, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterType, VirtualMachine, VMInterface from tenancy.models import Tenant, TenantGroup @@ -375,6 +375,13 @@ class IPAddressTestCase(TestCase): ) Device.objects.bulk_create(devices) + interfaces = ( + Interface(device=devices[0], name='Interface 1'), + Interface(device=devices[1], name='Interface 2'), + Interface(device=devices[2], name='Interface 3'), + ) + Interface.objects.bulk_create(interfaces) + clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') cluster = Cluster.objects.create(type=clustertype, name='Cluster 1') @@ -385,15 +392,12 @@ class IPAddressTestCase(TestCase): ) VirtualMachine.objects.bulk_create(virtual_machines) - interfaces = ( - Interface(device=devices[0], name='Interface 1'), - Interface(device=devices[1], name='Interface 2'), - Interface(device=devices[2], name='Interface 3'), - Interface(virtual_machine=virtual_machines[0], name='Interface 1'), - Interface(virtual_machine=virtual_machines[1], name='Interface 2'), - Interface(virtual_machine=virtual_machines[2], name='Interface 3'), + vminterfaces = ( + VMInterface(virtual_machine=virtual_machines[0], name='Interface 1'), + VMInterface(virtual_machine=virtual_machines[1], name='Interface 2'), + VMInterface(virtual_machine=virtual_machines[2], name='Interface 3'), ) - Interface.objects.bulk_create(interfaces) + VMInterface.objects.bulk_create(vminterfaces) tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), @@ -411,16 +415,16 @@ class IPAddressTestCase(TestCase): Tenant.objects.bulk_create(tenants) ipaddresses = ( - IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'), - IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), - IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), - IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), - IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE), - IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'), - IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[3], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), - IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[4], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), - IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[5], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), - IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE), + IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'), + IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], assigned_object=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), + IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], assigned_object=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), + IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], assigned_object=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), + IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE), + IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'), + IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], assigned_object=vminterfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), + IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], assigned_object=vminterfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), + IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], assigned_object=vminterfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), + IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE), ) IPAddress.objects.bulk_create(ipaddresses) @@ -487,7 +491,14 @@ class IPAddressTestCase(TestCase): params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'interface': ['Interface 1', 'Interface 2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_vminterface(self): + vminterfaces = VMInterface.objects.all()[:2] + params = {'vminterface_id': [vminterfaces[0].pk, vminterfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'vminterface': ['Interface 1', 'Interface 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_assigned_to_interface(self): params = {'assigned_to_interface': 'true'} diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 06090e768..eb7f05e8f 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -236,7 +236,6 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tenant': None, 'status': IPAddressStatusChoices.STATUS_RESERVED, 'role': IPAddressRoleChoices.ROLE_ANYCAST, - 'interface': None, 'nat_inside': None, 'dns_name': 'example', 'description': 'A new IP address', diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 0447960c2..808a50ae1 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,6 +1,6 @@ import netaddr from django.conf import settings -from django.db.models import Count, Q +from django.db.models import Count from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render from django_tables2 import RequestConfig @@ -11,7 +11,7 @@ from utilities.views import ( BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ) -from virtualization.models import VirtualMachine +from virtualization.models import VirtualMachine, VMInterface from . import filters, forms, tables from .choices import * from .constants import * @@ -517,7 +517,7 @@ class PrefixIPAddressesView(ObjectView): # Find all IPAddresses belonging to this Prefix ipaddresses = prefix.get_child_ips().restrict(request.user, 'view').prefetch_related( - 'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for' + 'vrf', 'primary_ip4_for', 'primary_ip6_for' ) # Add available IP addresses to the table if requested @@ -593,7 +593,7 @@ class PrefixBulkDeleteView(BulkDeleteView): class IPAddressListView(ObjectListView): queryset = IPAddress.objects.prefetch_related( - 'vrf__tenant', 'tenant', 'nat_inside', 'interface__device', 'interface__virtual_machine' + 'vrf__tenant', 'tenant', 'nat_inside' ) filterset = filters.IPAddressFilterSet filterset_form = forms.IPAddressFilterForm @@ -622,7 +622,7 @@ class IPAddressView(ObjectView): ).exclude( pk=ipaddress.pk ).prefetch_related( - 'nat_inside', 'interface__device' + 'nat_inside' ) # Exclude anycast IPs if this IP is anycast if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST: @@ -630,9 +630,7 @@ class IPAddressView(ObjectView): duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False) # Related IP table - related_ips = IPAddress.objects.restrict(request.user, 'view').prefetch_related( - 'interface__device' - ).exclude( + related_ips = IPAddress.objects.restrict(request.user, 'view').exclude( address=str(ipaddress.address) ).filter( vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address) @@ -661,13 +659,18 @@ class IPAddressEditView(ObjectEditView): def alter_obj(self, obj, request, url_args, url_kwargs): - interface_id = request.GET.get('interface') - if interface_id: + if 'interface' in request.GET: try: - obj.interface = Interface.objects.get(pk=interface_id) + obj.assigned_object = Interface.objects.get(pk=request.GET['interface']) except (ValueError, Interface.DoesNotExist): pass + elif 'vminterface' in request.GET: + try: + obj.assigned_object = VMInterface.objects.get(pk=request.GET['vminterface']) + except (ValueError, VMInterface.DoesNotExist): + pass + return obj @@ -699,9 +702,7 @@ class IPAddressAssignView(ObjectView): if form.is_valid(): - addresses = self.queryset.prefetch_related( - 'vrf', 'tenant', 'interface__device', 'interface__virtual_machine' - ) + addresses = self.queryset.prefetch_related('vrf', 'tenant') # Limit to 100 results addresses = filters.IPAddressFilterSet(request.POST, addresses).qs[:100] table = tables.IPAddressAssignTable(addresses) @@ -735,7 +736,7 @@ class IPAddressBulkImportView(BulkImportView): class IPAddressBulkEditView(BulkEditView): - queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device') + queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') filterset = filters.IPAddressFilterSet table = tables.IPAddressTable form = forms.IPAddressBulkEditForm @@ -743,7 +744,7 @@ class IPAddressBulkEditView(BulkEditView): class IPAddressBulkDeleteView(BulkDeleteView): - queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device') + queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') filterset = filters.IPAddressFilterSet table = tables.IPAddressTable default_return_url = 'ipam:ipaddress_list' diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 2fe970fd7..75869db52 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -166,7 +166,7 @@ {% endif %} - + {% endif %} @@ -176,7 +176,7 @@ {% else %} - + {% endif %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 5714c8940..5165169ff 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -5,29 +5,25 @@
-