Merge pull request #4781 from netbox-community/4721-virtualmachine-interface

#4721: Move VM interfaces to a separate model (WIP)
This commit is contained in:
Jeremy Stretch 2020-06-24 12:10:20 -04:00 committed by GitHub
commit d60a2d3723
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 1236 additions and 652 deletions

View File

@ -23,12 +23,12 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
BulkRenameForm, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
from virtualization.models import Cluster, ClusterGroup
from .choices import *
from .constants import *
from .models import (
@ -150,30 +150,6 @@ class LabeledComponentForm(BootstrapMixin, forms.Form):
}, code='label_pattern_mismatch')
class BulkRenameForm(forms.Form):
"""
An extendable form to be used for renaming device components in bulk.
"""
find = forms.CharField()
replace = forms.CharField()
use_regex = forms.BooleanField(
required=False,
initial=True,
label='Use regular expressions'
)
def clean(self):
# Validate regular expression in "find" field
if self.cleaned_data['use_regex']:
try:
re.compile(self.cleaned_data['find'])
except re.error:
raise forms.ValidationError({
'find': "Invalid regular expression"
})
#
# Fields
#
@ -1816,18 +1792,20 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
ip_choices = [(None, '---------')]
# Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
interface_ids = self.instance.vc_interfaces.values('pk')
interface_ids = self.instance.vc_interfaces.values_list('pk', flat=True)
# Collect interface IPs
interface_ips = IPAddress.objects.prefetch_related('interface').filter(
address__family=family, interface_id__in=interface_ids
address__family=family,
interface__in=interface_ids
)
if interface_ips:
ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
ip_choices.append(('Interface IPs', ip_list))
# Collect NAT IPs
nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
address__family=family, nat_inside__interface__in=interface_ids
address__family=family,
nat_inside__interface__in=interface_ids
)
if nat_ips:
ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips]
@ -2961,12 +2939,6 @@ class InterfaceBulkDisconnectForm(ConfirmationForm):
class InterfaceCSVForm(CSVModelForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
required=False,
to_field_name='name'
)
virtual_machine = CSVModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
to_field_name='name'
)
lag = CSVModelChoiceField(

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,42 @@ class Interface(CableTermination, ComponentModel):
max_length=100,
blank=True
)
enabled = models.BooleanField(
default=True
)
mac_address = MACAddressField(
null=True,
blank=True,
verbose_name='MAC Address'
)
mtu = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1), MaxValueValidator(65536)],
verbose_name='MTU'
)
mode = models.CharField(
max_length=50,
choices=InterfaceModeChoices,
blank=True
)
class Meta:
abstract = True
@extras_features('graphs', 'export_templates', 'webhooks')
class Interface(CableTermination, ComponentModel, BaseInterface):
"""
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
"""
device = models.ForeignKey(
to='Device',
on_delete=models.CASCADE,
related_name='interfaces',
null=True,
blank=True
)
label = models.CharField(
max_length=64,
blank=True,
@ -656,30 +666,11 @@ class Interface(CableTermination, ComponentModel):
max_length=50,
choices=InterfaceTypeChoices
)
enabled = models.BooleanField(
default=True
)
mac_address = MACAddressField(
null=True,
blank=True,
verbose_name='MAC Address'
)
mtu = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1), MaxValueValidator(65536)],
verbose_name='MTU'
)
mgmt_only = models.BooleanField(
default=False,
verbose_name='OOB Management',
help_text='This interface is used only for out-of-band management'
)
mode = models.CharField(
max_length=50,
choices=InterfaceModeChoices,
blank=True
)
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
@ -694,15 +685,19 @@ class Interface(CableTermination, ComponentModel):
blank=True,
verbose_name='Tagged VLANs'
)
ip_addresses = GenericRelation(
to='ipam.IPAddress',
content_type_field='assigned_object_type',
object_id_field='assigned_object_id',
related_query_name='interface'
)
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
'description', 'mode',
'device', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode',
]
class Meta:
# TODO: ordering and unique_together should include virtual_machine
ordering = ('device', CollateAsChar('_name'))
unique_together = ('device', 'name')
@ -712,7 +707,6 @@ class Interface(CableTermination, ComponentModel):
def to_csv(self):
return (
self.device.identifier if self.device else None,
self.virtual_machine.name if self.virtual_machine else None,
self.name,
self.lag.name if self.lag else None,
self.get_type_display(),
@ -726,18 +720,6 @@ class Interface(CableTermination, ComponentModel):
def clean(self):
# An Interface must belong to a Device *or* to a VirtualMachine
if self.device and self.virtual_machine:
raise ValidationError("An interface cannot belong to both a device and a virtual machine.")
if not self.device and not self.virtual_machine:
raise ValidationError("An interface must belong to either a device or a virtual machine.")
# VM interfaces must be virtual
if self.virtual_machine and self.type not in VMInterfaceTypeChoices.values():
raise ValidationError({
'type': "Invalid interface type for a virtual machine: {}".format(self.type)
})
# Virtual interfaces cannot be connected
if self.type in NONCONNECTABLE_IFACE_TYPES and (
self.cable or getattr(self, 'circuit_termination', False)
@ -773,7 +755,7 @@ class Interface(CableTermination, ComponentModel):
if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
raise ValidationError({
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
"device/VM, or it must be global".format(self.untagged_vlan)
"device, or it must be global".format(self.untagged_vlan)
})
def save(self, *args, **kwargs):
@ -788,21 +770,6 @@ class Interface(CableTermination, ComponentModel):
return super().save(*args, **kwargs)
def to_objectchange(self, action):
# Annotate the parent Device/VM
try:
parent_obj = self.device or self.virtual_machine
except ObjectDoesNotExist:
parent_obj = None
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
related_object=parent_obj,
object_data=serialize_object(self)
)
@property
def connected_endpoint(self):
"""
@ -841,7 +808,7 @@ class Interface(CableTermination, ComponentModel):
@property
def parent(self):
return self.device or self.virtual_machine
return self.device
@property
def is_connectable(self):

View File

@ -598,17 +598,11 @@ class InterfaceImportTable(BaseTable):
viewname='dcim:device',
args=[Accessor('device.pk')]
)
virtual_machine = tables.LinkColumn(
viewname='virtualization:virtualmachine',
args=[Accessor('virtual_machine.pk')],
verbose_name='Virtual Machine'
)
class Meta(BaseTable.Meta):
model = Interface
fields = (
'device', 'virtual_machine', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu',
'mgmt_only', 'mode',
'device', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'mode',
)
empty_text = False
@ -863,6 +857,7 @@ class DeviceImportTable(BaseTable):
class DeviceComponentDetailTable(BaseTable):
pk = ToggleColumn()
device = tables.LinkColumn()
name = tables.Column(order_by=('_name',))
cable = tables.LinkColumn()
@ -881,7 +876,6 @@ class ConsolePortTable(BaseTable):
class ConsolePortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta):
pass
@ -896,7 +890,6 @@ class ConsoleServerPortTable(BaseTable):
class ConsoleServerPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta):
pass
@ -911,7 +904,6 @@ class PowerPortTable(BaseTable):
class PowerPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta):
pass
@ -926,7 +918,6 @@ class PowerOutletTable(BaseTable):
class PowerOutletDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta):
pass
@ -940,14 +931,11 @@ class InterfaceTable(BaseTable):
class InterfaceDetailTable(DeviceComponentDetailTable):
parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
name = tables.LinkColumn()
enabled = BooleanColumn()
class Meta(InterfaceTable.Meta):
order_by = ('parent', 'name')
fields = ('pk', 'parent', 'name', 'label', 'enabled', 'type', 'description', 'cable')
sequence = ('pk', 'parent', 'name', 'label', 'enabled', 'type', 'description', 'cable')
class Meta(DeviceComponentDetailTable.Meta, InterfaceTable.Meta):
fields = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description', 'cable')
sequence = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description', 'cable')
class FrontPortTable(BaseTable):
@ -960,7 +948,6 @@ class FrontPortTable(BaseTable):
class FrontPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta):
pass
@ -976,7 +963,6 @@ class RearPortTable(BaseTable):
class RearPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta):
pass
@ -991,7 +977,6 @@ class DeviceBayTable(BaseTable):
class DeviceBayDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
installed_device = tables.LinkColumn()
class Meta(DeviceBayTable.Meta):

View File

@ -1254,8 +1254,8 @@ class DeviceTestCase(TestCase):
# Assign primary IPs for filtering
ipaddresses = (
IPAddress(address='192.0.2.1/24', interface=interfaces[0]),
IPAddress(address='192.0.2.2/24', interface=interfaces[1]),
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
)
IPAddress.objects.bulk_create(ipaddresses)
Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0])

View File

@ -1,5 +1,4 @@
from collections import OrderedDict
import re
from django.conf import settings
from django.contrib import messages
@ -25,8 +24,9 @@ from utilities.paginator import EnhancedPaginator
from utilities.permissions import get_permission_for_model
from utilities.utils import csv_format
from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectPermissionRequiredMixin,
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
GetReturnURLMixin, ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
ObjectPermissionRequiredMixin,
)
from virtualization.models import VirtualMachine
from . import filters, forms, tables
@ -41,58 +41,6 @@ from .models import (
)
class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
An extendable view for renaming device components in bulk.
"""
queryset = None
form = None
template_name = 'dcim/bulk_rename.html'
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'change')
def post(self, request):
if '_preview' in request.POST or '_apply' in request.POST:
form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
if form.is_valid():
for obj in selected_objects:
find = form.cleaned_data['find']
replace = form.cleaned_data['replace']
if form.cleaned_data['use_regex']:
try:
obj.new_name = re.sub(find, replace, obj.name)
# Catch regex group reference errors
except re.error:
obj.new_name = obj.name
else:
obj.new_name = obj.name.replace(find, replace)
if '_apply' in request.POST:
for obj in selected_objects:
obj.name = obj.new_name
obj.save()
messages.success(request, "Renamed {} {}".format(
len(selected_objects),
self.queryset.model._meta.verbose_name_plural
))
return redirect(self.get_return_url(request))
else:
form = self.form(initial={'pk': request.POST.getlist('pk')})
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
return render(request, self.template_name, {
'form': form,
'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
'selected_objects': selected_objects,
'return_url': self.get_return_url(request),
})
class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
An extendable view for disconnection console/power/interface components in bulk.

View File

@ -1,5 +1,7 @@
from collections import OrderedDict
from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator
@ -9,10 +11,12 @@ from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from ipam.choices import *
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import (
ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer,
ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer,
get_serializer_for_model,
)
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
from .nested_serializers import *
@ -228,18 +232,31 @@ class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=IPAddressStatusChoices, required=False)
role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
assigned_object_type = ContentTypeField(
queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
required=False
)
assigned_object = serializers.SerializerMethodField(read_only=True)
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
nat_outside = NestedIPAddressSerializer(read_only=True)
class Meta:
model = IPAddress
fields = [
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'nat_inside',
'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id',
'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields',
'created', 'last_updated',
]
read_only_fields = ['family']
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_assigned_object(self, obj):
if obj.assigned_object is None:
return None
serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested')
context = {'request': self.context['request']}
return serializer(obj.assigned_object, context=context).data
class AvailableIPSerializer(serializers.Serializer):
"""

View File

@ -233,8 +233,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
class IPAddressViewSet(CustomFieldModelViewSet):
queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine',
'nat_outside', 'tags',
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags',
)
serializer_class = serializers.IPAddressSerializer
filterset_class = filters.IPAddressFilterSet

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='vminterface')
)
IPADDRESS_MASK_LENGTH_MIN = 1
IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6

View File

@ -11,7 +11,7 @@ from utilities.filters import (
BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter,
TreeNodeMultipleChoiceFilter,
)
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, VMInterface
from .choices import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@ -309,27 +309,38 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
field_name='pk',
label='Device (ID)',
)
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
field_name='interface__virtual_machine',
queryset=VirtualMachine.objects.unrestricted(),
label='Virtual machine (ID)',
)
virtual_machine = django_filters.ModelMultipleChoiceFilter(
field_name='interface__virtual_machine__name',
queryset=VirtualMachine.objects.unrestricted(),
to_field_name='name',
virtual_machine = MultiValueCharFilter(
method='filter_virtual_machine',
field_name='name',
label='Virtual machine (name)',
)
virtual_machine_id = MultiValueNumberFilter(
method='filter_virtual_machine',
field_name='pk',
label='Virtual machine (ID)',
)
interface = django_filters.ModelMultipleChoiceFilter(
field_name='interface__name',
queryset=Interface.objects.unrestricted(),
to_field_name='name',
label='Interface (ID)',
label='Interface (name)',
)
interface_id = django_filters.ModelMultipleChoiceFilter(
field_name='interface',
queryset=Interface.objects.unrestricted(),
label='Interface (ID)',
)
vminterface = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface__name',
queryset=VMInterface.objects.unrestricted(),
to_field_name='name',
label='VM interface (name)',
)
vminterface_id = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface',
queryset=VMInterface.objects.unrestricted(),
label='VM interface (ID)',
)
assigned_to_interface = django_filters.BooleanFilter(
method='_assigned_to_interface',
label='Is assigned to an interface',
@ -379,17 +390,29 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
return queryset.filter(address__net_mask_length=value)
def filter_device(self, queryset, name, value):
try:
devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value})
vc_interface_ids = []
for device in devices:
vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')])
return queryset.filter(interface_id__in=vc_interface_ids)
except Device.DoesNotExist:
devices = Device.objects.filter(**{'{}__in'.format(name): value})
if not devices.exists():
return queryset.none()
interface_ids = []
for device in devices:
interface_ids.extend(device.vc_interfaces.values_list('id', flat=True))
return queryset.filter(
interface__in=interface_ids
)
def filter_virtual_machine(self, queryset, name, value):
virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value})
if not virtual_machines.exists():
return queryset.none()
interface_ids = []
for vm in virtual_machines:
interface_ids.extend(vm.interfaces.values_list('id', flat=True))
return queryset.filter(
vminterface__in=interface_ids
)
def _assigned_to_interface(self, queryset, name, value):
return queryset.exclude(interface__isnull=value)
return queryset.exclude(assigned_object_id__isnull=value)
class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):

View File

@ -14,7 +14,7 @@ from utilities.forms import (
ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, VMInterface
from .choices import *
from .constants import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@ -522,10 +522,33 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
#
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm):
interface = forms.ModelChoiceField(
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
widget=APISelect(
filter_for={
'interface': 'device_id'
}
)
)
interface = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False
)
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
widget=APISelect(
filter_for={
'vminterface': 'virtual_machine_id'
}
)
)
vminterface = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
required=False,
label='Interface'
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
@ -597,8 +620,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'interface', 'primary_for_parent',
'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags',
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack',
'nat_inside', 'tenant_group', 'tenant', 'tags',
]
widgets = {
'status': StaticSelect2(),
@ -610,32 +633,26 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
# Initialize helper selectors
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()
if instance and instance.nat_inside and instance.nat_inside.device is not None:
initial['nat_site'] = instance.nat_inside.device.site
initial['nat_rack'] = instance.nat_inside.device.rack
initial['nat_device'] = instance.nat_inside.device
if instance:
if type(instance.assigned_object) is Interface:
initial['device'] = instance.assigned_object.device
initial['interface'] = instance.assigned_object
elif type(instance.assigned_object) is VMInterface:
initial['virtual_machine'] = instance.assigned_object.virtual_machine
initial['vminterface'] = instance.assigned_object
if instance.nat_inside and instance.nat_inside.device is not None:
initial['nat_site'] = instance.nat_inside.device.site
initial['nat_rack'] = instance.nat_inside.device.rack
initial['nat_device'] = instance.nat_inside.device
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
# Limit interface selections to those belonging to the parent device/VM
if self.instance and self.instance.interface:
self.fields['interface'].queryset = Interface.objects.filter(
device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine
).prefetch_related(
'device__primary_ip4',
'device__primary_ip6',
'virtual_machine__primary_ip4',
'virtual_machine__primary_ip6',
) # We prefetch the primary address fields to ensure cache invalidation does not balk on the save()
else:
self.fields['interface'].choices = []
# Initialize primary_for_parent if IP address is already assigned
if self.instance.pk and self.instance.interface is not None:
parent = self.instance.interface.parent
if self.instance.pk and self.instance.assigned_object:
parent = self.instance.assigned_object.parent
if (
self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or
self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk
@ -645,32 +662,39 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
def clean(self):
super().clean()
# Cannot select both a device interface and a VM interface
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface")
# Primary IP assignment is only available if an interface has been assigned.
if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'):
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
if self.cleaned_data.get('primary_for_parent') and not interface:
self.add_error(
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
)
def save(self, *args, **kwargs):
# Set assigned object
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
if interface:
self.instance.assigned_object = interface
ipaddress = super().save(*args, **kwargs)
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
if self.cleaned_data['primary_for_parent']:
parent = self.cleaned_data['interface'].parent
if interface and self.cleaned_data['primary_for_parent']:
if ipaddress.address.version == 4:
parent.primary_ip4 = ipaddress
interface.parent.primary_ip4 = ipaddress
else:
parent.primary_ip6 = ipaddress
parent.save()
elif self.cleaned_data['interface']:
parent = self.cleaned_data['interface'].parent
if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
parent.primary_ip4 = None
parent.save()
elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
parent.primary_ip6 = None
parent.save()
interface.primary_ip6 = ipaddress
interface.parent.save()
elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
interface.parent.primary_ip4 = None
interface.parent.save()
elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress:
interface.parent.primary_ip4 = None
interface.parent.save()
return ipaddress
@ -742,7 +766,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
help_text='Parent VM of assigned interface (if any)'
)
interface = CSVModelChoiceField(
queryset=Interface.objects.all(),
queryset=Interface.objects.none(), # Can also refer to VMInterface
required=False,
to_field_name='name',
help_text='Assigned interface'
@ -761,21 +785,17 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
if data:
# Limit interface queryset by assigned device or virtual machine
# Limit interface queryset by assigned device
if data.get('device'):
params = {
f"device__{self.fields['device'].to_field_name}": data.get('device')
}
self.fields['interface'].queryset = Interface.objects.filter(
**{f"device__{self.fields['device'].to_field_name}": data['device']}
)
# Limit interface queryset by assigned device
elif data.get('virtual_machine'):
params = {
f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data.get('virtual_machine')
}
else:
params = {
'device': None,
'virtual_machine': None,
}
self.fields['interface'].queryset = self.fields['interface'].queryset.filter(**params)
self.fields['interface'].queryset = VMInterface.objects.filter(
**{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
)
def clean(self):
super().clean()
@ -790,6 +810,10 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
def save(self, *args, **kwargs):
# Set interface assignment
if self.cleaned_data['interface']:
self.instance.assigned_object = self.cleaned_data['interface']
ipaddress = super().save(*args, **kwargs)
# Set as primary for device/VM
@ -1194,13 +1218,12 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
# Limit IP address choices to those assigned to interfaces of the parent device/VM
if self.instance.device:
vc_interface_ids = [i['id'] for i in self.instance.device.vc_interfaces.values('id')]
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
interface_id__in=vc_interface_ids
interface__in=self.instance.device.vc_interfaces.values_list('id', flat=True)
)
elif self.instance.virtual_machine:
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
interface__virtual_machine=self.instance.virtual_machine
vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True)
)
else:
self.fields['ipaddresses'].choices = []

View File

@ -0,0 +1,40 @@
from django.db import migrations, models
import django.db.models.deletion
def set_assigned_object_type(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType')
IPAddress = apps.get_model('ipam', 'IPAddress')
device_ct = ContentType.objects.get(app_label='dcim', model='interface').pk
IPAddress.objects.update(assigned_object_type=device_ct)
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('ipam', '0036_standardize_description'),
]
operations = [
migrations.RenameField(
model_name='ipaddress',
old_name='interface',
new_name='assigned_object_id',
),
migrations.AlterField(
model_name='ipaddress',
name='assigned_object_id',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='ipaddress',
name='assigned_object_type',
field=models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType'),
preserve_default=False,
),
migrations.RunPython(
code=set_assigned_object_type
),
]

View File

@ -1,10 +1,11 @@
import netaddr
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import F, Q
from django.db.models import F
from django.urls import reverse
from taggit.managers import TaggableManager
@ -14,7 +15,7 @@ from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, VMInterface
from .choices import *
from .constants import *
from .fields import IPNetworkField, IPAddressField
@ -606,13 +607,22 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
blank=True,
help_text='The functional role of this IP'
)
interface = models.ForeignKey(
to='dcim.Interface',
on_delete=models.CASCADE,
related_name='ip_addresses',
assigned_object_type = models.ForeignKey(
to=ContentType,
limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS,
on_delete=models.PROTECT,
related_name='+',
blank=True,
null=True
)
assigned_object_id = models.PositiveIntegerField(
blank=True,
null=True
)
assigned_object = GenericForeignKey(
ct_field='assigned_object_type',
fk_field='assigned_object_id'
)
nat_inside = models.OneToOneField(
to='self',
on_delete=models.SET_NULL,
@ -643,11 +653,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
objects = IPAddressManager()
csv_headers = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id', 'is_primary',
'dns_name', 'description',
]
clone_fields = [
'vrf', 'tenant', 'status', 'role', 'description', 'interface',
'vrf', 'tenant', 'status', 'role', 'description',
]
STATUS_CLASS_MAP = {
@ -707,32 +717,31 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
)
})
if self.pk:
# Check for primary IP assignment that doesn't match the assigned device/VM
# Check for primary IP assignment that doesn't match the assigned device/VM
if self.pk and type(self.assigned_object) is Interface:
device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if device:
if self.interface is None:
if self.assigned_object is None:
raise ValidationError({
'interface': "IP address is primary for device {} but not assigned".format(device)
'interface': f"IP address is primary for device {device} but not assigned to an interface"
})
elif (device.primary_ip4 == self or device.primary_ip6 == self) and self.interface.device != device:
elif self.assigned_object.device != device:
raise ValidationError({
'interface': "IP address is primary for device {} but assigned to {} ({})".format(
device, self.interface.device, self.interface
)
'interface': f"IP address is primary for device {device} but assigned to "
f"{self.assigned_object.device} ({self.assigned_object})"
})
elif self.pk and type(self.assigned_object) is VMInterface:
vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if vm:
if self.interface is None:
if self.assigned_object is None:
raise ValidationError({
'interface': "IP address is primary for virtual machine {} but not assigned".format(vm)
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an "
f"interface"
})
elif (vm.primary_ip4 == self or vm.primary_ip6 == self) and self.interface.virtual_machine != vm:
elif self.interface.virtual_machine != vm:
raise ValidationError({
'interface': "IP address is primary for virtual machine {} but assigned to {} ({})".format(
vm, self.interface.virtual_machine, self.interface
)
'vminterface': f"IP address is primary for virtual machine {vm} but assigned to "
f"{self.assigned_object.virtual_machine} ({self.assigned_object})"
})
def save(self, *args, **kwargs):
@ -743,29 +752,27 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
super().save(*args, **kwargs)
def to_objectchange(self, action):
# Annotate the assigned Interface (if any)
try:
parent_obj = self.interface
except ObjectDoesNotExist:
parent_obj = None
# Annotate the assigned object, if any
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
related_object=parent_obj,
related_object=self.assigned_object,
object_data=serialize_object(self)
)
def to_csv(self):
# Determine if this IP is primary for a Device
is_primary = False
if self.address.version == 4 and getattr(self, 'primary_ip4_for', False):
is_primary = True
elif self.address.version == 6 and getattr(self, 'primary_ip6_for', False):
is_primary = True
else:
is_primary = False
obj_type = None
if self.assigned_object_type:
obj_type = f'{self.assigned_object_type.app_label}.{self.assigned_object_type.model}'
return (
self.address,
@ -773,9 +780,8 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
self.tenant.name if self.tenant else None,
self.get_status_display(),
self.get_role_display(),
self.device.identifier if self.device else None,
self.virtual_machine.name if self.virtual_machine else None,
self.interface.name if self.interface else None,
obj_type,
self.assigned_object_id,
is_primary,
self.dns_name,
self.description,
@ -796,18 +802,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
self.address.prefixlen = value
mask_length = property(fset=_set_mask_length)
@property
def device(self):
if self.interface:
return self.interface.device
return None
@property
def virtual_machine(self):
if self.interface:
return self.interface.virtual_machine
return None
def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status)

View File

@ -92,14 +92,6 @@ IPADDRESS_ASSIGN_LINK = """
{% endif %}
"""
IPADDRESS_PARENT = """
{% if record.interface %}
<a href="{{ record.interface.parent.get_absolute_url }}">{{ record.interface.parent }}</a>
{% else %}
&mdash;
{% endif %}
"""
VRF_LINK = """
{% if record.vrf %}
<a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
@ -168,7 +160,7 @@ VLAN_MEMBER_UNTAGGED = """
VLAN_MEMBER_ACTIONS = """
{% if perms.dcim.change_interface %}
<a href="{% if record.device %}{% url 'dcim:interface_edit' pk=record.pk %}{% else %}{% url 'virtualization:interface_edit' pk=record.pk %}{% endif %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil"></i></a>
<a href="{% if record.device %}{% url 'dcim:interface_edit' pk=record.pk %}{% else %}{% url 'virtualization:vminterface_edit' pk=record.pk %}{% endif %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil"></i></a>
{% endif %}
"""
@ -431,18 +423,14 @@ class IPAddressTable(BaseTable):
tenant = tables.TemplateColumn(
template_code=TENANT_LINK
)
parent = tables.TemplateColumn(
template_code=IPADDRESS_PARENT,
orderable=False
)
interface = tables.Column(
orderable=False
assigned = tables.BooleanColumn(
accessor='assigned_object_id'
)
class Meta(BaseTable.Meta):
model = IPAddress
fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description',
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
)
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
@ -465,11 +453,11 @@ class IPAddressDetailTable(IPAddressTable):
class Meta(IPAddressTable.Meta):
fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'dns_name',
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name',
'description', 'tags',
)
default_columns = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description',
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
)
@ -481,17 +469,13 @@ class IPAddressAssignTable(BaseTable):
status = tables.TemplateColumn(
template_code=STATUS_LABEL
)
parent = tables.TemplateColumn(
template_code=IPADDRESS_PARENT,
orderable=False
)
interface = tables.Column(
assigned_object = tables.Column(
orderable=False
)
class Meta(BaseTable.Meta):
model = IPAddress
fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'description')
orderable = False

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, VirtualMachine, VMInterface
from tenancy.models import Tenant, TenantGroup
@ -375,6 +375,13 @@ class IPAddressTestCase(TestCase):
)
Device.objects.bulk_create(devices)
interfaces = (
Interface(device=devices[0], name='Interface 1'),
Interface(device=devices[1], name='Interface 2'),
Interface(device=devices[2], name='Interface 3'),
)
Interface.objects.bulk_create(interfaces)
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
@ -385,15 +392,12 @@ class IPAddressTestCase(TestCase):
)
VirtualMachine.objects.bulk_create(virtual_machines)
interfaces = (
Interface(device=devices[0], name='Interface 1'),
Interface(device=devices[1], name='Interface 2'),
Interface(device=devices[2], name='Interface 3'),
Interface(virtual_machine=virtual_machines[0], name='Interface 1'),
Interface(virtual_machine=virtual_machines[1], name='Interface 2'),
Interface(virtual_machine=virtual_machines[2], name='Interface 3'),
vminterfaces = (
VMInterface(virtual_machine=virtual_machines[0], name='Interface 1'),
VMInterface(virtual_machine=virtual_machines[1], name='Interface 2'),
VMInterface(virtual_machine=virtual_machines[2], name='Interface 3'),
)
Interface.objects.bulk_create(interfaces)
VMInterface.objects.bulk_create(vminterfaces)
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
@ -411,16 +415,16 @@ class IPAddressTestCase(TestCase):
Tenant.objects.bulk_create(tenants)
ipaddresses = (
IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[3], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[4], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[5], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], assigned_object=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], assigned_object=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], assigned_object=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], assigned_object=vminterfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], assigned_object=vminterfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], assigned_object=vminterfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
)
IPAddress.objects.bulk_create(ipaddresses)
@ -487,7 +491,14 @@ class IPAddressTestCase(TestCase):
params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'interface': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_vminterface(self):
vminterfaces = VMInterface.objects.all()[:2]
params = {'vminterface_id': [vminterfaces[0].pk, vminterfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'vminterface': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_assigned_to_interface(self):
params = {'assigned_to_interface': 'true'}

View File

@ -236,7 +236,6 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'tenant': None,
'status': IPAddressStatusChoices.STATUS_RESERVED,
'role': IPAddressRoleChoices.ROLE_ANYCAST,
'interface': None,
'nat_inside': None,
'dns_name': 'example',
'description': 'A new IP address',

View File

@ -1,6 +1,6 @@
import netaddr
from django.conf import settings
from django.db.models import Count, Q
from django.db.models import Count
from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render
from django_tables2 import RequestConfig
@ -11,7 +11,7 @@ from utilities.views import (
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
ObjectListView,
)
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, VMInterface
from . import filters, forms, tables
from .choices import *
from .constants import *
@ -517,7 +517,7 @@ class PrefixIPAddressesView(ObjectView):
# Find all IPAddresses belonging to this Prefix
ipaddresses = prefix.get_child_ips().restrict(request.user, 'view').prefetch_related(
'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for'
'vrf', 'primary_ip4_for', 'primary_ip6_for'
)
# Add available IP addresses to the table if requested
@ -593,7 +593,7 @@ class PrefixBulkDeleteView(BulkDeleteView):
class IPAddressListView(ObjectListView):
queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device', 'interface__virtual_machine'
'vrf__tenant', 'tenant', 'nat_inside'
)
filterset = filters.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
@ -622,7 +622,7 @@ class IPAddressView(ObjectView):
).exclude(
pk=ipaddress.pk
).prefetch_related(
'nat_inside', 'interface__device'
'nat_inside'
)
# Exclude anycast IPs if this IP is anycast
if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST:
@ -630,9 +630,7 @@ class IPAddressView(ObjectView):
duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
# Related IP table
related_ips = IPAddress.objects.restrict(request.user, 'view').prefetch_related(
'interface__device'
).exclude(
related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
address=str(ipaddress.address)
).filter(
vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
@ -661,13 +659,18 @@ class IPAddressEditView(ObjectEditView):
def alter_obj(self, obj, request, url_args, url_kwargs):
interface_id = request.GET.get('interface')
if interface_id:
if 'interface' in request.GET:
try:
obj.interface = Interface.objects.get(pk=interface_id)
obj.assigned_object = Interface.objects.get(pk=request.GET['interface'])
except (ValueError, Interface.DoesNotExist):
pass
elif 'vminterface' in request.GET:
try:
obj.assigned_object = VMInterface.objects.get(pk=request.GET['vminterface'])
except (ValueError, VMInterface.DoesNotExist):
pass
return obj
@ -699,9 +702,7 @@ class IPAddressAssignView(ObjectView):
if form.is_valid():
addresses = self.queryset.prefetch_related(
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
)
addresses = self.queryset.prefetch_related('vrf', 'tenant')
# Limit to 100 results
addresses = filters.IPAddressFilterSet(request.POST, addresses).qs[:100]
table = tables.IPAddressAssignTable(addresses)
@ -735,7 +736,7 @@ class IPAddressBulkImportView(BulkImportView):
class IPAddressBulkEditView(BulkEditView):
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
filterset = filters.IPAddressFilterSet
table = tables.IPAddressTable
form = forms.IPAddressBulkEditForm
@ -743,7 +744,7 @@ class IPAddressBulkEditView(BulkEditView):
class IPAddressBulkDeleteView(BulkDeleteView):
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
filterset = filters.IPAddressFilterSet
table = tables.IPAddressTable
default_return_url = 'ipam:ipaddress_list'

View File

@ -166,7 +166,7 @@
</ul>
</span>
{% endif %}
<a href="{% if iface.device_id %}{% url 'dcim:interface_edit' pk=iface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
<a href="{% url 'dcim:interface_edit' pk=iface.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>
{% endif %}
@ -176,7 +176,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% if iface.device_id %}{% url 'dcim:interface_delete' pk=iface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
<a href="{% url 'dcim:interface_delete' pk=iface.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}

View File

@ -5,29 +5,25 @@
<div class="row noprint">
<div class="col-md-12">
<ol class="breadcrumb">
{% if interface.device %}
<li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
{% else %}
<li><a href="{% url 'virtualization:virtualmachine_list' %}">Virtual Machines</a></li>
{% endif %}
<li><a href="{{ interface.parent.get_absolute_url }}">{{ interface.parent }}</a></li>
<li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
<li><a href="{{ interface.device.get_absolute_url }}">{{ interface.device }}</a></li>
<li>{{ interface }}</li>
</ol>
</div>
</div>
<div class="pull-right noprint">
{% if perms.dcim.change_interface %}
<a href="{% if interface.device %}{% url 'dcim:interface_edit' pk=interface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=interface.pk %}{% endif %}" class="btn btn-warning">
<a href="{% url 'dcim:interface_edit' pk=interface.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span> Edit
</a>
{% endif %}
{% if perms.dcim.delete_interface %}
<a href="{% if interface.device %}{% url 'dcim:interface_delete' pk=interface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=interface.pk %}{% endif %}" class="btn btn-danger">
<a href="{% url 'dcim:interface_delete' pk=interface.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span> Delete
</a>
{% endif %}
</div>
<h1>{% block title %}{{ interface.parent }} / {{ interface.name }}{% endblock %}</h1>
<h1>{% block title %}{{ interface.device }} / {{ interface.name }}{% endblock %}</h1>
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ interface.get_absolute_url }}">Interface</a>
@ -49,9 +45,9 @@
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>{% if interface.device %}Device{% else %}Virtual Machine{% endif %}</td>
<td>Device</td>
<td>
<a href="{{ interface.parent.get_absolute_url }}">{{ interface.parent }}</a>
<a href="{{ interface.device.get_absolute_url }}">{{ interface.device }}</a>
</td>
</tr>
<tr>
@ -96,7 +92,7 @@
</tr>
<tr>
<td>MAC Address</td>
<td>{{ interface.mac_address|placeholder }}</span></td>
<td><span class="text-monospace">{{ interface.mac_address|placeholder }}</span></td>
</tr>
<tr>
<td>802.1Q Mode</td>
@ -118,7 +114,7 @@
<tr>
<td>Device</td>
<td>
<a href="{{ connected_interface.parent.get_absolute_url }}">{{ connected_interface.device }}</a>
<a href="{{ connected_interface.device.get_absolute_url }}">{{ connected_interface.device }}</a>
</td>
</tr>
<tr>
@ -225,7 +221,7 @@
{% for member in interface.member_interfaces.all %}
<tr>
<td>
<a href="{{ member.parent.get_absolute_url }}">{{ member.parent }}</a>
<a href="{{ member.device.get_absolute_url }}">{{ member.device }}</a>
</td>
<td>
<a href="{{ member.get_absolute_url }}">{{ member }}</a>

View File

@ -372,6 +372,14 @@
{% endif %}
<a href="{% url 'virtualization:virtualmachine_list' %}">Virtual Machines</a>
</li>
<li{% if not perms.virtualization.view_vminterface%} class="disabled"{% endif %}>
{% if perms.virtualization.add_vminterface %}
<div class="buttons pull-right">
<a href="{% url 'virtualization:vminterface_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'virtualization:vminterface_list' %}">Interfaces</a>
</li>
<li class="divider"></li>
<li class="dropdown-header">Clusters</li>
<li{% if not perms.virtualization.view_cluster %} class="disabled"{% endif %}>

View File

@ -120,8 +120,8 @@
<tr>
<td>Assignment</td>
<td>
{% if ipaddress.interface %}
<span><a href="{{ ipaddress.interface.parent.get_absolute_url }}">{{ ipaddress.interface.parent }}</a> ({{ ipaddress.interface }})</span>
{% if ipaddress.assigned_object %}
<span><a href="{{ ipaddress.assigned_object.parent.get_absolute_url }}">{{ ipaddress.assigned_object.parent }}</a> ({{ ipaddress.assigned_object }})</span>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
@ -132,8 +132,8 @@
<td>
{% if ipaddress.nat_inside %}
<a href="{% url 'ipam:ipaddress' pk=ipaddress.nat_inside.pk %}">{{ ipaddress.nat_inside }}</a>
{% if ipaddress.nat_inside.interface %}
(<a href="{{ ipaddress.nat_inside.interface.parent.get_absolute_url }}">{{ ipaddress.nat_inside.interface.parent }}</a>)
{% if ipaddress.nat_inside.assigned_object %}
(<a href="{{ ipaddress.nat_inside.assigned_object.parent.get_absolute_url }}">{{ ipaddress.nat_inside.assigned_object.parent }}</a>)
{% endif %}
{% else %}
<span class="text-muted">None</span>

View File

@ -28,25 +28,30 @@
{% render_field form.tenant %}
</div>
</div>
{% if obj.interface %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interface Assignment</strong>
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label">{{ obj.interface.parent|meta:"verbose_name"|bettertitle }}</label>
<div class="col-md-9">
<p class="form-control-static">
<a href="{{ obj.interface.parent.get_absolute_url }}">{{ obj.interface.parent }}</a>
</p>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interface Assignment</strong>
</div>
<div class="panel-body">
{% with vm_tab_active=obj.vminterface.exists %}
<ul class="nav nav-tabs" role="tablist">
<li role="presentation"{% if not vm_tab_active %} class="active"{% endif %}><a href="#device" role="tab" data-toggle="tab">Device</a></li>
<li role="presentation"{% if vm_tab_active %} class="active"{% endif %}><a href="#virtualmachine" role="tab" data-toggle="tab">Virtual Machine</a></li>
</ul>
<div class="tab-content">
<div class="tab-pane{% if not vm_tab_active %} active{% endif %}" id="device">
{% render_field form.device %}
{% render_field form.interface %}
</div>
<div class="tab-pane{% if vm_tab_active %} active{% endif %}" id="virtualmachine">
{% render_field form.virtual_machine %}
{% render_field form.vminterface %}
</div>
</div>
{% render_field form.interface %}
{% render_field form.primary_for_parent %}
</div>
{% endwith %}
{% render_field form.primary_for_parent %}
</div>
{% endif %}
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>NAT IP (Inside)</strong></div>
<div class="panel-body">

View File

@ -0,0 +1,141 @@
{% load helpers %}
<tr class="interface{% if not iface.enabled %} danger{% endif %}" id="interface_{{ iface.name }}">
{# Checkbox #}
{% if perms.virtualization.change_interface or perms.virtualization.delete_interface %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ iface.pk }}" />
</td>
{% endif %}
{# Name #}
<td>
<a href="{{ iface.get_absolute_url }}">{{ iface }}</a>
</td>
{# MAC address #}
<td class="text-monospace">
{{ iface.mac_address|default:"&mdash;" }}
</td>
{# MTU #}
<td>{{ iface.mtu|default:"&mdash;" }}</td>
{# 802.1Q mode #}
<td>{{ iface.get_mode_display|default:"&mdash;" }}</td>
{# Description/tags #}
<td>
{% if iface.description %}
{{ iface.description }}<br/>
{% endif %}
{% for tag in iface.tags.all %}
{% tag tag %}
{% empty %}
{% if not iface.description %}&mdash;{% endif %}
{% endfor %}
</td>
{# Buttons #}
<td class="text-right text-nowrap noprint">
{% if show_interface_graphs %}
<button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ virtualmachine.name }} - {{ iface.name }}" data-url="{% url 'virtualization-api:vminterface-graphs' pk=iface.pk %}" title="Show graphs">
<i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
</button>
{% endif %}
{% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ iface.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.virtualization.change_interface %}
<a href="{% url 'virtualization:vminterface_edit' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.virtualization.delete_interface %}
<a href="{% url 'virtualization:vminterface_delete' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}
</td>
</tr>
{% with ipaddresses=iface.ip_addresses.all %}
{% if ipaddresses %}
<tr class="ipaddresses">
{# Placeholder #}
{% if perms.virtualization.change_interface or perms.virtualization.delete_interface %}
<td></td>
{% endif %}
{# IP addresses table #}
<td colspan="9" style="padding: 0">
<table class="table table-condensed interface-ips">
<thead>
<tr class="text-muted">
<th class="col-md-3">IP Address</th>
<th class="col-md-2">Status/Role</th>
<th class="col-md-3">VRF</th>
<th class="col-md-3">Description</th>
<th class="col-md-1"></th>
</tr>
</thead>
{% for ip in iface.ip_addresses.all %}
<tr>
{# IP address #}
<td>
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
</td>
{# Primary/status/role #}
<td>
{% if virtualmachine.primary_ip4 == ip or virtualmachine.primary_ip6 == ip %}
<span class="label label-success">Primary</span>
{% endif %}
<span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
{% if ip.role %}
<span class="label label-{{ ip.get_role_class }}">{{ ip.get_role_display }}</span>
{% endif %}
</td>
{# VRF #}
<td>
{% if ip.vrf %}
<a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}" title="{{ ip.vrf.rd }}">{{ ip.vrf.name }}</a>
{% else %}
<span class="text-muted">Global</span>
{% endif %}
</td>
{# Description #}
<td>
{% if ip.description %}
{{ ip.description }}
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
{# Buttons #}
<td class="text-right text-nowrap noprint">
{% if perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>
</a>
{% endif %}
{% if perms.ipam.delete_ipaddress %}
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</td>
</tr>
{% endif %}
{% endwith %}

View File

@ -248,7 +248,7 @@
</div>
<div class="row">
<div class="col-md-12">
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
{% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="virtual_machine" value="{{ virtualmachine.pk }}" />
@ -268,22 +268,20 @@
<table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
<thead>
<tr>
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
{% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>LAG</th>
<th>Description</th>
<th>MAC Address</th>
<th>MTU</th>
<th>Mode</th>
<th>Cable</th>
<th colspan="2">Connection</th>
<th>Description</th>
<th></th>
</tr>
</thead>
<tbody>
{% for iface in interfaces %}
{% include 'dcim/inc/interface.html' with device=virtualmachine %}
{% include 'virtualization/inc/vminterface.html' %}
{% empty %}
<tr>
<td colspan="8" class="text-center text-muted">&mdash; No interfaces defined &mdash;</td>
@ -291,24 +289,24 @@
{% endfor %}
</tbody>
</table>
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}
{% if perms.virtualization.add_vminterface or perms.virtualization.delete_vminterface %}
<div class="panel-footer noprint">
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
{% if interfaces and perms.virtualization.change_vminterface %}
<button type="submit" name="_rename" formaction="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_edit" formaction="{% url 'virtualization:vminterface_bulk_edit' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
{% endif %}
{% if interfaces and perms.dcim.delete_interface %}
<button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs">
{% if interfaces and perms.virtualization.delete_vminterface %}
<button type="submit" name="_delete" formaction="{% url 'virtualization:vminterface_bulk_delete' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_interface %}
{% if perms.virtualization.add_vminterface %}
<div class="pull-right">
<a href="{% url 'virtualization:interface_add' %}?virtual_machine={{ virtualmachine.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-primary btn-xs">
<a href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ virtualmachine.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a>
</div>
@ -317,7 +315,7 @@
</div>
{% endif %}
</div>
{% if perms.dcim.delete_interface %}
{% if perms.virtualization.delete_vminterface %}
</form>
{% endif %}
</div>

View File

@ -22,12 +22,6 @@
<strong>{{ component_type|bettertitle }}</strong>
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label required">Virtual Machine</label>
<div class="col-md-9">
<p class="form-control-static">{{ parent }}</p>
</div>
</div>
{% render_form form %}
</div>
</div>

View File

@ -7,7 +7,7 @@
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if perms.dcim.add_interface %}<li><a href="{% url 'virtualization:virtualmachine_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
{% if perms.dcim.add_interface %}<li><a href="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
</ul>
</div>
{% endif %}

View File

@ -0,0 +1,100 @@
{% extends 'base.html' %}
{% load helpers %}
{% block header %}
<div class="row noprint">
<div class="col-md-12">
<ol class="breadcrumb">
<li><a href="{% url 'virtualization:virtualmachine_list' %}">Virtual Machines</a></li>
<li><a href="{{ vminterface.virtual_machine.get_absolute_url }}">{{ vminterface.virtual_machine }}</a></li>
<li>{{ vminterface }}</li>
</ol>
</div>
</div>
<div class="pull-right noprint">
{% if perms.virtualization.change_vminterface %}
<a href="{% url 'virtualization:vminterface_edit' pk=vminterface.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span> Edit
</a>
{% endif %}
{% if perms.virtualization.delete_vminterface %}
<a href="{% url 'virtualization:vminterface_delete' pk=vminterface.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span> Delete
</a>
{% endif %}
</div>
<h1>{% block title %}{{ vminterface.virtual_machine }} / {{ vminterface.name }}{% endblock %}</h1>
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ vminterface.get_absolute_url }}">Interface</a>
</li>
{% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'virtualization:vminterface_changelog' pk=vminterface.pk %}">Change Log</a>
</li>
{% endif %}
</ul>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interface</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Virtual Machine</td>
<td>
<a href="{{ vminterface.virtual_machine.get_absolute_url }}">{{ vminterface.virtual_machine }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>{{ vminterface.name }}</td>
</tr>
<tr>
<td>Enabled</td>
<td>
{% if vminterface.enabled %}
<span class="text-success"><i class="fa fa-check"></i></span>
{% else %}
<span class="text-danger"><i class="fa fa-close"></i></span>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>{{ vminterface.description|placeholder }} </td>
</tr>
<tr>
<td>MTU</td>
<td>{{ vminterface.mtu|placeholder }}</td>
</tr>
<tr>
<td>MAC Address</td>
<td><span class="text-monospace">{{ vminterface.mac_address|placeholder }}</span></td>
</tr>
<tr>
<td>802.1Q Mode</td>
<td>{{ vminterface.get_mode_display }}</td>
</tr>
</table>
</div>
</div>
<div class="col-md-6">
{% include 'extras/inc/tags_panel.html' with tags=vminterface.tags.all %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
</div>
</div>
{% endblock %}

View File

@ -21,7 +21,7 @@
{% block buttons %}
{% if obj.pk %}
<button type="submit" name="_update" class="btn btn-primary">Update</button>
<button type="submit" formaction="?return_url={% url 'virtualization:interface_edit' pk=obj.pk %}" class="btn btn-primary">Update and Continue Editing</button>
<button type="submit" formaction="?return_url={% url 'virtualization:vminterface_edit' pk=obj.pk %}" class="btn btn-primary">Update and Continue Editing</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>

View File

@ -5,14 +5,8 @@ from drf_yasg.utils import get_serializer_ref_name
from rest_framework.fields import ChoiceField
from rest_framework.relations import ManyRelatedField
from dcim.api.serializers import InterfaceSerializer as DeviceInterfaceSerializer
from extras.api.customfields import CustomFieldsSerializer
from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer
from virtualization.api.serializers import InterfaceSerializer as VirtualMachineInterfaceSerializer
# this might be ugly, but it limits drf_yasg-specific code to this file
DeviceInterfaceSerializer.Meta.ref_name = 'DeviceInterface'
VirtualMachineInterfaceSerializer.Meta.ref_name = 'VirtualMachineInterface'
class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):

View File

@ -733,6 +733,30 @@ class BulkEditForm(forms.Form):
self.nullable_fields = self.Meta.nullable_fields
class BulkRenameForm(forms.Form):
"""
An extendable form to be used for renaming objects in bulk.
"""
find = forms.CharField()
replace = forms.CharField()
use_regex = forms.BooleanField(
required=False,
initial=True,
label='Use regular expressions'
)
def clean(self):
# Validate regular expression in "find" field
if self.cleaned_data['use_regex']:
try:
re.compile(self.cleaned_data['find'])
except re.error:
raise forms.ValidationError({
'find': "Invalid regular expression"
})
class CSVModelForm(forms.ModelForm):
"""
ModelForm used for the import of objects in CSV format.

View File

@ -1,4 +1,5 @@
import logging
import re
import sys
from copy import deepcopy
@ -963,6 +964,58 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
})
class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
An extendable view for renaming objects in bulk.
"""
queryset = None
form = None
template_name = 'utilities/obj_bulk_rename.html'
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'change')
def post(self, request):
if '_preview' in request.POST or '_apply' in request.POST:
form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
if form.is_valid():
for obj in selected_objects:
find = form.cleaned_data['find']
replace = form.cleaned_data['replace']
if form.cleaned_data['use_regex']:
try:
obj.new_name = re.sub(find, replace, obj.name)
# Catch regex group reference errors
except re.error:
obj.new_name = obj.name
else:
obj.new_name = obj.name.replace(find, replace)
if '_apply' in request.POST:
for obj in selected_objects:
obj.name = obj.new_name
obj.save()
messages.success(request, "Renamed {} {}".format(
len(selected_objects),
self.queryset.model._meta.verbose_name_plural
))
return redirect(self.get_return_url(request))
else:
form = self.form(initial={'pk': request.POST.getlist('pk')})
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
return render(request, self.template_name, {
'form': form,
'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
'selected_objects': selected_objects,
'return_url': self.get_return_url(request),
})
class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Delete objects in bulk.

View File

@ -8,7 +8,7 @@ __all__ = [
'NestedClusterGroupSerializer',
'NestedClusterSerializer',
'NestedClusterTypeSerializer',
'NestedInterfaceSerializer',
'NestedVMInterfaceSerializer',
'NestedVirtualMachineSerializer',
]
@ -56,8 +56,8 @@ class NestedVirtualMachineSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'name']
class NestedInterfaceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail')
class NestedVMInterfaceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail')
virtual_machine = NestedVirtualMachineSerializer(read_only=True)
class Meta:

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, VirtualMachine, VMInterface
from .nested_serializers import *
@ -95,9 +94,8 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
# VM interfaces
#
class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
class VMInterfaceSerializer(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(
@ -108,8 +106,8 @@ class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
)
class Meta:
model = Interface
model = VMInterface
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

@ -21,7 +21,7 @@ router.register('clusters', views.ClusterViewSet)
# VirtualMachines
router.register('virtual-machines', views.VirtualMachineViewSet)
router.register('interfaces', views.InterfaceViewSet)
router.register('interfaces', views.VMInterfaceViewSet)
app_name = 'virtualization-api'
urlpatterns = router.urls

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, VirtualMachine, VMInterface
from . import serializers
@ -71,18 +71,11 @@ class VirtualMachineViewSet(CustomFieldModelViewSet):
return serializers.VirtualMachineWithConfigContextSerializer
class InterfaceViewSet(ModelViewSet):
queryset = Interface.objects.filter(
class VMInterfaceViewSet(ModelViewSet):
queryset = VMInterface.objects.filter(
virtual_machine__isnull=False
).prefetch_related(
'virtual_machine', 'tags'
)
serializer_class = serializers.InterfaceSerializer
filterset_class = filters.InterfaceFilterSet
def get_serializer_class(self):
request = self.get_serializer_context()['request']
if request.query_params.get('brief', False):
# Override get_serializer_for_model(), which will return the DCIM NestedInterfaceSerializer
return serializers.NestedInterfaceSerializer
return serializers.InterfaceSerializer
serializer_class = serializers.VMInterfaceSerializer
filterset_class = filters.VMInterfaceFilterSet

View File

@ -1,4 +1,3 @@
from dcim.choices import InterfaceTypeChoices
from utilities.choices import ChoiceSet
@ -29,16 +28,3 @@ class VirtualMachineStatusChoices(ChoiceSet):
STATUS_ACTIVE: 1,
STATUS_STAGED: 3,
}
#
# Interface types (for VirtualMachines)
#
class VMInterfaceTypeChoices(ChoiceSet):
TYPE_VIRTUAL = InterfaceTypeChoices.TYPE_VIRTUAL
CHOICES = (
(TYPE_VIRTUAL, 'Virtual'),
)

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,14 +9,14 @@ from utilities.filters import (
TreeNodeMultipleChoiceFilter,
)
from .choices import *
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
__all__ = (
'ClusterFilterSet',
'ClusterGroupFilterSet',
'ClusterTypeFilterSet',
'InterfaceFilterSet',
'VirtualMachineFilterSet',
'VMInterfaceFilterSet',
)
@ -201,7 +201,7 @@ class VirtualMachineFilterSet(
)
class InterfaceFilterSet(BaseFilterSet):
class VMInterfaceFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -222,7 +222,7 @@ class InterfaceFilterSet(BaseFilterSet):
)
class Meta:
model = Interface
model = VMInterface
fields = ['id', 'name', 'enabled', 'mtu']
def search(self, queryset, name, value):

View File

@ -4,7 +4,7 @@ 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,
)
@ -14,12 +14,12 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
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,
BulkRenameForm, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
)
from .choices import *
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
#
@ -356,7 +356,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
ip_choices = [(None, '---------')]
# Collect interface IPs
interface_ips = IPAddress.objects.prefetch_related('interface').filter(
address__family=family, interface__virtual_machine=self.instance
address__family=family,
vminterface__in=self.instance.interfaces.values_list('id', flat=True)
)
if interface_ips:
ip_choices.append(
@ -366,7 +367,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
)
# Collect NAT IPs
nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
address__family=family, nat_inside__interface__virtual_machine=self.instance
address__family=family,
nat_inside__vminterface__in=self.instance.interfaces.values_list('id', flat=True)
)
if nat_ips:
ip_choices.append(
@ -569,7 +571,7 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
# VM interfaces
#
class InterfaceForm(BootstrapMixin, forms.ModelForm):
class VMInterfaceForm(BootstrapMixin, forms.ModelForm):
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
@ -598,14 +600,13 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
)
class Meta:
model = Interface
model = VMInterface
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 = {
@ -618,10 +619,13 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
virtual_machine = VirtualMachine.objects.get(
pk=self.initial.get('virtual_machine') or self.data.get('virtual_machine')
)
# Add current site to VLANs query params
site = getattr(self.instance.parent, 'site', None)
if site is not None:
# Add current site to VLANs query params
site = virtual_machine.site
if site:
self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk)
@ -642,19 +646,13 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
self.cleaned_data['tagged_vlans'] = []
class InterfaceCreateForm(BootstrapMixin, forms.Form):
virtual_machine = forms.ModelChoiceField(
queryset=VirtualMachine.objects.all(),
widget=forms.HiddenInput()
class VMInterfaceCreateForm(BootstrapMixin, forms.Form):
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField(
choices=VMInterfaceTypeChoices,
initial=VMInterfaceTypeChoices.TYPE_VIRTUAL,
widget=forms.HiddenInput()
)
enabled = forms.BooleanField(
required=False,
initial=True
@ -712,16 +710,39 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
pk=self.initial.get('virtual_machine') or self.data.get('virtual_machine')
)
site = getattr(virtual_machine.cluster, 'site', None)
if site is not None:
# Add current site to VLANs query params
# Add current site to VLANs query params
site = virtual_machine.site
if site:
self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk)
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
class VMInterfaceCSVForm(CSVModelForm):
virtual_machine = CSVModelChoiceField(
queryset=VirtualMachine.objects.all(),
to_field_name='name'
)
mode = CSVChoiceField(
choices=InterfaceModeChoices,
required=False,
help_text='IEEE 802.1Q operational mode (for L2 interfaces)'
)
class Meta:
model = VMInterface
fields = VMInterface.csv_headers
def clean_enabled(self):
# Make sure enabled is True when it's not included in the uploaded data
if 'enabled' not in self.data:
return True
else:
return self.cleaned_data['enabled']
class VMInterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Interface.objects.all(),
queryset=VMInterface.objects.all(),
widget=forms.MultipleHiddenInput()
)
virtual_machine = forms.ModelChoiceField(
@ -789,6 +810,24 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk)
class VMInterfaceBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField(
queryset=VMInterface.objects.all(),
widget=forms.MultipleHiddenInput()
)
class VMInterfaceFilterForm(forms.Form):
model = VMInterface
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(model)
#
# Bulk VirtualMachine component creation
#
@ -808,12 +847,8 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form):
return ','.join(self.cleaned_data.get('tags'))
class InterfaceBulkCreateForm(
form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']),
class VMInterfaceBulkCreateForm(
form_from_model(VMInterface, ['enabled', 'mtu', 'description', 'tags']),
VirtualMachineBulkAddComponentForm
):
type = forms.ChoiceField(
choices=VMInterfaceTypeChoices,
initial=VMInterfaceTypeChoices.TYPE_VIRTUAL,
widget=forms.HiddenInput()
)
pass

View File

@ -0,0 +1,44 @@
# 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='VMInterface',
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='vminterfaces_as_tagged', to='ipam.VLAN')),
('tags', taggit.managers.TaggableManager(related_name='vminterface', through='extras.TaggedItem', to='extras.Tag')),
('untagged_vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vminterfaces_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')},
'verbose_name': 'interface',
},
),
]

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('extras', 'TaggedItem')
Interface = apps.get_model('dcim', 'Interface')
IPAddress = apps.get_model('ipam', 'IPAddress')
VMInterface = apps.get_model('virtualization', 'VMInterface')
interface_ct = ContentType.objects.get_for_model(Interface)
vminterface_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:
vminterface = 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,
)
vminterface.save()
# Copy tagged VLANs
vminterface.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=vminterface_ct, object_id=vminterface.pk
)
# Update any assigned IPAddresses
IPAddress.objects.filter(assigned_object_id=interface.pk).update(
assigned_object_type=vminterface_ct,
assigned_object_id=vminterface.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_vminterface'),
]
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 *
@ -18,6 +21,7 @@ __all__ = (
'ClusterGroup',
'ClusterType',
'VirtualMachine',
'VMInterface',
)
@ -370,3 +374,111 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
@property
def site(self):
return self.cluster.site
#
# Interfaces
#
@extras_features('graphs', 'export_templates', 'webhooks')
class VMInterface(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='vminterfaces_as_untagged',
null=True,
blank=True,
verbose_name='Untagged VLAN'
)
tagged_vlans = models.ManyToManyField(
to='ipam.VLAN',
related_name='vminterfaces_as_tagged',
blank=True,
verbose_name='Tagged VLANs'
)
ip_addresses = GenericRelation(
to='ipam.IPAddress',
content_type_field='assigned_object_type',
object_id_field='assigned_object_id',
related_query_name='vminterface'
)
tags = TaggableManager(
through=TaggedItem,
related_name='vminterface'
)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
]
class Meta:
verbose_name = 'interface'
ordering = ('virtual_machine', CollateAsChar('_name'))
unique_together = ('virtual_machine', 'name')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('virtualization:vminterface', 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.virtual_machine.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, VirtualMachine, VMInterface
CLUSTERTYPE_ACTIONS = """
<a href="{% url 'virtualization:clustertype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
@ -173,8 +172,12 @@ class VirtualMachineDetailTable(VirtualMachineTable):
# VM components
#
class InterfaceTable(BaseTable):
class VMInterfaceTable(BaseTable):
virtual_machine = tables.LinkColumn()
name = tables.Column(
linkify=True
)
class Meta(BaseTable.Meta):
model = Interface
fields = ('name', 'enabled', 'description')
model = VMInterface
fields = ('virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description')

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, VirtualMachine, VMInterface
class AppTest(APITestCase):
@ -196,7 +194,7 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
# TODO: Standardize InterfaceTest (pending #4721)
class InterfaceTest(APITestCase):
class VMInterfaceTest(APITestCase):
def setUp(self):
@ -205,20 +203,17 @@ class InterfaceTest(APITestCase):
clustertype = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
cluster = Cluster.objects.create(name='Test Cluster 1', type=clustertype)
self.virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1')
self.interface1 = Interface.objects.create(
self.interface1 = VMInterface.objects.create(
virtual_machine=self.virtualmachine,
name='Test Interface 1',
type=InterfaceTypeChoices.TYPE_VIRTUAL
name='Test Interface 1'
)
self.interface2 = Interface.objects.create(
self.interface2 = VMInterface.objects.create(
virtual_machine=self.virtualmachine,
name='Test Interface 2',
type=InterfaceTypeChoices.TYPE_VIRTUAL
name='Test Interface 2'
)
self.interface3 = Interface.objects.create(
self.interface3 = VMInterface.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)
@ -226,22 +221,22 @@ class InterfaceTest(APITestCase):
self.vlan3 = VLAN.objects.create(name="Test VLAN 3", vid=3)
def test_get_interface(self):
url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
self.add_permissions('dcim.view_interface')
url = reverse('virtualization-api:vminterface-detail', kwargs={'pk': self.interface1.pk})
self.add_permissions('virtualization.view_vminterface')
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')
url = reverse('virtualization-api:vminterface-list')
self.add_permissions('virtualization.view_vminterface')
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')
url = reverse('virtualization-api:vminterface-list')
self.add_permissions('virtualization.view_vminterface')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
@ -254,13 +249,13 @@ class InterfaceTest(APITestCase):
'virtual_machine': self.virtualmachine.pk,
'name': 'Test Interface 4',
}
url = reverse('virtualization-api:interface-list')
self.add_permissions('dcim.add_interface')
url = reverse('virtualization-api:vminterface-list')
self.add_permissions('virtualization.add_vminterface')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Interface.objects.count(), 4)
interface4 = Interface.objects.get(pk=response.data['id'])
self.assertEqual(VMInterface.objects.count(), 4)
interface4 = VMInterface.objects.get(pk=response.data['id'])
self.assertEqual(interface4.virtual_machine_id, data['virtual_machine'])
self.assertEqual(interface4.name, data['name'])
@ -272,12 +267,12 @@ class InterfaceTest(APITestCase):
'untagged_vlan': self.vlan3.id,
'tagged_vlans': [self.vlan1.id, self.vlan2.id],
}
url = reverse('virtualization-api:interface-list')
self.add_permissions('dcim.add_interface')
url = reverse('virtualization-api:vminterface-list')
self.add_permissions('virtualization.add_vminterface')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Interface.objects.count(), 4)
self.assertEqual(VMInterface.objects.count(), 4)
self.assertEqual(response.data['virtual_machine']['id'], data['virtual_machine'])
self.assertEqual(response.data['name'], data['name'])
self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan'])
@ -298,12 +293,12 @@ class InterfaceTest(APITestCase):
'name': 'Test Interface 6',
},
]
url = reverse('virtualization-api:interface-list')
self.add_permissions('dcim.add_interface')
url = reverse('virtualization-api:vminterface-list')
self.add_permissions('virtualization.add_vminterface')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Interface.objects.count(), 6)
self.assertEqual(VMInterface.objects.count(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
@ -332,12 +327,12 @@ class InterfaceTest(APITestCase):
'tagged_vlans': [self.vlan1.id],
},
]
url = reverse('virtualization-api:interface-list')
self.add_permissions('dcim.add_interface')
url = reverse('virtualization-api:vminterface-list')
self.add_permissions('virtualization.add_vminterface')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Interface.objects.count(), 6)
self.assertEqual(VMInterface.objects.count(), 6)
for i in range(0, 3):
self.assertEqual(response.data[i]['name'], data[i]['name'])
self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans'])
@ -348,19 +343,19 @@ class InterfaceTest(APITestCase):
'virtual_machine': self.virtualmachine.pk,
'name': 'Test Interface X',
}
url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
self.add_permissions('dcim.change_interface')
url = reverse('virtualization-api:vminterface-detail', kwargs={'pk': self.interface1.pk})
self.add_permissions('virtualization.change_vminterface')
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Interface.objects.count(), 3)
interface1 = Interface.objects.get(pk=response.data['id'])
self.assertEqual(VMInterface.objects.count(), 3)
interface1 = VMInterface.objects.get(pk=response.data['id'])
self.assertEqual(interface1.name, data['name'])
def test_delete_interface(self):
url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
self.add_permissions('dcim.delete_interface')
url = reverse('virtualization-api:vminterface-detail', kwargs={'pk': self.interface1.pk})
self.add_permissions('virtualization.delete_vminterface')
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Interface.objects.count(), 2)
self.assertEqual(VMInterface.objects.count(), 2)

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, VirtualMachine, VMInterface
class ClusterTypeTestCase(TestCase):
@ -260,11 +260,11 @@ class VirtualMachineTestCase(TestCase):
VirtualMachine.objects.bulk_create(vms)
interfaces = (
Interface(virtual_machine=vms[0], name='Interface 1', mac_address='00-00-00-00-00-01'),
Interface(virtual_machine=vms[1], name='Interface 2', mac_address='00-00-00-00-00-02'),
Interface(virtual_machine=vms[2], name='Interface 3', mac_address='00-00-00-00-00-03'),
VMInterface(virtual_machine=vms[0], name='Interface 1', mac_address='00-00-00-00-00-01'),
VMInterface(virtual_machine=vms[1], name='Interface 2', mac_address='00-00-00-00-00-02'),
VMInterface(virtual_machine=vms[2], name='Interface 3', mac_address='00-00-00-00-00-03'),
)
Interface.objects.bulk_create(interfaces)
VMInterface.objects.bulk_create(interfaces)
def test_id(self):
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
@ -365,9 +365,9 @@ class VirtualMachineTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class InterfaceTestCase(TestCase):
queryset = Interface.objects.all()
filterset = InterfaceFilterSet
class VMInterfaceTestCase(TestCase):
queryset = VMInterface.objects.all()
filterset = VMInterfaceFilterSet
@classmethod
def setUpTestData(cls):
@ -394,11 +394,11 @@ class InterfaceTestCase(TestCase):
VirtualMachine.objects.bulk_create(vms)
interfaces = (
Interface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01'),
Interface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02'),
Interface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'),
VMInterface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01'),
VMInterface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02'),
VMInterface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'),
)
Interface.objects.bulk_create(interfaces)
VMInterface.objects.bulk_create(interfaces)
def test_id(self):
id_list = self.queryset.values_list('id', flat=True)[:2]

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, VirtualMachine, VMInterface
class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@ -189,21 +189,11 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
# TODO: Update base class to DeviceComponentViewTestCase
# Blocked by #4721
class InterfaceTestCase(
class VMInterfaceTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.BulkCreateObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase,
ViewTestCases.DeviceComponentViewTestCase,
):
model = Interface
def _get_base_url(self):
# Interface belongs to the DCIM app, so we have to override the base URL
return 'virtualization:interface_{}'
model = VMInterface
@classmethod
def setUpTestData(cls):
@ -218,10 +208,10 @@ 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),
VMInterface.objects.bulk_create([
VMInterface(virtual_machine=virtualmachines[0], name='Interface 1'),
VMInterface(virtual_machine=virtualmachines[0], name='Interface 2'),
VMInterface(virtual_machine=virtualmachines[0], name='Interface 3'),
])
vlans = (
@ -237,9 +227,7 @@ 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'),
'mtu': 2000,
'description': 'New description',
@ -252,9 +240,7 @@ 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'),
'mtu': 2000,
'description': 'New description',
@ -264,19 +250,19 @@ class InterfaceTestCase(
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"virtual_machine,name",
"Virtual Machine 2,Interface 4",
"Virtual Machine 2,Interface 5",
"Virtual Machine 2,Interface 6",
)
cls.bulk_edit_data = {
'virtual_machine': virtualmachines[1].pk,
'enabled': False,
'mtu': 2000,
'description': 'New description',
'mode': InterfaceModeChoices.MODE_TAGGED,
# 'untagged_vlan': vlans[0].pk,
# 'tagged_vlans': [v.pk for v in vlans[1:4]],
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
}
cls.csv_data = (
"device,name,type",
"Device 1,Interface 4,1000BASE-T (1GE)",
"Device 1,Interface 5,1000BASE-T (1GE)",
"Device 1,Interface 6,1000BASE-T (1GE)",
)

View File

@ -3,7 +3,7 @@ from django.urls import path
from extras.views import ObjectChangeLogView
from ipam.views import ServiceEditView
from . import views
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
app_name = 'virtualization'
urlpatterns = [
@ -51,11 +51,16 @@ urlpatterns = [
path('virtual-machines/<int:virtualmachine>/services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'),
# VM interfaces
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>/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'),
path('interfaces/', views.VMInterfaceListView.as_view(), name='vminterface_list'),
path('interfaces/add/', views.VMInterfaceCreateView.as_view(), name='vminterface_add'),
path('interfaces/import/', views.VMInterfaceBulkImportView.as_view(), name='vminterface_import'),
path('interfaces/edit/', views.VMInterfaceBulkEditView.as_view(), name='vminterface_bulk_edit'),
path('interfaces/rename/', views.VMInterfaceBulkRenameView.as_view(), name='vminterface_bulk_rename'),
path('interfaces/delete/', views.VMInterfaceBulkDeleteView.as_view(), name='vminterface_bulk_delete'),
path('interfaces/<int:pk>/', views.VMInterfaceView.as_view(), name='vminterface'),
path('interfaces/<int:pk>/edit/', views.VMInterfaceEditView.as_view(), name='vminterface_edit'),
path('interfaces/<int:pk>/delete/', views.VMInterfaceDeleteView.as_view(), name='vminterface_delete'),
path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vminterface_changelog', kwargs={'model': VMInterface}),
path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'),
]

View File

@ -4,16 +4,17 @@ 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.tables import DeviceTable
from extras.views import ObjectConfigContextView
from ipam.models import Service
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectView,
ObjectDeleteView, ObjectEditView, ObjectListView,
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
#
@ -235,7 +236,7 @@ class VirtualMachineView(ObjectView):
def get(self, request, pk):
virtualmachine = get_object_or_404(self.queryset, pk=pk)
interfaces = Interface.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine)
interfaces = VMInterface.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine)
services = Service.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine)
return render(request, 'virtualization/virtualmachine.html', {
@ -288,32 +289,87 @@ class VirtualMachineBulkDeleteView(BulkDeleteView):
# VM interfaces
#
class InterfaceCreateView(ComponentCreateView):
queryset = Interface.objects.all()
form = forms.InterfaceCreateForm
model_form = forms.InterfaceForm
class VMInterfaceListView(ObjectListView):
queryset = VMInterface.objects.prefetch_related('virtual_machine')
filterset = filters.VMInterfaceFilterSet
filterset_form = forms.VMInterfaceFilterForm
table = tables.VMInterfaceTable
action_buttons = ('export',)
class VMInterfaceView(ObjectView):
queryset = VMInterface.objects.all()
def get(self, request, pk):
vminterface = get_object_or_404(self.queryset, pk=pk)
# Get assigned IP addresses
ipaddress_table = InterfaceIPAddressTable(
data=vminterface.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
orderable=False
)
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if vminterface.untagged_vlan is not None:
vlans.append(vminterface.untagged_vlan)
vlans[0].tagged = False
for vlan in vminterface.tagged_vlans.prefetch_related('site', 'group', 'tenant', 'role'):
vlan.tagged = True
vlans.append(vlan)
vlan_table = InterfaceVLANTable(
interface=vminterface,
data=vlans,
orderable=False
)
return render(request, 'virtualization/vminterface.html', {
'vminterface': vminterface,
'ipaddress_table': ipaddress_table,
'vlan_table': vlan_table,
})
# TODO: This should not use ComponentCreateView
class VMInterfaceCreateView(ComponentCreateView):
queryset = VMInterface.objects.all()
form = forms.VMInterfaceCreateForm
model_form = forms.VMInterfaceForm
template_name = 'virtualization/virtualmachine_component_add.html'
class InterfaceEditView(ObjectEditView):
queryset = Interface.objects.all()
model_form = forms.InterfaceForm
template_name = 'virtualization/interface_edit.html'
class VMInterfaceEditView(ObjectEditView):
queryset = VMInterface.objects.all()
model_form = forms.VMInterfaceForm
template_name = 'virtualization/vminterface_edit.html'
class InterfaceDeleteView(ObjectDeleteView):
queryset = Interface.objects.all()
class VMInterfaceDeleteView(ObjectDeleteView):
queryset = VMInterface.objects.all()
class InterfaceBulkEditView(BulkEditView):
queryset = Interface.objects.all()
table = tables.InterfaceTable
form = forms.InterfaceBulkEditForm
class VMInterfaceBulkImportView(BulkImportView):
queryset = VMInterface.objects.all()
model_form = forms.VMInterfaceCSVForm
table = tables.VMInterfaceTable
default_return_url = 'virtualization:vminterface_list'
class InterfaceBulkDeleteView(BulkDeleteView):
queryset = Interface.objects.all()
table = tables.InterfaceTable
class VMInterfaceBulkEditView(BulkEditView):
queryset = VMInterface.objects.all()
table = tables.VMInterfaceTable
form = forms.VMInterfaceBulkEditForm
class VMInterfaceBulkRenameView(BulkRenameView):
queryset = VMInterface.objects.all()
form = forms.VMInterfaceBulkRenameForm
class VMInterfaceBulkDeleteView(BulkDeleteView):
queryset = VMInterface.objects.all()
table = tables.VMInterfaceTable
#
@ -323,9 +379,9 @@ class InterfaceBulkDeleteView(BulkDeleteView):
class VirtualMachineBulkAddInterfaceView(BulkComponentCreateView):
parent_model = VirtualMachine
parent_field = 'virtual_machine'
form = forms.InterfaceBulkCreateForm
queryset = Interface.objects.all()
model_form = forms.InterfaceForm
form = forms.VMInterfaceBulkCreateForm
queryset = VMInterface.objects.all()
model_form = forms.VMInterfaceForm
filterset = filters.VirtualMachineFilterSet
table = tables.VirtualMachineTable
default_return_url = 'virtualization:virtualmachine_list'