1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Initial work on #4721 (WIP)

This commit is contained in:
Jeremy Stretch
2020-06-22 13:10:56 -04:00
parent 181bcd70ad
commit 6cb31a274f
26 changed files with 481 additions and 215 deletions

View File

@ -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',
),
]

View File

@ -35,11 +35,12 @@ from .device_component_templates import (
PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
) )
from .device_components import ( from .device_components import (
CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet, BaseInterface, CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem,
PowerPort, RearPort, PowerOutlet, PowerPort, RearPort,
) )
__all__ = ( __all__ = (
'BaseInterface',
'Cable', 'Cable',
'CableTermination', 'CableTermination',
'ConsolePort', 'ConsolePort',

View File

@ -19,7 +19,6 @@ from utilities.ordering import naturalize_interface
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from utilities.utils import serialize_object from utilities.utils import serialize_object
from virtualization.choices import VMInterfaceTypeChoices
__all__ = ( __all__ = (
@ -53,18 +52,12 @@ class ComponentModel(models.Model):
return self.name return self.name
def to_objectchange(self, action): def to_objectchange(self, action):
# Annotate the parent Device/VM # Annotate the parent Device
try:
parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None)
except ObjectDoesNotExist:
# The parent device/VM has already been deleted
parent = None
return ObjectChange( return ObjectChange(
changed_object=self, changed_object=self,
object_repr=str(self), object_repr=str(self),
action=action, action=action,
related_object=parent, related_object=self.device,
object_data=serialize_object(self) object_data=serialize_object(self)
) )
@ -592,26 +585,7 @@ class PowerOutlet(CableTermination, ComponentModel):
# Interfaces # Interfaces
# #
@extras_features('graphs', 'export_templates', 'webhooks') class BaseInterface(models.Model):
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
)
name = models.CharField( name = models.CharField(
max_length=64 max_length=64
) )
@ -621,6 +595,43 @@ class Interface(CableTermination, ComponentModel):
max_length=100, max_length=100,
blank=True 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( label = models.CharField(
max_length=64, max_length=64,
blank=True, blank=True,
@ -656,30 +667,11 @@ class Interface(CableTermination, ComponentModel):
max_length=50, max_length=50,
choices=InterfaceTypeChoices 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( mgmt_only = models.BooleanField(
default=False, default=False,
verbose_name='OOB Management', verbose_name='OOB Management',
help_text='This interface is used only for out-of-band 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( untagged_vlan = models.ForeignKey(
to='ipam.VLAN', to='ipam.VLAN',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -694,15 +686,19 @@ class Interface(CableTermination, ComponentModel):
blank=True, blank=True,
verbose_name='Tagged VLANs' 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) tags = TaggableManager(through=TaggedItem)
csv_headers = [ 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', 'description', 'mode',
] ]
class Meta: class Meta:
# TODO: ordering and unique_together should include virtual_machine
ordering = ('device', CollateAsChar('_name')) ordering = ('device', CollateAsChar('_name'))
unique_together = ('device', 'name') unique_together = ('device', 'name')
@ -712,7 +708,6 @@ class Interface(CableTermination, ComponentModel):
def to_csv(self): def to_csv(self):
return ( return (
self.device.identifier if self.device else None, self.device.identifier if self.device else None,
self.virtual_machine.name if self.virtual_machine else None,
self.name, self.name,
self.lag.name if self.lag else None, self.lag.name if self.lag else None,
self.get_type_display(), self.get_type_display(),
@ -726,18 +721,6 @@ class Interface(CableTermination, ComponentModel):
def clean(self): 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 # Virtual interfaces cannot be connected
if self.type in NONCONNECTABLE_IFACE_TYPES and ( if self.type in NONCONNECTABLE_IFACE_TYPES and (
self.cable or getattr(self, 'circuit_termination', False) 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]: if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
raise ValidationError({ raise ValidationError({
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent " '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): def save(self, *args, **kwargs):
@ -788,21 +771,6 @@ class Interface(CableTermination, ComponentModel):
return super().save(*args, **kwargs) 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 @property
def connected_endpoint(self): def connected_endpoint(self):
""" """
@ -841,7 +809,7 @@ class Interface(CableTermination, ComponentModel):
@property @property
def parent(self): def parent(self):
return self.device or self.virtual_machine return self.device
@property @property
def is_connectable(self): def is_connectable(self):

View File

@ -863,6 +863,7 @@ class DeviceImportTable(BaseTable):
class DeviceComponentDetailTable(BaseTable): class DeviceComponentDetailTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
device = tables.LinkColumn()
name = tables.Column(order_by=('_name',)) name = tables.Column(order_by=('_name',))
cable = tables.LinkColumn() cable = tables.LinkColumn()
@ -881,7 +882,6 @@ class ConsolePortTable(BaseTable):
class ConsolePortDetailTable(DeviceComponentDetailTable): class ConsolePortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta): class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta):
pass pass
@ -896,7 +896,6 @@ class ConsoleServerPortTable(BaseTable):
class ConsoleServerPortDetailTable(DeviceComponentDetailTable): class ConsoleServerPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta): class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta):
pass pass
@ -911,7 +910,6 @@ class PowerPortTable(BaseTable):
class PowerPortDetailTable(DeviceComponentDetailTable): class PowerPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta): class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta):
pass pass
@ -926,7 +924,6 @@ class PowerOutletTable(BaseTable):
class PowerOutletDetailTable(DeviceComponentDetailTable): class PowerOutletDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta): class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta):
pass pass
@ -940,14 +937,11 @@ class InterfaceTable(BaseTable):
class InterfaceDetailTable(DeviceComponentDetailTable): class InterfaceDetailTable(DeviceComponentDetailTable):
parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
name = tables.LinkColumn()
enabled = BooleanColumn() enabled = BooleanColumn()
class Meta(InterfaceTable.Meta): class Meta(DeviceComponentDetailTable.Meta, InterfaceTable.Meta):
order_by = ('parent', 'name') fields = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description', 'cable')
fields = ('pk', 'parent', 'name', 'label', 'enabled', 'type', 'description', 'cable') sequence = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description', 'cable')
sequence = ('pk', 'parent', 'name', 'label', 'enabled', 'type', 'description', 'cable')
class FrontPortTable(BaseTable): class FrontPortTable(BaseTable):
@ -960,7 +954,6 @@ class FrontPortTable(BaseTable):
class FrontPortDetailTable(DeviceComponentDetailTable): class FrontPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta): class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta):
pass pass
@ -976,7 +969,6 @@ class RearPortTable(BaseTable):
class RearPortDetailTable(DeviceComponentDetailTable): class RearPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta): class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta):
pass pass
@ -991,7 +983,6 @@ class DeviceBayTable(BaseTable):
class DeviceBayDetailTable(DeviceComponentDetailTable): class DeviceBayDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
installed_device = tables.LinkColumn() installed_device = tables.LinkColumn()
class Meta(DeviceBayTable.Meta): class Meta(DeviceBayTable.Meta):

View File

@ -1442,7 +1442,7 @@ class InterfaceView(ObjectView):
# Get assigned IP addresses # Get assigned IP addresses
ipaddress_table = InterfaceIPAddressTable( 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 orderable=False
) )

View File

@ -1,3 +1,5 @@
from django.db.models import Q
from .choices import IPAddressRoleChoices from .choices import IPAddressRoleChoices
# BGP ASN bounds # BGP ASN bounds
@ -29,6 +31,11 @@ PREFIX_LENGTH_MAX = 127 # IPv6
# IPAddresses # 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_MIN = 1
IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6 IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6

View File

@ -299,37 +299,37 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
to_field_name='rd', to_field_name='rd',
label='VRF (RD)', label='VRF (RD)',
) )
device = MultiValueCharFilter( # device = MultiValueCharFilter(
method='filter_device', # method='filter_device',
field_name='name', # field_name='name',
label='Device (name)', # label='Device (name)',
) # )
device_id = MultiValueNumberFilter( # device_id = MultiValueNumberFilter(
method='filter_device', # method='filter_device',
field_name='pk', # field_name='pk',
label='Device (ID)', # label='Device (ID)',
) # )
virtual_machine_id = django_filters.ModelMultipleChoiceFilter( # virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
field_name='interface__virtual_machine', # field_name='interface__virtual_machine',
queryset=VirtualMachine.objects.unrestricted(), # queryset=VirtualMachine.objects.unrestricted(),
label='Virtual machine (ID)', # label='Virtual machine (ID)',
) # )
virtual_machine = django_filters.ModelMultipleChoiceFilter( # virtual_machine = django_filters.ModelMultipleChoiceFilter(
field_name='interface__virtual_machine__name', # field_name='interface__virtual_machine__name',
queryset=VirtualMachine.objects.unrestricted(), # queryset=VirtualMachine.objects.unrestricted(),
to_field_name='name', # to_field_name='name',
label='Virtual machine (name)', # label='Virtual machine (name)',
) # )
interface = django_filters.ModelMultipleChoiceFilter( # interface = django_filters.ModelMultipleChoiceFilter(
field_name='interface__name', # field_name='interface__name',
queryset=Interface.objects.unrestricted(), # queryset=Interface.objects.unrestricted(),
to_field_name='name', # to_field_name='name',
label='Interface (ID)', # label='Interface (ID)',
) # )
interface_id = django_filters.ModelMultipleChoiceFilter( # interface_id = django_filters.ModelMultipleChoiceFilter(
queryset=Interface.objects.unrestricted(), # queryset=Interface.objects.unrestricted(),
label='Interface (ID)', # label='Interface (ID)',
) # )
assigned_to_interface = django_filters.BooleanFilter( assigned_to_interface = django_filters.BooleanFilter(
method='_assigned_to_interface', method='_assigned_to_interface',
label='Is assigned to an interface', label='Is assigned to an interface',

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from dcim.models import Device, Interface, Rack, Region, Site from dcim.models import Device, Interface, Rack, Region, Site
@ -14,7 +15,7 @@ from utilities.forms import (
ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES, BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import VirtualMachine from virtualization.models import Interface as VMInterface, VirtualMachine
from .choices import * from .choices import *
from .constants import * from .constants import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF 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 # Limit IP address choices to those assigned to interfaces of the parent device/VM
if self.instance.device: 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( 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: elif self.instance.virtual_machine:
self.fields['ipaddresses'].queryset = IPAddress.objects.filter( 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: else:
self.fields['ipaddresses'].choices = [] self.fields['ipaddresses'].choices = []

View File

@ -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
),
]

View File

@ -1,6 +1,7 @@
import netaddr import netaddr
from django.conf import settings 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.exceptions import ValidationError, ObjectDoesNotExist
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
@ -606,13 +607,25 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
blank=True, blank=True,
help_text='The functional role of this IP' 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', to='dcim.Interface',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='ip_addresses', related_name='ip_addresses',
blank=True, blank=True,
null=True null=True
) )
assigned_object = GenericForeignKey(
ct_field='assigned_object_type',
fk_field='assigned_object_id'
)
nat_inside = models.OneToOneField( nat_inside = models.OneToOneField(
to='self', to='self',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,

View File

@ -431,18 +431,14 @@ class IPAddressTable(BaseTable):
tenant = tables.TemplateColumn( tenant = tables.TemplateColumn(
template_code=TENANT_LINK template_code=TENANT_LINK
) )
parent = tables.TemplateColumn( assigned = tables.BooleanColumn(
template_code=IPADDRESS_PARENT, accessor='assigned_object_id'
orderable=False
)
interface = tables.Column(
orderable=False
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = IPAddress model = IPAddress
fields = ( fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description', 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
) )
row_attrs = { row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
@ -465,11 +461,11 @@ class IPAddressDetailTable(IPAddressTable):
class Meta(IPAddressTable.Meta): class Meta(IPAddressTable.Meta):
fields = ( 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', 'description', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description', 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
) )

View File

@ -4,7 +4,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer,
from ipam.choices import * from ipam.choices import *
from ipam.filters import * from ipam.filters import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF 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 from tenancy.models import Tenant, TenantGroup
@ -375,6 +375,13 @@ class IPAddressTestCase(TestCase):
) )
Device.objects.bulk_create(devices) 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') clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(type=clustertype, name='Cluster 1') cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
@ -385,15 +392,12 @@ class IPAddressTestCase(TestCase):
) )
VirtualMachine.objects.bulk_create(virtual_machines) VirtualMachine.objects.bulk_create(virtual_machines)
interfaces = ( vm_interfaces = (
Interface(device=devices[0], name='Interface 1'), VMInterface(virtual_machine=virtual_machines[0], name='Interface 1'),
Interface(device=devices[1], name='Interface 2'), VMInterface(virtual_machine=virtual_machines[1], name='Interface 2'),
Interface(device=devices[2], name='Interface 3'), VMInterface(virtual_machine=virtual_machines[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'),
) )
Interface.objects.bulk_create(interfaces) VMInterface.objects.bulk_create(vm_interfaces)
tenant_groups = ( tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 1', slug='tenant-group-1'),

View File

@ -256,6 +256,10 @@ class BaseFilterSet(django_filters.FilterSet):
except django_filters.exceptions.FieldLookupError: except django_filters.exceptions.FieldLookupError:
# The filter could not be created because the lookup expression is not supported on the field # The filter could not be created because the lookup expression is not supported on the field
continue 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'): if lookup_name.startswith('n'):
# This is a negation filter which requires a queryset.exclude() clause # This is a negation filter which requires a queryset.exclude() clause

View File

@ -3,7 +3,6 @@ from rest_framework import serializers
from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer from extras.api.serializers import TaggedObjectSerializer
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer 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 tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer
from virtualization.choices import * 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 * from .nested_serializers import *
@ -97,7 +96,6 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
virtual_machine = NestedVirtualMachineSerializer() virtual_machine = NestedVirtualMachineSerializer()
type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField( tagged_vlans = SerializedPKRelatedField(
@ -110,6 +108,6 @@ class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
class Meta: class Meta:
model = Interface model = Interface
fields = [ fields = [
'id', 'virtual_machine', 'name', 'type', 'enabled', 'mtu', 'mac_address', 'description', 'mode', 'id', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan',
'untagged_vlan', 'tagged_vlans', 'tags', 'tagged_vlans', 'tags',
] ]

View File

@ -1,11 +1,11 @@
from django.db.models import Count from django.db.models import Count
from dcim.models import Device, Interface from dcim.models import Device
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
from utilities.api import ModelViewSet from utilities.api import ModelViewSet
from utilities.utils import get_subquery from utilities.utils import get_subquery
from virtualization import filters from virtualization import filters
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine
from . import serializers from . import serializers

View File

@ -1,7 +1,7 @@
import django_filters import django_filters
from django.db.models import Q 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 extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet
from tenancy.filters import TenancyFilterSet from tenancy.filters import TenancyFilterSet
from utilities.filters import ( from utilities.filters import (
@ -9,7 +9,7 @@ from utilities.filters import (
TreeNodeMultipleChoiceFilter, TreeNodeMultipleChoiceFilter,
) )
from .choices import * from .choices import *
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine
__all__ = ( __all__ = (
'ClusterFilterSet', 'ClusterFilterSet',

View File

@ -1,10 +1,11 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
from dcim.forms import INTERFACE_MODE_HELP_TEXT 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 ( from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
) )
@ -16,10 +17,10 @@ from utilities.forms import (
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea,
StaticSelect2, StaticSelect2Multiple, TagFilterField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
) )
from .choices import * 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]: for family in [4, 6]:
ip_choices = [(None, '---------')] ip_choices = [(None, '---------')]
# Collect interface IPs # Collect interface IPs
interface_pks = self.instance.interfaces.values_list('id', flat=True)
interface_ips = IPAddress.objects.prefetch_related('interface').filter( 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: if interface_ips:
ip_choices.append( ip_choices.append(
@ -600,12 +604,11 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = Interface model = Interface
fields = [ fields = [
'virtual_machine', 'name', 'type', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'untagged_vlan',
'untagged_vlan', 'tagged_vlans', 'tagged_vlans',
] ]
widgets = { widgets = {
'virtual_machine': forms.HiddenInput(), 'virtual_machine': forms.HiddenInput(),
'type': forms.HiddenInput(),
'mode': StaticSelect2() 'mode': StaticSelect2()
} }
labels = { labels = {
@ -619,7 +622,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Add current site to VLANs query params # 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: if site is not None:
# Add current site to VLANs query params # Add current site to VLANs query params
self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk) 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( name_pattern = ExpandableNameField(
label='Name' label='Name'
) )
type = forms.ChoiceField(
choices=VMInterfaceTypeChoices,
initial=VMInterfaceTypeChoices.TYPE_VIRTUAL,
widget=forms.HiddenInput()
)
enabled = forms.BooleanField( enabled = forms.BooleanField(
required=False, required=False,
initial=True initial=True
@ -789,6 +787,17 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk) 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 # Bulk VirtualMachine component creation
# #
@ -812,8 +821,4 @@ class InterfaceBulkCreateForm(
form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']), form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']),
VirtualMachineBulkAddComponentForm VirtualMachineBulkAddComponentForm
): ):
type = forms.ChoiceField( pass
choices=VMInterfaceTypeChoices,
initial=VMInterfaceTypeChoices.TYPE_VIRTUAL,
widget=forms.HiddenInput()
)

View File

@ -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')},
},
),
]

View File

@ -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
),
]

View File

@ -5,11 +5,14 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from dcim.models import Device from dcim.choices import InterfaceModeChoices
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem from dcim.models import BaseInterface, Device
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.models import ChangeLoggedModel from utilities.models import ChangeLoggedModel
from utilities.query_functions import CollateAsChar
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object
from .choices import * from .choices import *
@ -17,6 +20,7 @@ __all__ = (
'Cluster', 'Cluster',
'ClusterGroup', 'ClusterGroup',
'ClusterType', 'ClusterType',
'Interface',
'VirtualMachine', 'VirtualMachine',
) )
@ -370,3 +374,109 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
@property @property
def site(self): def site(self):
return self.cluster.site 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()

View File

@ -1,10 +1,9 @@
import django_tables2 as tables import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from dcim.models import Interface
from tenancy.tables import COL_TENANT from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, ColoredLabelColumn, TagColumn, ToggleColumn 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 = """ CLUSTERTYPE_ACTIONS = """
<a href="{% url 'virtualization:clustertype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log"> <a href="{% url 'virtualization:clustertype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">

View File

@ -2,11 +2,9 @@ from django.urls import reverse
from rest_framework import status from rest_framework import status
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.models import Interface
from ipam.models import VLAN from ipam.models import VLAN
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
class AppTest(APITestCase): class AppTest(APITestCase):
@ -207,18 +205,15 @@ class InterfaceTest(APITestCase):
self.virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1') self.virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1')
self.interface1 = Interface.objects.create( self.interface1 = Interface.objects.create(
virtual_machine=self.virtualmachine, virtual_machine=self.virtualmachine,
name='Test Interface 1', name='Test Interface 1'
type=InterfaceTypeChoices.TYPE_VIRTUAL
) )
self.interface2 = Interface.objects.create( self.interface2 = Interface.objects.create(
virtual_machine=self.virtualmachine, virtual_machine=self.virtualmachine,
name='Test Interface 2', name='Test Interface 2'
type=InterfaceTypeChoices.TYPE_VIRTUAL
) )
self.interface3 = Interface.objects.create( self.interface3 = Interface.objects.create(
virtual_machine=self.virtualmachine, virtual_machine=self.virtualmachine,
name='Test Interface 3', name='Test Interface 3'
type=InterfaceTypeChoices.TYPE_VIRTUAL
) )
self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1) self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1)
@ -227,21 +222,21 @@ class InterfaceTest(APITestCase):
def test_get_interface(self): def test_get_interface(self):
url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) 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) response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.interface1.name) self.assertEqual(response.data['name'], self.interface1.name)
def test_list_interfaces(self): def test_list_interfaces(self):
url = reverse('virtualization-api:interface-list') 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) response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3) self.assertEqual(response.data['count'], 3)
def test_list_interfaces_brief(self): def test_list_interfaces_brief(self):
url = reverse('virtualization-api:interface-list') 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) response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual( self.assertEqual(
@ -255,7 +250,7 @@ class InterfaceTest(APITestCase):
'name': 'Test Interface 4', 'name': 'Test Interface 4',
} }
url = reverse('virtualization-api:interface-list') 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) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
@ -273,7 +268,7 @@ class InterfaceTest(APITestCase):
'tagged_vlans': [self.vlan1.id, self.vlan2.id], 'tagged_vlans': [self.vlan1.id, self.vlan2.id],
} }
url = reverse('virtualization-api:interface-list') 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) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
@ -299,7 +294,7 @@ class InterfaceTest(APITestCase):
}, },
] ]
url = reverse('virtualization-api:interface-list') 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) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
@ -333,7 +328,7 @@ class InterfaceTest(APITestCase):
}, },
] ]
url = reverse('virtualization-api:interface-list') 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) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
@ -349,7 +344,7 @@ class InterfaceTest(APITestCase):
'name': 'Test Interface X', 'name': 'Test Interface X',
} }
url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) 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) response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
@ -359,7 +354,7 @@ class InterfaceTest(APITestCase):
def test_delete_interface(self): def test_delete_interface(self):
url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) 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) response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)

View File

@ -1,10 +1,10 @@
from django.test import TestCase 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 tenancy.models import Tenant, TenantGroup
from virtualization.choices import * from virtualization.choices import *
from virtualization.filters 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): class ClusterTypeTestCase(TestCase):

View File

@ -1,11 +1,11 @@
from netaddr import EUI from netaddr import EUI
from dcim.choices import InterfaceModeChoices 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 ipam.models import VLAN
from utilities.testing import ViewTestCases from utilities.testing import ViewTestCases
from virtualization.choices import * 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): class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@ -201,10 +201,6 @@ class InterfaceTestCase(
): ):
model = Interface 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -219,9 +215,9 @@ class InterfaceTestCase(
VirtualMachine.objects.bulk_create(virtualmachines) VirtualMachine.objects.bulk_create(virtualmachines)
Interface.objects.bulk_create([ Interface.objects.bulk_create([
Interface(virtual_machine=virtualmachines[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL), Interface(virtual_machine=virtualmachines[0], name='Interface 1'),
Interface(virtual_machine=virtualmachines[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL), Interface(virtual_machine=virtualmachines[0], name='Interface 2'),
Interface(virtual_machine=virtualmachines[0], name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL), Interface(virtual_machine=virtualmachines[0], name='Interface 3'),
]) ])
vlans = ( vlans = (
@ -237,7 +233,6 @@ class InterfaceTestCase(
cls.form_data = { cls.form_data = {
'virtual_machine': virtualmachines[1].pk, 'virtual_machine': virtualmachines[1].pk,
'name': 'Interface X', 'name': 'Interface X',
'type': InterfaceTypeChoices.TYPE_VIRTUAL,
'enabled': False, 'enabled': False,
'mgmt_only': False, 'mgmt_only': False,
'mac_address': EUI('01-02-03-04-05-06'), 'mac_address': EUI('01-02-03-04-05-06'),
@ -252,7 +247,6 @@ class InterfaceTestCase(
cls.bulk_create_data = { cls.bulk_create_data = {
'virtual_machine': virtualmachines[1].pk, 'virtual_machine': virtualmachines[1].pk,
'name_pattern': 'Interface [4-6]', 'name_pattern': 'Interface [4-6]',
'type': InterfaceTypeChoices.TYPE_VIRTUAL,
'enabled': False, 'enabled': False,
'mgmt_only': False, 'mgmt_only': False,
'mac_address': EUI('01-02-03-04-05-06'), 'mac_address': EUI('01-02-03-04-05-06'),

View File

@ -54,6 +54,7 @@ urlpatterns = [
path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
path('interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'), path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'), path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'),

View File

@ -4,7 +4,8 @@ from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse 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 dcim.tables import DeviceTable
from extras.views import ObjectConfigContextView from extras.views import ObjectConfigContextView
from ipam.models import Service from ipam.models import Service
@ -13,7 +14,7 @@ from utilities.views import (
ObjectDeleteView, ObjectEditView, ObjectListView, ObjectDeleteView, ObjectEditView, ObjectListView,
) )
from . import filters, forms, tables 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 # 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): class InterfaceCreateView(ComponentCreateView):
queryset = Interface.objects.all() queryset = Interface.objects.all()
form = forms.InterfaceCreateForm form = forms.InterfaceCreateForm