From 6cb31a274fdcdf8b93ea5f46cbfa480b4ae70801 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 13:10:56 -0400 Subject: [PATCH] Initial work on #4721 (WIP) --- .../migrations/0109_interface_remove_vm.py | 18 +++ netbox/dcim/models/__init__.py | 5 +- netbox/dcim/models/device_components.py | 128 +++++++----------- netbox/dcim/tables.py | 17 +-- netbox/dcim/views.py | 2 +- netbox/ipam/constants.py | 7 + netbox/ipam/filters.py | 62 ++++----- netbox/ipam/forms.py | 10 +- .../migrations/0037_ipaddress_assignment.py | 35 +++++ netbox/ipam/models.py | 17 ++- netbox/ipam/tables.py | 14 +- netbox/ipam/tests/test_filters.py | 22 +-- netbox/utilities/filters.py | 4 + netbox/virtualization/api/serializers.py | 8 +- netbox/virtualization/api/views.py | 4 +- netbox/virtualization/filters.py | 4 +- netbox/virtualization/forms.py | 41 +++--- .../migrations/0015_interface.py | 43 ++++++ .../migrations/0016_replicate_interfaces.py | 69 ++++++++++ netbox/virtualization/models.py | 114 +++++++++++++++- netbox/virtualization/tables.py | 3 +- netbox/virtualization/tests/test_api.py | 31 ++--- netbox/virtualization/tests/test_filters.py | 4 +- netbox/virtualization/tests/test_views.py | 16 +-- netbox/virtualization/urls.py | 1 + netbox/virtualization/views.py | 17 ++- 26 files changed, 481 insertions(+), 215 deletions(-) create mode 100644 netbox/dcim/migrations/0109_interface_remove_vm.py create mode 100644 netbox/ipam/migrations/0037_ipaddress_assignment.py create mode 100644 netbox/virtualization/migrations/0015_interface.py create mode 100644 netbox/virtualization/migrations/0016_replicate_interfaces.py 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..8f945622a 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,43 @@ 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 +667,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 +686,19 @@ class Interface(CableTermination, ComponentModel): blank=True, verbose_name='Tagged VLANs' ) + ipaddresses = GenericRelation( + to='ipam.IPAddress', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id' + ) tags = TaggableManager(through=TaggedItem) csv_headers = [ - 'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', + '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 +708,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 +721,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 +756,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 +771,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 +809,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..189f98923 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -863,6 +863,7 @@ class DeviceImportTable(BaseTable): class DeviceComponentDetailTable(BaseTable): pk = ToggleColumn() + device = tables.LinkColumn() name = tables.Column(order_by=('_name',)) cable = tables.LinkColumn() @@ -881,7 +882,6 @@ class ConsolePortTable(BaseTable): class ConsolePortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta): pass @@ -896,7 +896,6 @@ class ConsoleServerPortTable(BaseTable): class ConsoleServerPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta): pass @@ -911,7 +910,6 @@ class PowerPortTable(BaseTable): class PowerPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta): pass @@ -926,7 +924,6 @@ class PowerOutletTable(BaseTable): class PowerOutletDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta): pass @@ -940,14 +937,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 +954,6 @@ class FrontPortTable(BaseTable): class FrontPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta): pass @@ -976,7 +969,6 @@ class RearPortTable(BaseTable): class RearPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta): pass @@ -991,7 +983,6 @@ class DeviceBayTable(BaseTable): class DeviceBayDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() installed_device = tables.LinkColumn() class Meta(DeviceBayTable.Meta): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 6aad18bd3..9b19734e6 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1442,7 +1442,7 @@ class InterfaceView(ObjectView): # Get assigned IP addresses ipaddress_table = InterfaceIPAddressTable( - data=interface.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), + data=interface.ipaddresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), orderable=False ) diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 41075e54a..0a3c67f32 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='interface') +) + 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..15be58ad4 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -299,37 +299,37 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, to_field_name='rd', label='VRF (RD)', ) - device = MultiValueCharFilter( - method='filter_device', - field_name='name', - label='Device (name)', - ) - device_id = MultiValueNumberFilter( - method='filter_device', - 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', - label='Virtual machine (name)', - ) - interface = django_filters.ModelMultipleChoiceFilter( - field_name='interface__name', - queryset=Interface.objects.unrestricted(), - to_field_name='name', - label='Interface (ID)', - ) - interface_id = django_filters.ModelMultipleChoiceFilter( - queryset=Interface.objects.unrestricted(), - label='Interface (ID)', - ) + # device = MultiValueCharFilter( + # method='filter_device', + # field_name='name', + # label='Device (name)', + # ) + # device_id = MultiValueNumberFilter( + # method='filter_device', + # 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', + # label='Virtual machine (name)', + # ) + # interface = django_filters.ModelMultipleChoiceFilter( + # field_name='interface__name', + # queryset=Interface.objects.unrestricted(), + # to_field_name='name', + # label='Interface (ID)', + # ) + # interface_id = django_filters.ModelMultipleChoiceFilter( + # queryset=Interface.objects.unrestricted(), + # label='Interface (ID)', + # ) assigned_to_interface = django_filters.BooleanFilter( method='_assigned_to_interface', label='Is assigned to an interface', diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index b332bf33f..620638703 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.core.validators import MaxValueValidator, MinValueValidator from dcim.models import Device, Interface, Rack, Region, Site @@ -14,7 +15,7 @@ from utilities.forms import ( ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) -from virtualization.models import VirtualMachine +from virtualization.models import Interface as VMInterface, VirtualMachine from .choices import * from .constants import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF @@ -1194,13 +1195,14 @@ 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 + assigned_object_type=ContentType.objects.get_for_model(Interface), + assigned_object_id__in=self.instance.device.vc_interfaces.values('id', flat=True) ) elif self.instance.virtual_machine: self.fields['ipaddresses'].queryset = IPAddress.objects.filter( - interface__virtual_machine=self.instance.virtual_machine + assigned_object_type=ContentType.objects.get_for_model(VMInterface), + assigned_object_id__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..4586a5088 --- /dev/null +++ b/netbox/ipam/migrations/0037_ipaddress_assignment.py @@ -0,0 +1,35 @@ +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.AddField( + model_name='ipaddress', + name='assigned_object_type', + field=models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'virtualization'), ('model', 'interface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType', blank=True, null=True), + preserve_default=False, + ), + migrations.RunPython( + code=set_assigned_object_type + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index b99a6c919..ba7c959dd 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,6 +1,7 @@ import netaddr from django.conf import settings -from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -606,13 +607,25 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): blank=True, help_text='The functional role of this IP' ) - interface = models.ForeignKey( + 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.ForeignKey( to='dcim.Interface', on_delete=models.CASCADE, related_name='ip_addresses', 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, diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index ca48c2951..989fe0844 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -431,18 +431,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 +461,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', ) diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 785f5f2c5..24d0d7fa8 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, Interfaces as VMInterface, VirtualMachine 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'), + vm_interfaces = ( + 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(vm_interfaces) tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index f628ca917..2b49dd99e 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -256,6 +256,10 @@ class BaseFilterSet(django_filters.FilterSet): except django_filters.exceptions.FieldLookupError: # The filter could not be created because the lookup expression is not supported on the field continue + except Exception as e: + print(existing_filter_name, existing_filter) + print(f'field: {field}, lookup_expr: {lookup_expr}') + raise e if lookup_name.startswith('n'): # This is a negation filter which requires a queryset.exclude() clause diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 008c6dd88..a437a000c 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -3,7 +3,6 @@ from rest_framework import serializers from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.choices import InterfaceModeChoices -from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer @@ -11,7 +10,7 @@ from ipam.models import VLAN from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer from virtualization.choices import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine from .nested_serializers import * @@ -97,7 +96,6 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer): virtual_machine = NestedVirtualMachineSerializer() - type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( @@ -110,6 +108,6 @@ class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class Meta: model = Interface fields = [ - 'id', 'virtual_machine', 'name', 'type', 'enabled', 'mtu', 'mac_address', 'description', 'mode', - 'untagged_vlan', 'tagged_vlans', 'tags', + 'id', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', + 'tagged_vlans', 'tags', ] diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 2a1d7c3a9..bcff543a8 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,11 +1,11 @@ from django.db.models import Count -from dcim.models import Device, Interface +from dcim.models import Device from extras.api.views import CustomFieldModelViewSet from utilities.api import ModelViewSet from utilities.utils import get_subquery from virtualization import filters -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine from . import serializers diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 7e8349cf1..dd1c3e4b2 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from dcim.models import DeviceRole, Interface, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( @@ -9,7 +9,7 @@ from utilities.filters import ( TreeNodeMultipleChoiceFilter, ) from .choices import * -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine __all__ = ( 'ClusterFilterSet', diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 942368f19..5789dff88 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -1,10 +1,11 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.forms import INTERFACE_MODE_HELP_TEXT -from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site +from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, ) @@ -16,10 +17,10 @@ from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, - StaticSelect2, StaticSelect2Multiple, TagFilterField, + StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from .choices import * -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine # @@ -355,8 +356,11 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): for family in [4, 6]: ip_choices = [(None, '---------')] # Collect interface IPs + interface_pks = self.instance.interfaces.values_list('id', flat=True) interface_ips = IPAddress.objects.prefetch_related('interface').filter( - address__family=family, interface__virtual_machine=self.instance + address__family=family, + assigned_object_type=ContentType.objects.get_for_model(Interface), + assigned_object_id__in=interface_pks ) if interface_ips: ip_choices.append( @@ -600,12 +604,11 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface fields = [ - 'virtual_machine', 'name', 'type', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', - 'untagged_vlan', 'tagged_vlans', + 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'untagged_vlan', + 'tagged_vlans', ] widgets = { 'virtual_machine': forms.HiddenInput(), - 'type': forms.HiddenInput(), 'mode': StaticSelect2() } labels = { @@ -619,7 +622,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): super().__init__(*args, **kwargs) # Add current site to VLANs query params - site = getattr(self.instance.parent, 'site', None) + site = getattr(self.instance.virtual_machine, 'site', None) if site is not None: # Add current site to VLANs query params self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk) @@ -650,11 +653,6 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) - type = forms.ChoiceField( - choices=VMInterfaceTypeChoices, - initial=VMInterfaceTypeChoices.TYPE_VIRTUAL, - widget=forms.HiddenInput() - ) enabled = forms.BooleanField( required=False, initial=True @@ -789,6 +787,17 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk) +class InterfaceFilterForm(forms.Form): + model = Interface + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + # # Bulk VirtualMachine component creation # @@ -812,8 +821,4 @@ class InterfaceBulkCreateForm( form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']), VirtualMachineBulkAddComponentForm ): - type = forms.ChoiceField( - choices=VMInterfaceTypeChoices, - initial=VMInterfaceTypeChoices.TYPE_VIRTUAL, - widget=forms.HiddenInput() - ) + pass diff --git a/netbox/virtualization/migrations/0015_interface.py b/netbox/virtualization/migrations/0015_interface.py new file mode 100644 index 000000000..7ad22eeb8 --- /dev/null +++ b/netbox/virtualization/migrations/0015_interface.py @@ -0,0 +1,43 @@ +# Generated by Django 3.0.6 on 2020-06-18 20:21 + +import dcim.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.fields +import utilities.ordering +import utilities.query_functions + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0036_standardize_description'), + ('extras', '0042_customfield_manager'), + ('virtualization', '0014_standardize_description'), + ] + + operations = [ + migrations.CreateModel( + name='Interface', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface)), + ('enabled', models.BooleanField(default=True)), + ('mac_address', dcim.fields.MACAddressField(blank=True, null=True)), + ('mtu', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65536)])), + ('mode', models.CharField(blank=True, max_length=50)), + ('description', models.CharField(blank=True, max_length=200)), + ('tagged_vlans', models.ManyToManyField(blank=True, related_name='vm_interfaces_as_tagged', to='ipam.VLAN')), + ('tags', taggit.managers.TaggableManager(related_name='vm_interface', through='extras.TaggedItem', to='extras.Tag')), + ('untagged_vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vm_interfaces_as_untagged', to='ipam.VLAN')), + ('virtual_machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine')), + ], + options={ + 'ordering': ('virtual_machine', utilities.query_functions.CollateAsChar('_name')), + 'unique_together': {('virtual_machine', 'name')}, + }, + ), + ] diff --git a/netbox/virtualization/migrations/0016_replicate_interfaces.py b/netbox/virtualization/migrations/0016_replicate_interfaces.py new file mode 100644 index 000000000..c259b4140 --- /dev/null +++ b/netbox/virtualization/migrations/0016_replicate_interfaces.py @@ -0,0 +1,69 @@ +import sys + +from django.db import migrations + + +def replicate_interfaces(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + TaggedItem = apps.get_model('taggit', 'TaggedItem') + Interface = apps.get_model('dcim', 'Interface') + IPAddress = apps.get_model('ipam', 'IPAddress') + VMInterface = apps.get_model('virtualization', 'Interface') + + interface_ct = ContentType.objects.get_for_model(Interface) + vm_interface_ct = ContentType.objects.get_for_model(VMInterface) + + # Replicate dcim.Interface instances assigned to VirtualMachines + original_interfaces = Interface.objects.filter(virtual_machine__isnull=False) + for interface in original_interfaces: + vm_interface = VMInterface( + virtual_machine=interface.virtual_machine, + name=interface.name, + enabled=interface.enabled, + mac_address=interface.mac_address, + mtu=interface.mtu, + mode=interface.mode, + description=interface.description, + untagged_vlan=interface.untagged_vlan, + ) + vm_interface.save() + + # Copy tagged VLANs + vm_interface.tagged_vlans.set(interface.tagged_vlans.all()) + + # Reassign tags to the new instance + TaggedItem.objects.filter( + content_type=interface_ct, object_id=interface.pk + ).update( + content_type=vm_interface_ct, object_id=vm_interface.pk + ) + + # Update any assigned IPAddresses + IPAddress.objects.filter(assigned_object_id=interface.pk).update( + assigned_object_type=vm_interface_ct, + assigned_object_id=vm_interface.pk + ) + + replicated_count = VMInterface.objects.count() + if 'test' not in sys.argv: + print(f"\n Replicated {replicated_count} interfaces ", end='', flush=True) + + # Verify that all interfaces have been replicated + assert replicated_count == original_interfaces.count(), "Replicated interfaces count does not match original count!" + + # Delete original VM interfaces + original_interfaces.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0037_ipaddress_assignment'), + ('virtualization', '0015_interface'), + ] + + operations = [ + migrations.RunPython( + code=replicate_interfaces + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 8ad40bab7..8d4d5d889 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -5,11 +5,14 @@ from django.db import models from django.urls import reverse from taggit.managers import TaggableManager -from dcim.models import Device -from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem +from dcim.choices import InterfaceModeChoices +from dcim.models import BaseInterface, Device +from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features from utilities.models import ChangeLoggedModel +from utilities.query_functions import CollateAsChar from utilities.querysets import RestrictedQuerySet +from utilities.utils import serialize_object from .choices import * @@ -17,6 +20,7 @@ __all__ = ( 'Cluster', 'ClusterGroup', 'ClusterType', + 'Interface', 'VirtualMachine', ) @@ -370,3 +374,109 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): @property def site(self): return self.cluster.site + + +# +# Interfaces +# + +@extras_features('graphs', 'export_templates', 'webhooks') +class Interface(BaseInterface): + virtual_machine = models.ForeignKey( + to='virtualization.VirtualMachine', + on_delete=models.CASCADE, + related_name='interfaces' + ) + description = models.CharField( + max_length=200, + blank=True + ) + untagged_vlan = models.ForeignKey( + to='ipam.VLAN', + on_delete=models.SET_NULL, + related_name='vm_interfaces_as_untagged', + null=True, + blank=True, + verbose_name='Untagged VLAN' + ) + tagged_vlans = models.ManyToManyField( + to='ipam.VLAN', + related_name='vm_interfaces_as_tagged', + blank=True, + verbose_name='Tagged VLANs' + ) + ipaddresses = GenericRelation( + to='ipam.IPAddress', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id' + ) + tags = TaggableManager( + through=TaggedItem, + related_name='vm_interface' + ) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = [ + 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + ] + + class Meta: + ordering = ('virtual_machine', CollateAsChar('_name')) + unique_together = ('virtual_machine', 'name') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('virtualization:interface', kwargs={'pk': self.pk}) + + def to_csv(self): + return ( + self.virtual_machine.name, + self.name, + self.enabled, + self.mac_address, + self.mtu, + self.description, + self.get_mode_display(), + ) + + def clean(self): + + # 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 " + "virtual machine, or it must be global".format(self.untagged_vlan) + }) + + def save(self, *args, **kwargs): + + # Remove untagged VLAN assignment for non-802.1Q interfaces + if self.mode is None: + self.untagged_vlan = None + + # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.) + if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED: + self.tagged_vlans.clear() + + return super().save(*args, **kwargs) + + def to_objectchange(self, action): + # Annotate the parent VirtualMachine + return ObjectChange( + changed_object=self, + object_repr=str(self), + action=action, + related_object=self.virtual_machine, + object_data=serialize_object(self) + ) + + @property + def parent(self): + return self.virtual_machine + + @property + def count_ipaddresses(self): + return self.ip_addresses.count() diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index d957e0053..97831a458 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,10 +1,9 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from dcim.models import Interface from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, ColoredLabelColumn, TagColumn, ToggleColumn -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine CLUSTERTYPE_ACTIONS = """ diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 6b466116e..3027211f2 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -2,11 +2,9 @@ from django.urls import reverse from rest_framework import status from dcim.choices import InterfaceModeChoices -from dcim.models import Interface from ipam.models import VLAN from utilities.testing import APITestCase, APIViewTestCases -from virtualization.choices import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine class AppTest(APITestCase): @@ -207,18 +205,15 @@ class InterfaceTest(APITestCase): self.virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1') self.interface1 = Interface.objects.create( virtual_machine=self.virtualmachine, - name='Test Interface 1', - type=InterfaceTypeChoices.TYPE_VIRTUAL + name='Test Interface 1' ) self.interface2 = Interface.objects.create( virtual_machine=self.virtualmachine, - name='Test Interface 2', - type=InterfaceTypeChoices.TYPE_VIRTUAL + name='Test Interface 2' ) self.interface3 = Interface.objects.create( virtual_machine=self.virtualmachine, - name='Test Interface 3', - type=InterfaceTypeChoices.TYPE_VIRTUAL + name='Test Interface 3' ) self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1) @@ -227,21 +222,21 @@ class InterfaceTest(APITestCase): def test_get_interface(self): url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('dcim.view_interface') + self.add_permissions('virtualization.view_interface') response = self.client.get(url, **self.header) self.assertEqual(response.data['name'], self.interface1.name) def test_list_interfaces(self): url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.view_interface') + self.add_permissions('virtualization.view_interface') response = self.client.get(url, **self.header) self.assertEqual(response.data['count'], 3) def test_list_interfaces_brief(self): url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.view_interface') + self.add_permissions('virtualization.view_interface') response = self.client.get('{}?brief=1'.format(url), **self.header) self.assertEqual( @@ -255,7 +250,7 @@ class InterfaceTest(APITestCase): 'name': 'Test Interface 4', } url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.add_interface') + self.add_permissions('virtualization.add_interface') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -273,7 +268,7 @@ class InterfaceTest(APITestCase): 'tagged_vlans': [self.vlan1.id, self.vlan2.id], } url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.add_interface') + self.add_permissions('virtualization.add_interface') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -299,7 +294,7 @@ class InterfaceTest(APITestCase): }, ] url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.add_interface') + self.add_permissions('virtualization.add_interface') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -333,7 +328,7 @@ class InterfaceTest(APITestCase): }, ] url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.add_interface') + self.add_permissions('virtualization.add_interface') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -349,7 +344,7 @@ class InterfaceTest(APITestCase): 'name': 'Test Interface X', } url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('dcim.change_interface') + self.add_permissions('virtualization.change_interface') response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) @@ -359,7 +354,7 @@ class InterfaceTest(APITestCase): def test_delete_interface(self): url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('dcim.delete_interface') + self.add_permissions('virtualization.delete_interface') response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) diff --git a/netbox/virtualization/tests/test_filters.py b/netbox/virtualization/tests/test_filters.py index 51c7c6e8d..562ed9901 100644 --- a/netbox/virtualization/tests/test_filters.py +++ b/netbox/virtualization/tests/test_filters.py @@ -1,10 +1,10 @@ from django.test import TestCase -from dcim.models import DeviceRole, Interface, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from virtualization.choices import * from virtualization.filters import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine class ClusterTypeTestCase(TestCase): diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index a98496f29..b8e1f92c5 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -1,11 +1,11 @@ from netaddr import EUI from dcim.choices import InterfaceModeChoices -from dcim.models import DeviceRole, Interface, Platform, Site +from dcim.models import DeviceRole, Platform, Site from ipam.models import VLAN from utilities.testing import ViewTestCases from virtualization.choices import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @@ -201,10 +201,6 @@ class InterfaceTestCase( ): model = Interface - def _get_base_url(self): - # Interface belongs to the DCIM app, so we have to override the base URL - return 'virtualization:interface_{}' - @classmethod def setUpTestData(cls): @@ -219,9 +215,9 @@ class InterfaceTestCase( VirtualMachine.objects.bulk_create(virtualmachines) Interface.objects.bulk_create([ - Interface(virtual_machine=virtualmachines[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL), - Interface(virtual_machine=virtualmachines[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL), - Interface(virtual_machine=virtualmachines[0], name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(virtual_machine=virtualmachines[0], name='Interface 1'), + Interface(virtual_machine=virtualmachines[0], name='Interface 2'), + Interface(virtual_machine=virtualmachines[0], name='Interface 3'), ]) vlans = ( @@ -237,7 +233,6 @@ class InterfaceTestCase( cls.form_data = { 'virtual_machine': virtualmachines[1].pk, 'name': 'Interface X', - 'type': InterfaceTypeChoices.TYPE_VIRTUAL, 'enabled': False, 'mgmt_only': False, 'mac_address': EUI('01-02-03-04-05-06'), @@ -252,7 +247,6 @@ class InterfaceTestCase( cls.bulk_create_data = { 'virtual_machine': virtualmachines[1].pk, 'name_pattern': 'Interface [4-6]', - 'type': InterfaceTypeChoices.TYPE_VIRTUAL, 'enabled': False, 'mgmt_only': False, 'mac_address': EUI('01-02-03-04-05-06'), diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 38ad1a8b1..4e29f861a 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -54,6 +54,7 @@ urlpatterns = [ path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), + path('interfaces//', views.InterfaceView.as_view(), name='interface'), path('interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index aea4d0556..a64b9b9db 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -4,7 +4,8 @@ from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from dcim.models import Device, Interface +from dcim.models import Device +from dcim.views import InterfaceView as DeviceInterfaceView from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView from ipam.models import Service @@ -13,7 +14,7 @@ from utilities.views import ( ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine # @@ -288,6 +289,18 @@ class VirtualMachineBulkDeleteView(BulkDeleteView): # VM interfaces # +class InterfaceListView(ObjectListView): + queryset = Interface.objects.prefetch_related('virtual_machine', 'virtual_machine__tenant', 'cable') + filterset = filters.InterfaceFilterSet + filterset_form = forms.InterfaceFilterForm + table = tables.InterfaceTable + action_buttons = ('import', 'export') + + +class InterfaceView(DeviceInterfaceView): + queryset = Interface.objects.all() + + class InterfaceCreateView(ComponentCreateView): queryset = Interface.objects.all() form = forms.InterfaceCreateForm