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

View File

@ -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):

View File

@ -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):

View File

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

View File

@ -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

View File

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

View File

@ -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 = []

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
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,

View File

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

View File

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

View File

@ -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

View File

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

View File

@ -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

View File

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

View File

@ -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

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 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()

View File

@ -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 = """
<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 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)

View File

@ -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):

View File

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

View File

@ -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/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
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('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.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