mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 20:12:00 -06:00
Merge branch 'virtualization' into develop-2.2
This commit is contained in:
commit
00024240bb
@ -96,4 +96,4 @@ VLAN groups can be employed for administrative organization within NetBox. Each
|
||||
|
||||
# Services
|
||||
|
||||
A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.)
|
||||
A service represents a TCP or UDP service available on a device or virtual machine. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to its parent. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.)
|
||||
|
29
docs/data-model/virtualization.md
Normal file
29
docs/data-model/virtualization.md
Normal file
@ -0,0 +1,29 @@
|
||||
NetBox supports the definition of virtual machines arranged in clusters. A cluster can optionally have physical host devices associated with it.
|
||||
|
||||
# Clusters
|
||||
|
||||
A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type, and may optionally be assigned an organizational group.
|
||||
|
||||
Physical devices (from NetBox's DCIM component) may be associated with clusters as hosts. This allows users to track on which host(s) a particular VM may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device.
|
||||
|
||||
### Cluster Types
|
||||
|
||||
A cluster type represents a technology or mechanism by which a cluster is formed. For example, you might create a cluster type named "VMware vSphere" for a locally hosted cluster or "DigitalOcean NYC3" for one hosted by a cloud provider.
|
||||
|
||||
### Cluster Groups
|
||||
|
||||
Cluster groups may be created for the purpose of organizing clusters.
|
||||
|
||||
---
|
||||
|
||||
# Virtual Machines
|
||||
|
||||
A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be associated with exactly one cluster.
|
||||
|
||||
Like devices, each VM can have interfaces created on it. These behave similarly to device interfaces, and can be assigned IP addresses, however given their virtual nature they cannot be connected to other interfaces. VMs can also be assigned layer four services. Unlike physical devices, VMs cannot be assigned console or power ports, or device bays.
|
||||
|
||||
The following resources can be defined for each VM:
|
||||
|
||||
* vCPU count
|
||||
* Memory (MB)
|
||||
* Disk space (GB)
|
@ -18,6 +18,7 @@ pages:
|
||||
- 'IPAM': 'data-model/ipam.md'
|
||||
- 'Secrets': 'data-model/secrets.md'
|
||||
- 'Tenancy': 'data-model/tenancy.md'
|
||||
- 'Virtualization': 'data-model/virtualization.md'
|
||||
- 'Extras': 'data-model/extras.md'
|
||||
- 'API':
|
||||
- 'Overview': 'api/overview.md'
|
||||
|
@ -16,6 +16,7 @@ from dcim.models import (
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from tenancy.api.serializers import NestedTenantSerializer
|
||||
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
||||
from virtualization.models import Cluster
|
||||
|
||||
|
||||
#
|
||||
@ -446,6 +447,15 @@ class DeviceIPAddressSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'url', 'family', 'address']
|
||||
|
||||
|
||||
# Cannot import virtualization.api.NestedClusterSerializer due to circular dependency
|
||||
class NestedClusterSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class DeviceSerializer(CustomFieldModelSerializer):
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_role = NestedDeviceRoleSerializer()
|
||||
@ -459,13 +469,14 @@ class DeviceSerializer(CustomFieldModelSerializer):
|
||||
primary_ip4 = DeviceIPAddressSerializer()
|
||||
primary_ip6 = DeviceIPAddressSerializer()
|
||||
parent_device = serializers.SerializerMethodField()
|
||||
cluster = NestedClusterSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = [
|
||||
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
||||
'comments', 'custom_fields',
|
||||
'cluster', 'comments', 'custom_fields',
|
||||
]
|
||||
|
||||
def get_parent_device(self, obj):
|
||||
|
@ -93,13 +93,15 @@ IFACE_FF_JUNIPER_VCP = 5200
|
||||
# Other
|
||||
IFACE_FF_OTHER = 32767
|
||||
|
||||
VIFACE_FF_CHOICES = [
|
||||
[IFACE_FF_VIRTUAL, 'Virtual'],
|
||||
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
|
||||
]
|
||||
|
||||
IFACE_FF_CHOICES = [
|
||||
[
|
||||
'Virtual interfaces',
|
||||
[
|
||||
[IFACE_FF_VIRTUAL, 'Virtual'],
|
||||
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
|
||||
]
|
||||
VIFACE_FF_CHOICES,
|
||||
],
|
||||
[
|
||||
'Ethernet (fixed)',
|
||||
|
@ -10,6 +10,7 @@ from django.db.models import Q
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NullableCharFieldFilter, NullableModelMultipleChoiceFilter, NumericInFilter
|
||||
from virtualization.models import Cluster
|
||||
from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
|
||||
@ -408,6 +409,10 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack (ID)',
|
||||
)
|
||||
cluster_id = NullableModelMultipleChoiceFilter(
|
||||
queryset=Cluster.objects.all(),
|
||||
label='VM cluster (ID)',
|
||||
)
|
||||
model = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device_type__slug',
|
||||
queryset=DeviceType.objects.all(),
|
||||
|
@ -13,9 +13,9 @@ from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ConfirmationForm, CSVChoiceField, ExpandableNameField,
|
||||
FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
|
||||
FilterTreeNodeMultipleChoiceField,
|
||||
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField,
|
||||
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
|
||||
SlugField, FilterTreeNodeMultipleChoiceField,
|
||||
)
|
||||
from .formfields import MACAddressFormField
|
||||
from .models import (
|
||||
@ -49,15 +49,6 @@ def get_device_by_name_or_pk(name):
|
||||
return device
|
||||
|
||||
|
||||
class DeviceComponentForm(BootstrapMixin, forms.Form):
|
||||
"""
|
||||
Allow inclusion of the parent device as context for limiting field choices.
|
||||
"""
|
||||
def __init__(self, device, *args, **kwargs):
|
||||
self.device = device
|
||||
super(DeviceComponentForm, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
#
|
||||
# Regions
|
||||
#
|
||||
@ -452,7 +443,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ConsolePortTemplateCreateForm(DeviceComponentForm):
|
||||
class ConsolePortTemplateCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -466,7 +457,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateCreateForm(DeviceComponentForm):
|
||||
class ConsoleServerPortTemplateCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -480,7 +471,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class PowerPortTemplateCreateForm(DeviceComponentForm):
|
||||
class PowerPortTemplateCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -494,7 +485,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletTemplateCreateForm(DeviceComponentForm):
|
||||
class PowerOutletTemplateCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -508,7 +499,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class InterfaceTemplateCreateForm(DeviceComponentForm):
|
||||
class InterfaceTemplateCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
|
||||
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
|
||||
@ -533,7 +524,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayTemplateCreateForm(DeviceComponentForm):
|
||||
class DeviceBayTemplateCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -933,7 +924,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ConsolePortCreateForm(DeviceComponentForm):
|
||||
class ConsolePortCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -1102,7 +1093,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortCreateForm(DeviceComponentForm):
|
||||
class ConsoleServerPortCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -1194,7 +1185,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class PowerPortCreateForm(DeviceComponentForm):
|
||||
class PowerPortCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -1363,7 +1354,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletCreateForm(DeviceComponentForm):
|
||||
class PowerOutletCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -1468,7 +1459,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
|
||||
)
|
||||
|
||||
|
||||
class InterfaceCreateForm(DeviceComponentForm):
|
||||
class InterfaceCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
|
||||
enabled = forms.BooleanField(required=False)
|
||||
@ -1487,9 +1478,9 @@ class InterfaceCreateForm(DeviceComponentForm):
|
||||
super(InterfaceCreateForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces belonging to this device
|
||||
if self.device is not None:
|
||||
if self.parent is not None:
|
||||
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
|
||||
device=self.device, form_factor=IFACE_FF_LAG
|
||||
device=self.parent, form_factor=IFACE_FF_LAG
|
||||
)
|
||||
else:
|
||||
self.fields['lag'].queryset = Interface.objects.none()
|
||||
@ -1715,7 +1706,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayCreateForm(DeviceComponentForm):
|
||||
class DeviceBayCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
|
32
netbox/dcim/migrations/0044_virtualization.py
Normal file
32
netbox/dcim/migrations/0044_virtualization.py
Normal file
@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.4 on 2017-08-31 14:15
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('virtualization', '0001_virtualization'),
|
||||
('dcim', '0043_device_component_name_lengths'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='cluster',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='virtualization.Cluster'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='virtual_machine',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='device',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device'),
|
||||
),
|
||||
]
|
@ -808,6 +808,13 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True,
|
||||
verbose_name='Primary IPv6'
|
||||
)
|
||||
cluster = models.ForeignKey(
|
||||
to='virtualization.Cluster',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='devices',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
images = GenericRelation(ImageAttachment)
|
||||
@ -1145,13 +1152,26 @@ class PowerOutlet(models.Model):
|
||||
@python_2_unicode_compatible
|
||||
class Interface(models.Model):
|
||||
"""
|
||||
A physical data interface within a Device. An Interface can connect to exactly one other Interface via the creation
|
||||
of an InterfaceConnection.
|
||||
A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
|
||||
Interface via the creation of an InterfaceConnection.
|
||||
"""
|
||||
device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
|
||||
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
|
||||
)
|
||||
lag = models.ForeignKey(
|
||||
'self',
|
||||
models.SET_NULL,
|
||||
to='self',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='member_interfaces',
|
||||
null=True,
|
||||
blank=True,
|
||||
@ -1180,6 +1200,18 @@ class Interface(models.Model):
|
||||
|
||||
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.form_factor not in VIRTUAL_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'form_factor': "Virtual machines cannot have physical interfaces."
|
||||
})
|
||||
|
||||
# Virtual interfaces cannot be connected
|
||||
if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.is_connected:
|
||||
raise ValidationError({
|
||||
@ -1209,6 +1241,10 @@ class Interface(models.Model):
|
||||
)
|
||||
})
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
return self.device or self.virtual_machine
|
||||
|
||||
@property
|
||||
def is_virtual(self):
|
||||
return self.form_factor in VIRTUAL_IFACE_TYPES
|
||||
|
@ -126,7 +126,7 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
|
||||
url(r'^devices/(?P<pk>\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'),
|
||||
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
|
||||
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'),
|
||||
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='device_service_assign'),
|
||||
url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
|
||||
|
||||
# Console ports
|
||||
|
@ -23,7 +23,8 @@ from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_S
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView,
|
||||
ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
from . import filters, forms, tables
|
||||
from .models import (
|
||||
@ -60,87 +61,6 @@ def expand_pattern(string):
|
||||
yield "{0}{1}".format(lead, i)
|
||||
|
||||
|
||||
class ComponentCreateView(View):
|
||||
parent_model = None
|
||||
parent_field = None
|
||||
model = None
|
||||
form = None
|
||||
model_form = None
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
parent = get_object_or_404(self.parent_model, pk=pk)
|
||||
form = self.form(parent, initial=request.GET)
|
||||
|
||||
return render(request, 'dcim/device_component_add.html', {
|
||||
'parent': parent,
|
||||
'component_type': self.model._meta.verbose_name,
|
||||
'form': form,
|
||||
'return_url': parent.get_absolute_url(),
|
||||
})
|
||||
|
||||
def post(self, request, pk):
|
||||
|
||||
parent = get_object_or_404(self.parent_model, pk=pk)
|
||||
|
||||
form = self.form(parent, request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
new_components = []
|
||||
data = deepcopy(form.cleaned_data)
|
||||
|
||||
for name in form.cleaned_data['name_pattern']:
|
||||
component_data = {
|
||||
self.parent_field: parent.pk,
|
||||
'name': name,
|
||||
}
|
||||
# Replace objects with their primary key to keep component_form.clean() happy
|
||||
for k, v in data.items():
|
||||
if hasattr(v, 'pk'):
|
||||
component_data[k] = v.pk
|
||||
else:
|
||||
component_data[k] = v
|
||||
component_form = self.model_form(component_data)
|
||||
if component_form.is_valid():
|
||||
new_components.append(component_form.save(commit=False))
|
||||
else:
|
||||
for field, errors in component_form.errors.as_data().items():
|
||||
# Assign errors on the child form's name field to name_pattern on the parent form
|
||||
if field == 'name':
|
||||
field = 'name_pattern'
|
||||
for e in errors:
|
||||
form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
|
||||
|
||||
if not form.errors:
|
||||
self.model.objects.bulk_create(new_components)
|
||||
messages.success(request, "Added {} {} to {}.".format(
|
||||
len(new_components), self.model._meta.verbose_name_plural, parent
|
||||
))
|
||||
if '_addanother' in request.POST:
|
||||
return redirect(request.path)
|
||||
else:
|
||||
return redirect(parent.get_absolute_url())
|
||||
|
||||
return render(request, 'dcim/device_component_add.html', {
|
||||
'parent': parent,
|
||||
'component_type': self.model._meta.verbose_name,
|
||||
'form': form,
|
||||
'return_url': parent.get_absolute_url(),
|
||||
})
|
||||
|
||||
|
||||
class ComponentEditView(ObjectEditView):
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return obj.device.get_absolute_url()
|
||||
|
||||
|
||||
class ComponentDeleteView(ObjectDeleteView):
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return obj.device.get_absolute_url()
|
||||
|
||||
|
||||
class BulkDisconnectView(View):
|
||||
"""
|
||||
An extendable view for disconnection console/power/interface components in bulk.
|
||||
@ -662,6 +582,7 @@ class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
|
||||
model = ConsolePortTemplate
|
||||
form = forms.ConsolePortTemplateCreateForm
|
||||
model_form = forms.ConsolePortTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@ -680,6 +601,7 @@ class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCrea
|
||||
model = ConsoleServerPortTemplate
|
||||
form = forms.ConsoleServerPortTemplateCreateForm
|
||||
model_form = forms.ConsoleServerPortTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@ -696,6 +618,7 @@ class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
|
||||
model = PowerPortTemplate
|
||||
form = forms.PowerPortTemplateCreateForm
|
||||
model_form = forms.PowerPortTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@ -712,6 +635,7 @@ class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
|
||||
model = PowerOutletTemplate
|
||||
form = forms.PowerOutletTemplateCreateForm
|
||||
model_form = forms.PowerOutletTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@ -728,6 +652,7 @@ class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
|
||||
model = InterfaceTemplate
|
||||
form = forms.InterfaceTemplateCreateForm
|
||||
model_form = forms.InterfaceTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
@ -752,6 +677,7 @@ class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
|
||||
model = DeviceBayTemplate
|
||||
form = forms.DeviceBayTemplateCreateForm
|
||||
model_form = forms.DeviceBayTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@ -1035,6 +961,7 @@ class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
|
||||
model = ConsolePort
|
||||
form = forms.ConsolePortCreateForm
|
||||
model_form = forms.ConsolePortForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
@permission_required('dcim.change_consoleport')
|
||||
@ -1116,12 +1043,14 @@ def consoleport_disconnect(request, pk):
|
||||
class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_consoleport'
|
||||
model = ConsolePort
|
||||
parent_field = 'device'
|
||||
form_class = forms.ConsolePortForm
|
||||
|
||||
|
||||
class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_consoleport'
|
||||
model = ConsolePort
|
||||
parent_field = 'device'
|
||||
|
||||
|
||||
class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@ -1149,6 +1078,7 @@ class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
|
||||
model = ConsoleServerPort
|
||||
form = forms.ConsoleServerPortCreateForm
|
||||
model_form = forms.ConsoleServerPortForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
@permission_required('dcim.change_consoleserverport')
|
||||
@ -1233,12 +1163,14 @@ def consoleserverport_disconnect(request, pk):
|
||||
class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_consoleserverport'
|
||||
model = ConsoleServerPort
|
||||
parent_field = 'device'
|
||||
form_class = forms.ConsoleServerPortForm
|
||||
|
||||
|
||||
class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_consoleserverport'
|
||||
model = ConsoleServerPort
|
||||
parent_field = 'device'
|
||||
|
||||
|
||||
class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
|
||||
@ -1268,6 +1200,7 @@ class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
|
||||
model = PowerPort
|
||||
form = forms.PowerPortCreateForm
|
||||
model_form = forms.PowerPortForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
@permission_required('dcim.change_powerport')
|
||||
@ -1349,12 +1282,14 @@ def powerport_disconnect(request, pk):
|
||||
class PowerPortEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_powerport'
|
||||
model = PowerPort
|
||||
parent_field = 'device'
|
||||
form_class = forms.PowerPortForm
|
||||
|
||||
|
||||
class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_powerport'
|
||||
model = PowerPort
|
||||
parent_field = 'device'
|
||||
|
||||
|
||||
class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@ -1382,6 +1317,7 @@ class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
|
||||
model = PowerOutlet
|
||||
form = forms.PowerOutletCreateForm
|
||||
model_form = forms.PowerOutletForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
@permission_required('dcim.change_poweroutlet')
|
||||
@ -1466,12 +1402,14 @@ def poweroutlet_disconnect(request, pk):
|
||||
class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_poweroutlet'
|
||||
model = PowerOutlet
|
||||
parent_field = 'device'
|
||||
form_class = forms.PowerOutletForm
|
||||
|
||||
|
||||
class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_poweroutlet'
|
||||
model = PowerOutlet
|
||||
parent_field = 'device'
|
||||
|
||||
|
||||
class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
|
||||
@ -1503,17 +1441,20 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
|
||||
model = Interface
|
||||
form = forms.InterfaceCreateForm
|
||||
model_form = forms.InterfaceForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_interface'
|
||||
model = Interface
|
||||
parent_field = 'device'
|
||||
form_class = forms.InterfaceForm
|
||||
|
||||
|
||||
class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_interface'
|
||||
model = Interface
|
||||
parent_field = 'device'
|
||||
|
||||
|
||||
class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
|
||||
@ -1554,17 +1495,20 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
|
||||
model = DeviceBay
|
||||
form = forms.DeviceBayCreateForm
|
||||
model_form = forms.DeviceBayForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_devicebay'
|
||||
model = DeviceBay
|
||||
parent_field = 'device'
|
||||
form_class = forms.DeviceBayForm
|
||||
|
||||
|
||||
class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_devicebay'
|
||||
model = DeviceBay
|
||||
parent_field = 'device'
|
||||
|
||||
|
||||
@permission_required('dcim.change_devicebay')
|
||||
@ -1871,6 +1815,7 @@ class InterfaceConnectionsListView(ObjectListView):
|
||||
class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_inventoryitem'
|
||||
model = InventoryItem
|
||||
parent_field = 'device'
|
||||
form_class = forms.InventoryItemForm
|
||||
|
||||
def alter_obj(self, obj, request, url_args, url_kwargs):
|
||||
@ -1882,3 +1827,4 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_inventoryitem'
|
||||
model = InventoryItem
|
||||
parent_field = 'device'
|
||||
|
@ -12,6 +12,7 @@ from ipam.models import (
|
||||
)
|
||||
from tenancy.api.serializers import NestedTenantSerializer
|
||||
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
||||
from virtualization.api.serializers import NestedVirtualMachineSerializer
|
||||
|
||||
|
||||
#
|
||||
@ -295,12 +296,13 @@ class AvailableIPSerializer(serializers.Serializer):
|
||||
|
||||
class ServiceSerializer(serializers.ModelSerializer):
|
||||
device = NestedDeviceSerializer()
|
||||
virtual_machine = NestedVirtualMachineSerializer()
|
||||
protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
|
||||
ipaddresses = NestedIPAddressSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
|
||||
|
||||
# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
|
||||
@ -308,4 +310,4 @@ class WritableServiceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
|
@ -134,7 +134,11 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
#
|
||||
|
||||
class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
|
||||
queryset = IPAddress.objects.select_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside'
|
||||
).prefetch_related(
|
||||
'interface__device'
|
||||
)
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
write_serializer_class = serializers.WritableIPAddressSerializer
|
||||
filter_class = filters.IPAddressFilter
|
||||
|
@ -70,7 +70,7 @@
|
||||
"family": 4,
|
||||
"address": "10.0.255.1/32",
|
||||
"vrf": null,
|
||||
"interface": 3,
|
||||
"interface_id": 3,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -84,7 +84,7 @@
|
||||
"family": 4,
|
||||
"address": "169.254.254.1/31",
|
||||
"vrf": null,
|
||||
"interface": 4,
|
||||
"interface_id": 4,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -98,7 +98,7 @@
|
||||
"family": 4,
|
||||
"address": "10.0.255.2/32",
|
||||
"vrf": null,
|
||||
"interface": 185,
|
||||
"interface_id": 185,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -112,7 +112,7 @@
|
||||
"family": 4,
|
||||
"address": "169.254.1.1/31",
|
||||
"vrf": null,
|
||||
"interface": 213,
|
||||
"interface_id": 213,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -126,7 +126,7 @@
|
||||
"family": 4,
|
||||
"address": "10.0.254.1/24",
|
||||
"vrf": null,
|
||||
"interface": 12,
|
||||
"interface_id": 12,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -140,7 +140,7 @@
|
||||
"family": 4,
|
||||
"address": "10.15.21.1/31",
|
||||
"vrf": null,
|
||||
"interface": 218,
|
||||
"interface_id": 218,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -154,7 +154,7 @@
|
||||
"family": 4,
|
||||
"address": "10.15.21.2/31",
|
||||
"vrf": null,
|
||||
"interface": 9,
|
||||
"interface_id": 9,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -168,7 +168,7 @@
|
||||
"family": 4,
|
||||
"address": "10.15.22.1/31",
|
||||
"vrf": null,
|
||||
"interface": 8,
|
||||
"interface_id": 8,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -182,7 +182,7 @@
|
||||
"family": 4,
|
||||
"address": "10.15.20.1/31",
|
||||
"vrf": null,
|
||||
"interface": 7,
|
||||
"interface_id": 7,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -196,7 +196,7 @@
|
||||
"family": 4,
|
||||
"address": "10.16.20.1/31",
|
||||
"vrf": null,
|
||||
"interface": 216,
|
||||
"interface_id": 216,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -210,7 +210,7 @@
|
||||
"family": 4,
|
||||
"address": "10.15.22.2/31",
|
||||
"vrf": null,
|
||||
"interface": 206,
|
||||
"interface_id": 206,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -224,7 +224,7 @@
|
||||
"family": 4,
|
||||
"address": "10.16.22.1/31",
|
||||
"vrf": null,
|
||||
"interface": 217,
|
||||
"interface_id": 217,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -238,7 +238,7 @@
|
||||
"family": 4,
|
||||
"address": "10.16.22.2/31",
|
||||
"vrf": null,
|
||||
"interface": 205,
|
||||
"interface_id": 205,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -252,7 +252,7 @@
|
||||
"family": 4,
|
||||
"address": "10.16.20.2/31",
|
||||
"vrf": null,
|
||||
"interface": 211,
|
||||
"interface_id": 211,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -266,7 +266,7 @@
|
||||
"family": 4,
|
||||
"address": "10.15.22.2/31",
|
||||
"vrf": null,
|
||||
"interface": 212,
|
||||
"interface_id": 212,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -280,7 +280,7 @@
|
||||
"family": 4,
|
||||
"address": "10.0.254.2/32",
|
||||
"vrf": null,
|
||||
"interface": 188,
|
||||
"interface_id": 188,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -294,7 +294,7 @@
|
||||
"family": 4,
|
||||
"address": "169.254.1.1/31",
|
||||
"vrf": null,
|
||||
"interface": 200,
|
||||
"interface_id": 200,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -308,7 +308,7 @@
|
||||
"family": 4,
|
||||
"address": "169.254.1.2/31",
|
||||
"vrf": null,
|
||||
"interface": 194,
|
||||
"interface_id": 194,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
|
@ -377,50 +377,9 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
#
|
||||
|
||||
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
|
||||
interface_site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label='Site',
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'interface_rack'}
|
||||
)
|
||||
)
|
||||
interface_rack = ChainedModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
chains=(
|
||||
('site', 'interface_site'),
|
||||
),
|
||||
required=False,
|
||||
label='Rack',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/?site_id={{interface_site}}',
|
||||
display_field='display_name',
|
||||
attrs={'filter-for': 'interface_device', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
interface_device = ChainedModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
chains=(
|
||||
('site', 'interface_site'),
|
||||
('rack', 'interface_rack'),
|
||||
),
|
||||
required=False,
|
||||
label='Device',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}',
|
||||
display_field='display_name',
|
||||
attrs={'filter-for': 'interface'}
|
||||
)
|
||||
)
|
||||
interface = ChainedModelChoiceField(
|
||||
interface = forms.ModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
chains=(
|
||||
('device', 'interface_device'),
|
||||
),
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/interfaces/?device_id={{interface_device}}'
|
||||
)
|
||||
required=False
|
||||
)
|
||||
nat_site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@ -479,13 +438,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
||||
obj_label='address'
|
||||
)
|
||||
)
|
||||
primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device')
|
||||
primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM')
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack',
|
||||
'nat_inside', 'tenant_group', 'tenant',
|
||||
'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site',
|
||||
'nat_rack', 'nat_inside', 'tenant_group', 'tenant',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -493,10 +452,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
||||
# Initialize helper selectors
|
||||
instance = kwargs.get('instance')
|
||||
initial = kwargs.get('initial', {}).copy()
|
||||
if instance and instance.interface is not None:
|
||||
initial['interface_site'] = instance.interface.device.site
|
||||
initial['interface_rack'] = instance.interface.device.rack
|
||||
initial['interface_device'] = instance.interface.device
|
||||
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
|
||||
@ -507,22 +462,30 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
# Initialize primary_for_device if IP address is already assigned
|
||||
if self.instance.interface is not None:
|
||||
device = self.instance.interface.device
|
||||
# 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
|
||||
)
|
||||
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.address.version == 4 and device.primary_ip4 == self.instance or
|
||||
self.instance.address.version == 6 and device.primary_ip6 == self.instance
|
||||
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
|
||||
):
|
||||
self.initial['primary_for_device'] = True
|
||||
self.initial['primary_for_parent'] = True
|
||||
|
||||
def clean(self):
|
||||
super(IPAddressForm, self).clean()
|
||||
|
||||
# Primary IP assignment is only available if an interface has been assigned.
|
||||
if self.cleaned_data.get('primary_for_device') and not self.cleaned_data.get('interface'):
|
||||
if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'):
|
||||
self.add_error(
|
||||
'primary_for_device', "Only IP addresses assigned to an interface can be designated as primary IPs."
|
||||
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@ -530,13 +493,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
||||
ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
|
||||
|
||||
# Assign this IPAddress as the primary for the associated Device.
|
||||
if self.cleaned_data['primary_for_device']:
|
||||
device = self.cleaned_data['interface'].device
|
||||
if self.cleaned_data['primary_for_parent']:
|
||||
parent = self.cleaned_data['interface'].parent
|
||||
if ipaddress.address.version == 4:
|
||||
device.primary_ip4 = ipaddress
|
||||
parent.primary_ip4 = ipaddress
|
||||
else:
|
||||
device.primary_ip6 = ipaddress
|
||||
device.save()
|
||||
parent.primary_ip6 = ipaddress
|
||||
parent.save()
|
||||
|
||||
# Clear assignment as primary for device if set.
|
||||
else:
|
||||
@ -898,5 +861,14 @@ class ServiceForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
super(ServiceForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit IP address choices to those assigned to interfaces of the parent device
|
||||
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(interface__device=self.instance.device)
|
||||
# Limit IP address choices to those assigned to interfaces of the parent device/VM
|
||||
if self.instance.device:
|
||||
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
||||
interface__device=self.instance.device
|
||||
)
|
||||
elif self.instance.virtual_machine:
|
||||
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
||||
interface__virtual_machine=self.instance.virtual_machine
|
||||
)
|
||||
else:
|
||||
self.fields['ipaddresses'].choices = []
|
||||
|
31
netbox/ipam/migrations/0019_virtualization.py
Normal file
31
netbox/ipam/migrations/0019_virtualization.py
Normal file
@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.4 on 2017-08-31 15:44
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('virtualization', '0001_virtualization'),
|
||||
('ipam', '0018_remove_service_uniqueness_constraint'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='service',
|
||||
options={'ordering': ['protocol', 'port']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='virtual_machine',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='virtualization.VirtualMachine'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='service',
|
||||
name='device',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device'),
|
||||
),
|
||||
]
|
@ -2,10 +2,12 @@ from __future__ import unicode_literals
|
||||
import netaddr
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
@ -586,20 +588,59 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
||||
@python_2_unicode_compatible
|
||||
class Service(CreatedUpdatedModel):
|
||||
"""
|
||||
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied
|
||||
to one or more specific IPAddresses belonging to the Device.
|
||||
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
|
||||
optionally be tied to one or more specific IPAddresses belonging to its parent.
|
||||
"""
|
||||
device = models.ForeignKey('dcim.Device', related_name='services', on_delete=models.CASCADE, verbose_name='device')
|
||||
name = models.CharField(max_length=30)
|
||||
protocol = models.PositiveSmallIntegerField(choices=IP_PROTOCOL_CHOICES)
|
||||
port = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)],
|
||||
verbose_name='Port number')
|
||||
ipaddresses = models.ManyToManyField('ipam.IPAddress', related_name='services', blank=True,
|
||||
verbose_name='IP addresses')
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='services',
|
||||
verbose_name='device',
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
virtual_machine = models.ForeignKey(
|
||||
to='virtualization.VirtualMachine',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='services',
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=30
|
||||
)
|
||||
protocol = models.PositiveSmallIntegerField(
|
||||
choices=IP_PROTOCOL_CHOICES
|
||||
)
|
||||
port = models.PositiveIntegerField(
|
||||
validators=[MinValueValidator(1), MaxValueValidator(65535)],
|
||||
verbose_name='Port number'
|
||||
)
|
||||
ipaddresses = models.ManyToManyField(
|
||||
to='ipam.IPAddress',
|
||||
related_name='services',
|
||||
blank=True,
|
||||
verbose_name='IP addresses'
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['device', 'protocol', 'port']
|
||||
ordering = ['protocol', 'port']
|
||||
|
||||
def __str__(self):
|
||||
return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
return self.device or self.virtual_machine
|
||||
|
||||
def clean(self):
|
||||
|
||||
# A Service must belong to a Device *or* to a VirtualMachine
|
||||
if self.device and self.virtual_machine:
|
||||
raise ValidationError("A service cannot be associated with both a device and a virtual machine.")
|
||||
if not self.device and not self.virtual_machine:
|
||||
raise ValidationError("A service must be associated with either a device or a virtual machine.")
|
||||
|
@ -77,9 +77,9 @@ IPADDRESS_LINK = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
IPADDRESS_DEVICE = """
|
||||
IPADDRESS_PARENT = """
|
||||
{% if record.interface %}
|
||||
<a href="{{ record.interface.device.get_absolute_url }}">{{ record.interface.device }}</a>
|
||||
<a href="{{ record.interface.parent.get_absolute_url }}">{{ record.interface.parent }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
@ -265,12 +265,12 @@ class IPAddressTable(BaseTable):
|
||||
status = tables.TemplateColumn(STATUS_LABEL)
|
||||
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
|
||||
tenant = tables.TemplateColumn(TENANT_LINK)
|
||||
device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False)
|
||||
parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False)
|
||||
interface = tables.Column(orderable=False)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'device', 'interface', 'description')
|
||||
fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
|
||||
}
|
||||
@ -283,7 +283,7 @@ class IPAddressDetailTable(IPAddressTable):
|
||||
|
||||
class Meta(IPAddressTable.Meta):
|
||||
fields = (
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'device', 'interface', 'description',
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'description',
|
||||
)
|
||||
|
||||
|
||||
|
@ -10,11 +10,12 @@ from django.shortcuts import get_object_or_404, render
|
||||
from django.urls import reverse
|
||||
from django.views.generic import View
|
||||
|
||||
from dcim.models import Device
|
||||
from dcim.models import Device, Interface
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.views import (
|
||||
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import filters, forms, tables
|
||||
from .constants import IPADDRESS_ROLE_ANYCAST
|
||||
from .models import (
|
||||
@ -595,7 +596,11 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class IPAddressListView(ObjectListView):
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
|
||||
queryset = IPAddress.objects.select_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside'
|
||||
).prefetch_related(
|
||||
'interface__device', 'interface__virtual_machine'
|
||||
)
|
||||
filter = filters.IPAddressFilter
|
||||
filter_form = forms.IPAddressFilterForm
|
||||
table = tables.IPAddressDetailTable
|
||||
@ -606,7 +611,7 @@ class IPAddressView(View):
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
|
||||
ipaddress = get_object_or_404(IPAddress.objects.select_related('vrf__tenant', 'tenant'), pk=pk)
|
||||
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.filter(
|
||||
@ -623,7 +628,9 @@ class IPAddressView(View):
|
||||
).exclude(
|
||||
pk=ipaddress.pk
|
||||
).select_related(
|
||||
'interface__device', 'nat_inside'
|
||||
'nat_inside'
|
||||
).prefetch_related(
|
||||
'interface__device'
|
||||
)
|
||||
# Exclude anycast IPs if this IP is anycast
|
||||
if ipaddress.role == IPADDRESS_ROLE_ANYCAST:
|
||||
@ -631,7 +638,7 @@ class IPAddressView(View):
|
||||
duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
|
||||
|
||||
# Related IP table
|
||||
related_ips = IPAddress.objects.select_related(
|
||||
related_ips = IPAddress.objects.prefetch_related(
|
||||
'interface__device'
|
||||
).exclude(
|
||||
address=str(ipaddress.address)
|
||||
@ -655,6 +662,17 @@ class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
template_name = 'ipam/ipaddress_edit.html'
|
||||
default_return_url = 'ipam:ipaddress_list'
|
||||
|
||||
def alter_obj(self, obj, request, url_args, url_kwargs):
|
||||
|
||||
interface_id = request.GET.get('interface')
|
||||
if interface_id:
|
||||
try:
|
||||
obj.interface = Interface.objects.get(pk=interface_id)
|
||||
except (ValueError, Interface.DoesNotExist):
|
||||
pass
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
class IPAddressEditView(IPAddressCreateView):
|
||||
permission_required = 'ipam.change_ipaddress'
|
||||
@ -685,7 +703,7 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'ipam.change_ipaddress'
|
||||
cls = IPAddress
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
|
||||
filter = filters.IPAddressFilter
|
||||
table = tables.IPAddressTable
|
||||
form = forms.IPAddressBulkEditForm
|
||||
@ -695,7 +713,7 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'ipam.delete_ipaddress'
|
||||
cls = IPAddress
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
|
||||
filter = filters.IPAddressFilter
|
||||
table = tables.IPAddressTable
|
||||
default_return_url = 'ipam:ipaddress_list'
|
||||
@ -821,10 +839,12 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
def alter_obj(self, obj, request, url_args, url_kwargs):
|
||||
if 'device' in url_kwargs:
|
||||
obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
|
||||
elif 'virtualmachine' in url_kwargs:
|
||||
obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine'])
|
||||
return obj
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return obj.device.get_absolute_url()
|
||||
return obj.parent.get_absolute_url()
|
||||
|
||||
|
||||
class ServiceEditView(ServiceCreateView):
|
||||
|
@ -146,6 +146,7 @@ INSTALLED_APPS = (
|
||||
'tenancy',
|
||||
'users',
|
||||
'utilities',
|
||||
'virtualization',
|
||||
)
|
||||
|
||||
# Middleware
|
||||
|
@ -32,6 +32,7 @@ _patterns = [
|
||||
url(r'^secrets/', include('secrets.urls')),
|
||||
url(r'^tenancy/', include('tenancy.urls')),
|
||||
url(r'^user/', include('users.urls')),
|
||||
url(r'^virtualization/', include('virtualization.urls')),
|
||||
|
||||
# API
|
||||
url(r'^api/$', APIRootView.as_view(), name='api-root'),
|
||||
@ -41,6 +42,7 @@ _patterns = [
|
||||
url(r'^api/ipam/', include('ipam.api.urls')),
|
||||
url(r'^api/secrets/', include('secrets.api.urls')),
|
||||
url(r'^api/tenancy/', include('tenancy.api.urls')),
|
||||
url(r'^api/virtualization/', include('virtualization.api.urls')),
|
||||
url(r'^api/docs/', swagger_view, name='api_docs'),
|
||||
|
||||
# Serving static media in Django to pipe it through LoginRequiredMiddleware
|
||||
|
@ -25,6 +25,9 @@ from secrets.tables import SecretTable
|
||||
from tenancy.filters import TenantFilter
|
||||
from tenancy.models import Tenant
|
||||
from tenancy.tables import TenantTable
|
||||
from virtualization.filters import ClusterFilter, VirtualMachineFilter
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
from virtualization.tables import ClusterTable, VirtualMachineTable
|
||||
from .forms import SearchForm
|
||||
|
||||
|
||||
@ -90,7 +93,7 @@ SEARCH_TYPES = OrderedDict((
|
||||
'url': 'ipam:prefix_list',
|
||||
}),
|
||||
('ipaddress', {
|
||||
'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'),
|
||||
'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant'),
|
||||
'filter': IPAddressFilter,
|
||||
'table': IPAddressTable,
|
||||
'url': 'ipam:ipaddress_list',
|
||||
@ -115,6 +118,19 @@ SEARCH_TYPES = OrderedDict((
|
||||
'table': TenantTable,
|
||||
'url': 'tenancy:tenant_list',
|
||||
}),
|
||||
# Virtualization
|
||||
('cluster', {
|
||||
'queryset': Cluster.objects.all(),
|
||||
'filter': ClusterFilter,
|
||||
'table': ClusterTable,
|
||||
'url': 'virtualization:cluster_list',
|
||||
}),
|
||||
('virtualmachine', {
|
||||
'queryset': VirtualMachine.objects.select_related('cluster', 'tenant', 'platform'),
|
||||
'filter': VirtualMachineFilter,
|
||||
'table': VirtualMachineTable,
|
||||
'url': 'virtualization:virtualmachine_list',
|
||||
}),
|
||||
))
|
||||
|
||||
|
||||
@ -150,6 +166,10 @@ class HomeView(View):
|
||||
# Secrets
|
||||
'secret_count': Secret.objects.count(),
|
||||
|
||||
# Virtualization
|
||||
'cluster_count': Cluster.objects.count(),
|
||||
'virtualmachine_count': VirtualMachine.objects.count(),
|
||||
|
||||
}
|
||||
|
||||
return render(request, self.template_name, {
|
||||
@ -216,14 +236,15 @@ class APIRootView(APIView):
|
||||
|
||||
def get(self, request, format=None):
|
||||
|
||||
return Response({
|
||||
'circuits': reverse('circuits-api:api-root', request=request, format=format),
|
||||
'dcim': reverse('dcim-api:api-root', request=request, format=format),
|
||||
'extras': reverse('extras-api:api-root', request=request, format=format),
|
||||
'ipam': reverse('ipam-api:api-root', request=request, format=format),
|
||||
'secrets': reverse('secrets-api:api-root', request=request, format=format),
|
||||
'tenancy': reverse('tenancy-api:api-root', request=request, format=format),
|
||||
})
|
||||
return Response(OrderedDict((
|
||||
('circuits', reverse('circuits-api:api-root', request=request, format=format)),
|
||||
('dcim', reverse('dcim-api:api-root', request=request, format=format)),
|
||||
('extras', reverse('extras-api:api-root', request=request, format=format)),
|
||||
('ipam', reverse('ipam-api:api-root', request=request, format=format)),
|
||||
('secrets', reverse('secrets-api:api-root', request=request, format=format)),
|
||||
('tenancy', reverse('tenancy-api:api-root', request=request, format=format)),
|
||||
('virtualization', reverse('virtualization-api:api-root', request=request, format=format)),
|
||||
)))
|
||||
|
||||
|
||||
def handle_500(request):
|
||||
|
@ -202,6 +202,34 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|contains:'/virtualization/' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Virtualization <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'virtualization:cluster_list' %}"><strong>Clusters</strong></a></li>
|
||||
{% if perms.virtualization.add_cluster %}
|
||||
<li class="subnav"><a href="{% url 'virtualization:cluster_add' %}"><i class="fa fa-plus"></i> Add a Cluster</a></li>
|
||||
<li class="subnav"><a href="{% url 'virtualization:cluster_import' %}"><i class="fa fa-download"></i> Import Clusters</a></li>
|
||||
{% endif %}
|
||||
{% if perms.virtualization.add_cluster or perms.virtualization.add_virtualmachine %}
|
||||
<li class="divider"></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'virtualization:virtualmachine_list' %}"><strong>Virtual Machines</strong></a></li>
|
||||
{% if perms.virtualization.add_virtualmachine %}
|
||||
<li class="subnav"><a href="{% url 'virtualization:virtualmachine_add' %}"><i class="fa fa-plus"></i> Add a Virtual Machine</a></li>
|
||||
<li class="subnav"><a href="{% url 'virtualization:virtualmachine_import' %}"><i class="fa fa-download"></i> Import Virtual Machines</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'virtualization:clustertype_list' %}"><strong>Cluster Types</strong></a></li>
|
||||
{% if perms.virtualization.add_clustertype %}
|
||||
<li class="subnav"><a href="{% url 'virtualization:clustertype_add' %}"><i class="fa fa-plus"></i> Add a Cluster Type</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'virtualization:clustergroup_list' %}"><strong>Cluster Groups</strong></a></li>
|
||||
{% if perms.virtualization.add_clustergroup %}
|
||||
<li class="subnav"><a href="{% url 'virtualization:clustergroup_add' %}"><i class="fa fa-plus"></i> Add a Cluster Group</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|contains:'/circuits/' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Circuits <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
|
@ -196,7 +196,7 @@
|
||||
{% if services %}
|
||||
<table class="table table-hover panel-body">
|
||||
{% for service in services %}
|
||||
{% include 'dcim/inc/service.html' %}
|
||||
{% include 'ipam/inc/service.html' %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
@ -206,7 +206,7 @@
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_service %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
|
||||
<a href="{% url 'dcim:device_service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
|
||||
</a>
|
||||
</div>
|
||||
|
@ -64,7 +64,7 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?interface_site={{ device.site.pk }}&interface_rack={{ device.rack.pk }}&interface_device={{ device.pk }}&interface={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?interface={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
|
||||
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -55,20 +55,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if perms.secrets %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Secrets</strong>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Virtualization</strong>
|
||||
</div>
|
||||
<div class="list-group">
|
||||
<div class="list-group-item">
|
||||
<span class="badge pull-right">{{ stats.cluster_count }}</span>
|
||||
<h4 class="list-group-item-heading"><a href="{% url 'virtualization:cluster_list' %}">Clusters</a></h4>
|
||||
<p class="list-group-item-text text-muted">Clusters of physical hosts in which VMs reside</p>
|
||||
</div>
|
||||
<div class="list-group">
|
||||
<div class="list-group-item">
|
||||
<span class="badge pull-right">{{ stats.secret_count }}</span>
|
||||
<h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
|
||||
<p class="list-group-item-text text-muted">Sensitive data (such as passwords) which has been stored securely</p>
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<span class="badge pull-right">{{ stats.virtualmachine_count }}</span>
|
||||
<h4 class="list-group-item-heading"><a href="{% url 'virtualization:virtualmachine_list' %}">Virtual Machines</a></h4>
|
||||
<p class="list-group-item-text text-muted">Virtual compute instances running inside clusters</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-md-4">
|
||||
<div class="panel panel-default">
|
||||
@ -120,6 +123,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if perms.secrets %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Secrets</strong>
|
||||
</div>
|
||||
<div class="list-group">
|
||||
<div class="list-group-item">
|
||||
<span class="badge pull-right">{{ stats.secret_count }}</span>
|
||||
<h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
|
||||
<p class="list-group-item-text text-muted">Sensitive data (such as passwords) which has been stored securely</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-sm-6 col-md-4">
|
||||
<div class="panel panel-default">
|
||||
|
@ -14,12 +14,12 @@
|
||||
<td class="text-right">
|
||||
{% if perms.ipam.change_service %}
|
||||
<a href="{% url 'ipam:service_edit' pk=service.pk %}" class="btn btn-info btn-xs" title="Edit service">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||
<i class="glyphicon glyphicon-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.ipam.delete_service %}
|
||||
<a href="{% url 'ipam:service_delete' pk=service.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete service"></i>
|
||||
<a href="{% url 'ipam:service_delete' pk=service.pk %}?return_url={{ service.parent.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<i class="glyphicon glyphicon-trash" title="Delete service"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
@ -104,7 +104,7 @@
|
||||
<td>Assignment</td>
|
||||
<td>
|
||||
{% if ipaddress.interface %}
|
||||
<span><a href="{% url 'dcim:device' pk=ipaddress.interface.device.pk %}">{{ ipaddress.interface.device }}</a> ({{ ipaddress.interface }})</span>
|
||||
<span><a href="{{ ipaddress.interface.parent.get_absolute_url }}">{{ ipaddress.interface.parent }}</a> ({{ ipaddress.interface }})</span>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
@ -116,7 +116,7 @@
|
||||
{% 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="{% url 'dcim:device' pk=ipaddress.nat_inside.interface.device.pk %}">{{ ipaddress.nat_inside.interface.device }}</a>)
|
||||
(<a href="{{ ipaddress.nat_inside.interface.parent.get_absolute_url }}">{{ ipaddress.nat_inside.interface.parent }}</a>)
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
|
@ -1,6 +1,7 @@
|
||||
{% extends 'utilities/obj_edit.html' %}
|
||||
{% load static from staticfiles %}
|
||||
{% load form_helpers %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block tabs %}
|
||||
{% if not obj.pk %}
|
||||
@ -26,18 +27,25 @@
|
||||
{% render_field form.tenant %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Interface Assignment</strong>
|
||||
{% 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|model_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>
|
||||
</div>
|
||||
{% render_field form.interface %}
|
||||
{% render_field form.primary_for_parent %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.interface_site %}
|
||||
{% render_field form.interface_rack %}
|
||||
{% render_field form.interface_device %}
|
||||
{% render_field form.interface %}
|
||||
{% render_field form.primary_for_device %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>NAT IP (Inside)</strong></div>
|
||||
<div class="panel-body">
|
||||
|
@ -5,12 +5,21 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Service</strong></div>
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">Device</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{{ obj.device }}</p>
|
||||
{% if obj.device %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required">Device</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{{ obj.device }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<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">{{ obj.virtual_machine }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% render_field form.name %}
|
||||
<div class="form-group form-inline">
|
||||
<label class="col-md-3 control-label required">Port</label>
|
||||
|
@ -124,6 +124,10 @@
|
||||
<h2><a href="{% url 'circuits:circuit_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.circuit_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.circuit_count }}</a></h2>
|
||||
<p>Circuits</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-center">
|
||||
<h2><a href="{% url 'virtualization:virtualmachine_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.virtualmachine_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.virtualmachine_count }}</a></h2>
|
||||
<p>Virtual machines</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
38
netbox/templates/utilities/obj_bulk_remove.html
Normal file
38
netbox/templates/utilities/obj_bulk_remove.html
Normal file
@ -0,0 +1,38 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block title %}Remove {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-md-offset-2">
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Confirm Bulk Removal</strong></div>
|
||||
<div class="panel-body">
|
||||
<strong>Warning:</strong> The following operation will remove {{ table.rows|length }} {{ obj_type_plural }} from {{ parent_obj }}. Please carefully review the {{ obj_type_plural }} to be removed and confirm below.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-md-offset-2">
|
||||
<div class="panel panel-default">
|
||||
{% include 'inc/table.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
<form action="." method="post" class="form">
|
||||
{% csrf_token %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
<div class="text-center">
|
||||
<button type="submit" name="_confirm" class="btn btn-danger">Delete these {{ table.rows|length }} {{ obj_type_plural }}</button>
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
117
netbox/templates/virtualization/cluster.html
Normal file
117
netbox/templates/virtualization/cluster.html
Normal file
@ -0,0 +1,117 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row" xmlns="http://www.w3.org/1999/html">
|
||||
<div class="col-sm-8 col-md-9">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{{ cluster.type.get_absolute_url }}">{{ cluster.type }}</a></li>
|
||||
{% if cluster.group %}
|
||||
<li><a href="{{ cluster.group.get_absolute_url }}">{{ cluster.group }}</a></li>
|
||||
{% endif %}
|
||||
<li>{{ cluster }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-sm-4 col-md-3">
|
||||
<form action="{% url 'virtualization:cluster_list' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" class="form-control" placeholder="Search clusters" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="fa fa-search" aria-hidden="true"></span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
{% if perms.virtualization.change_cluster %}
|
||||
<a href="{% url 'virtualization:cluster_edit' pk=cluster.pk %}" class="btn btn-warning">
|
||||
<span class="fa fa-pencil" aria-hidden="true"></span>
|
||||
Edit this cluster
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_cluster %}
|
||||
<a href="{% url 'virtualization:cluster_delete' pk=cluster.pk %}" class="btn btn-danger">
|
||||
<span class="fa fa-trash" aria-hidden="true"></span>
|
||||
Delete this cluster
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1>{% block title %}{{ cluster }}{% endblock %}</h1>
|
||||
{% include 'inc/created_updated.html' with obj=cluster %}
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Cluster</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>{{ cluster.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Type</td>
|
||||
<td><a href="{{ cluster.type.get_absolute_url }}">{{ cluster.type }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Group</td>
|
||||
<td>
|
||||
{% if cluster.group %}
|
||||
<a href="{{ cluster.group.get_absolute_url }}">{{ cluster.group }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Virtual Machines</td>
|
||||
<td><a href="{% url 'virtualization:virtualmachine_list' %}?cluster={{ cluster.pk }}">{{ cluster.virtual_machines.count }}</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/custom_fields_panel.html' with custom_fields=cluster.get_custom_fields %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Comments</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if cluster.comments %}
|
||||
{{ cluster.comments|gfm }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Host Devices</strong>
|
||||
</div>
|
||||
{% if perms.virtualization.change_cluster %}
|
||||
<form action="{% url 'virtualization:cluster_remove_devices' pk=cluster.pk %}" method="post">
|
||||
{% csrf_token %}
|
||||
{% endif %}
|
||||
{% include 'responsive_table.html' with table=device_table %}
|
||||
{% if perms.virtualization.change_cluster %}
|
||||
<div class="panel-footer">
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'virtualization:cluster_add_devices' pk=cluster.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add devices
|
||||
</a>
|
||||
</div>
|
||||
<button type="submit" name="_remove" class="btn btn-danger primary btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
|
||||
Remove devices
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
44
netbox/templates/virtualization/cluster_add_devices.html
Normal file
44
netbox/templates/virtualization/cluster_add_devices.html
Normal file
@ -0,0 +1,44 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load static from staticfiles %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block content %}
|
||||
<form action="." method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
<h3>{% block title %}Add Devices to Cluster {{ cluster }}{% endblock %}</h3>
|
||||
{% if form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
<div class="panel-body">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Devices</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.rack %}
|
||||
{% render_field form.devices %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3 text-right">
|
||||
<button type="submit" name="_add" class="btn btn-primary">Add Devices</button>
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script src="{% static 'js/livesearch.js' %}?v{{ settings.VERSION }}"></script>
|
||||
{% endblock %}
|
26
netbox/templates/virtualization/cluster_list.html
Normal file
26
netbox/templates/virtualization/cluster_list.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% extends '_base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pull-right">
|
||||
{% if perms.virtualization.add_cluster %}
|
||||
<a href="{% url 'virtualization:cluster_add' %}" class="btn btn-primary">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a cluster
|
||||
</a>
|
||||
<a href="{% url 'virtualization:cluster_import' %}" class="btn btn-info">
|
||||
<span class="fa fa-download" aria-hidden="true"></span>
|
||||
Import clusters
|
||||
</a>
|
||||
{% endif %}
|
||||
{% include 'inc/export_button.html' with obj_type='clusters' %}
|
||||
</div>
|
||||
<h1>{% block title %}Clusters{% endblock %}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
{% include 'utilities/obj_table.html' %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
19
netbox/templates/virtualization/clustergroup_list.html
Normal file
19
netbox/templates/virtualization/clustergroup_list.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pull-right">
|
||||
{% if perms.virtualization.add_clustergroup %}
|
||||
<a href="{% url 'virtualization:clustergroup_add' %}" class="btn btn-primary">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a cluster group
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1>{% block title %}Cluster Groups{% endblock %}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% include 'utilities/obj_table.html' with bulk_delete_url='virtualization:clustergroup_bulk_delete' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
19
netbox/templates/virtualization/clustertype_list.html
Normal file
19
netbox/templates/virtualization/clustertype_list.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pull-right">
|
||||
{% if perms.virtualization.add_clustertype %}
|
||||
<a href="{% url 'virtualization:clustertype_add' %}" class="btn btn-primary">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a cluster type
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1>{% block title %}Cluster Types{% endblock %}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% include 'utilities/obj_table.html' with bulk_delete_url='virtualization:clustertype_bulk_delete' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
70
netbox/templates/virtualization/inc/interface.html
Normal file
70
netbox/templates/virtualization/inc/interface.html
Normal file
@ -0,0 +1,70 @@
|
||||
<tr class="interface{% if not iface.enabled %} danger{% endif %}">
|
||||
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
|
||||
<td class="pk">
|
||||
<input name="pk" type="checkbox" value="{{ iface.pk }}" />
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<i class="fa fa-fw fa-square"></i> <span>{{ iface.name }}</span>
|
||||
{% if iface.description %}
|
||||
<i class="fa fa-fw fa-comment-o" title="{{ iface.description }}"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ iface.mtu|default:"" }}</td>
|
||||
<td>{{ iface.mac_address|default:"" }}</td>
|
||||
<td class="text-right">
|
||||
{% if perms.ipam.add_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?interface={{ iface.pk }}&return_url={{ vm.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.dcim.change_interface %}
|
||||
<a href="{% url 'virtualization:interface_edit' pk=iface.pk %}" class="btn btn-info btn-xs" title="Edit interface">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_interface %}
|
||||
<a href="{% url 'virtualization:interface_delete' pk=iface.pk %}" class="btn btn-danger btn-xs" title="Delete interface">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% for ip in iface.ip_addresses.all %}
|
||||
<tr class="ipaddress">
|
||||
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
|
||||
{% if ip.description %}
|
||||
<i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>
|
||||
{% endif %}
|
||||
{% if vm.primary_ip4 == ip or vm.primary_ip6 == ip %}
|
||||
<span class="label label-success">Primary</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if ip.vrf %}
|
||||
<a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}">{{ ip.vrf }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">Global</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if perms.ipam.change_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ vm.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={{ vm.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 %}
|
277
netbox/templates/virtualization/virtualmachine.html
Normal file
277
netbox/templates/virtualization/virtualmachine.html
Normal file
@ -0,0 +1,277 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm-8 col-md-9">
|
||||
<ol class="breadcrumb">
|
||||
{% if vm.cluster %}
|
||||
<li><a href="{{ vm.cluster.get_absolute_url }}">{{ vm.cluster }}</a></li>
|
||||
{% endif %}
|
||||
<li>{{ vm }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-sm-4 col-md-3">
|
||||
<form action="{% url 'virtualization:virtualmachine_list' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" class="form-control" placeholder="Search virtual machines" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="fa fa-search"></span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
{% if perms.virtualization.change_virtualmachine %}
|
||||
<a href="{% url 'virtualization:virtualmachine_edit' pk=vm.pk %}" class="btn btn-warning">
|
||||
<span class="fa fa-pencil"></span>
|
||||
Edit this VM
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.virtualization.delete_virtualmachine %}
|
||||
<a href="{% url 'virtualization:virtualmachine_delete' pk=vm.pk %}" class="btn btn-danger">
|
||||
<span class="fa fa-trash"></span>
|
||||
Delete this VM
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1>{% block title %}{{ vm }}{% endblock %}</h1>
|
||||
{% include 'inc/created_updated.html' with obj=vm %}
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Virtual Machine</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>{{ vm.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cluster</td>
|
||||
<td>
|
||||
{% if vm.cluster.group %}
|
||||
<a href="{{ vm.cluster.group.get_absolute_url }}">{{ vm.cluster.group }}</a>
|
||||
<i class="fa fa-angle-right"></i>
|
||||
{% endif %}
|
||||
<a href="{{ vm.cluster.get_absolute_url }}">{{ vm.cluster }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cluster Type</td>
|
||||
<td>{{ vm.cluster.type }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Platform</td>
|
||||
<td>
|
||||
{% if vm.platform %}
|
||||
<span>{{ vm.platform }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tenant</td>
|
||||
<td>
|
||||
{% if vm.tenant %}
|
||||
{% if vm.tenant.group %}
|
||||
<a href="{{ vm.tenant.group.get_absolute_url }}">{{ vm.tenant.group.name }}</a>
|
||||
<i class="fa fa-angle-right"></i>
|
||||
{% endif %}
|
||||
<a href="{{ vm.tenant.get_absolute_url }}">{{ vm.tenant }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Primary IPv4</td>
|
||||
<td>
|
||||
{% if vm.primary_ip4 %}
|
||||
<a href="{% url 'ipam:ipaddress' pk=vm.primary_ip4.pk %}">{{ vm.primary_ip4.address.ip }}</a>
|
||||
{% if vm.primary_ip4.nat_inside %}
|
||||
<span>(NAT for {{ vm.primary_ip4.nat_inside.address.ip }})</span>
|
||||
{% elif vm.primary_ip4.nat_outside %}
|
||||
<span>(NAT: {{ vm.primary_ip4.nat_outside.address.ip }})</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Primary IPv6</td>
|
||||
<td>
|
||||
{% if vm.primary_ip6 %}
|
||||
<a href="{% url 'ipam:ipaddress' pk=vm.primary_ip6.pk %}">{{ vm.primary_ip6.address.ip }}</a>
|
||||
{% if vm.primary_ip6.nat_inside %}
|
||||
<span>(NAT for {{ vm.primary_ip6.nat_inside.address.ip }})</span>
|
||||
{% elif vm.primary_ip6.nat_outside %}
|
||||
<span>(NAT: {{ vm.primary_ip6.nat_outside.address.ip }})</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Resources</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<td><i class="fa fa-tachometer"></i> Virtual CPUs</td>
|
||||
<td>
|
||||
{% if vm.vcpus %}
|
||||
{{ vm.vcpus }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="fa fa-microchip"></i> Memory</td>
|
||||
<td>
|
||||
{% if vm.memory %}
|
||||
{{ vm.memory }} MB
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="fa fa-hdd-o"></i> Disk Space</td>
|
||||
<td>
|
||||
{% if vm.disk %}
|
||||
{{ vm.disk }} GB
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Services</strong>
|
||||
</div>
|
||||
{% if services %}
|
||||
<table class="table table-hover panel-body">
|
||||
{% for service in services %}
|
||||
{% include 'ipam/inc/service.html' %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="panel-body text-muted">
|
||||
None
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_service %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'virtualization:virtualmachine_service_assign' virtualmachine=vm.pk %}" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Comments</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if vm.comments %}
|
||||
{{ vm.comments|gfm }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="virtual_machine" value="{{ vm.pk }}" />
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Interfaces</strong>
|
||||
<div class="pull-right">
|
||||
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
|
||||
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
|
||||
</button>
|
||||
{% if perms.dcim.change_interface and interfaces|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interface and interfaces|length > 10 %}
|
||||
<a href="{% url 'virtualization:interface_add' pk=vm.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table id="interfaces_table" class="table table-hover panel-body component-list">
|
||||
{% for iface in interfaces %}
|
||||
{% include 'virtualization/inc/interface.html' with selectable=True %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4">No interfaces defined</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}
|
||||
<div class="panel-footer">
|
||||
{% if interfaces and perms.dcim.change_interface %}
|
||||
<button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=vm.pk %}" 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' pk=vm.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interface %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'virtualization:interface_add' pk=vm.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if perms.dcim.delete_interface %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script type="text/javascript">
|
||||
// Toggle the display of IP addresses under interfaces
|
||||
$('button.toggle-ips').click(function() {
|
||||
var selected = $(this).attr('selected');
|
||||
if (selected) {
|
||||
$('#interfaces_table tr.ipaddress').hide();
|
||||
} else {
|
||||
$('#interfaces_table tr.ipaddress').show();
|
||||
}
|
||||
$(this).attr('selected', !selected);
|
||||
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -0,0 +1,44 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="." method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
{% if form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
<div class="panel-body">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<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>
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-offset-3">
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
43
netbox/templates/virtualization/virtualmachine_edit.html
Normal file
43
netbox/templates/virtualization/virtualmachine_edit.html
Normal file
@ -0,0 +1,43 @@
|
||||
{% extends 'utilities/obj_edit.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Virtual Machine</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.name %}
|
||||
{% render_field form.cluster_group %}
|
||||
{% render_field form.cluster %}
|
||||
{% render_field form.platform %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Resources</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.vcpus %}
|
||||
{% render_field form.memory %}
|
||||
{% render_field form.disk %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Tenancy</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.tenant_group %}
|
||||
{% render_field form.tenant %}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.custom_fields %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Custom Fields</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Comments</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
55
netbox/templates/virtualization/virtualmachine_list.html
Normal file
55
netbox/templates/virtualization/virtualmachine_list.html
Normal file
@ -0,0 +1,55 @@
|
||||
{% extends '_base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pull-right">
|
||||
{% if perms.virtualization.add_virtualmachine %}
|
||||
<a href="{% url 'virtualization:virtualmachine_add' %}" class="btn btn-primary">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a virtual machine
|
||||
</a>
|
||||
<a href="{% url 'virtualization:virtualmachine_import' %}" class="btn btn-info">
|
||||
<span class="fa fa-download" aria-hidden="true"></span>
|
||||
Import virtual machines
|
||||
</a>
|
||||
{% endif %}
|
||||
{% include 'inc/export_button.html' with obj_type='virtual machines' %}
|
||||
</div>
|
||||
<h1>{% block title %}Virtual Machines{% endblock %}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
{% include 'utilities/obj_table.html' %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
|
||||
var cluster_group_list = $('#id_cluster_group');
|
||||
var cluster_list = $('#id_cluster_id');
|
||||
|
||||
// Update cluster options based on selected group
|
||||
cluster_group_list.change(function() {
|
||||
var selected_groups = $(this).val();
|
||||
if (selected_groups) {
|
||||
cluster_list.empty();
|
||||
$.ajax({
|
||||
url: netbox_api_path + 'virtualization/clusters/?limit=500&group=' + selected_groups.join('&group='),
|
||||
dataType: 'json',
|
||||
success: function (response, status) {
|
||||
$.each(response["results"], function (index, cluster) {
|
||||
var option = $("<option></option>").attr("value", cluster.id).text(cluster.name);
|
||||
cluster_list.append(option);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -12,6 +12,7 @@ from ipam.models import IPAddress, Prefix, VLAN, VRF
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
from virtualization.models import VirtualMachine
|
||||
from .models import Tenant, TenantGroup
|
||||
from . import filters, forms, tables
|
||||
|
||||
@ -79,6 +80,7 @@ class TenantView(View):
|
||||
).count(),
|
||||
'vlan_count': VLAN.objects.filter(tenant=tenant).count(),
|
||||
'circuit_count': Circuit.objects.filter(tenant=tenant).count(),
|
||||
'virtualmachine_count': VirtualMachine.objects.filter(tenant=tenant).count(),
|
||||
}
|
||||
|
||||
return render(request, 'tenancy/tenant.html', {
|
||||
|
@ -188,6 +188,10 @@ class APISelect(SelectWithDisabled):
|
||||
self.attrs['disabled-indicator'] = disabled_indicator
|
||||
|
||||
|
||||
class APISelectMultiple(APISelect):
|
||||
allow_multiple_selected = True
|
||||
|
||||
|
||||
class Livesearch(forms.TextInput):
|
||||
"""
|
||||
A text widget that carries a few extra bits of data for use in AJAX-powered autocomplete search
|
||||
@ -386,6 +390,15 @@ class ChainedModelChoiceField(forms.ModelChoiceField):
|
||||
super(ChainedModelChoiceField, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
"""
|
||||
See ChainedModelChoiceField
|
||||
"""
|
||||
def __init__(self, chains=None, *args, **kwargs):
|
||||
self.chains = chains
|
||||
super(ChainedModelMultipleChoiceField, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class SlugField(forms.SlugField):
|
||||
|
||||
def __init__(self, slug_source='name', *args, **kwargs):
|
||||
@ -508,6 +521,15 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm):
|
||||
confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
|
||||
|
||||
|
||||
class ComponentForm(BootstrapMixin, forms.Form):
|
||||
"""
|
||||
Allow inclusion of the parent Device/VirtualMachine as context for limiting field choices.
|
||||
"""
|
||||
def __init__(self, parent, *args, **kwargs):
|
||||
self.parent = parent
|
||||
super(ComponentForm, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class BulkEditForm(forms.Form):
|
||||
|
||||
def __init__(self, model, *args, **kwargs):
|
||||
|
@ -46,6 +46,22 @@ def gfm(value):
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
@register.filter()
|
||||
def model_name(obj):
|
||||
"""
|
||||
Return the name of the model of the given object
|
||||
"""
|
||||
return obj._meta.verbose_name
|
||||
|
||||
|
||||
@register.filter()
|
||||
def model_name_plural(obj):
|
||||
"""
|
||||
Return the plural name of the model of the given object
|
||||
"""
|
||||
return obj._meta.verbose_name_plural
|
||||
|
||||
|
||||
@register.filter()
|
||||
def contains(value, arg):
|
||||
"""
|
||||
|
@ -1,5 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
from collections import OrderedDict
|
||||
from copy import deepcopy
|
||||
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
@ -697,3 +698,91 @@ class BulkDeleteView(View):
|
||||
if self.form:
|
||||
return self.form
|
||||
return BulkDeleteForm
|
||||
|
||||
|
||||
#
|
||||
# Device/VirtualMachine components
|
||||
#
|
||||
|
||||
class ComponentCreateView(View):
|
||||
parent_model = None
|
||||
parent_field = None
|
||||
model = None
|
||||
form = None
|
||||
model_form = None
|
||||
template_name = None
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
parent = get_object_or_404(self.parent_model, pk=pk)
|
||||
form = self.form(parent, initial=request.GET)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'parent': parent,
|
||||
'component_type': self.model._meta.verbose_name,
|
||||
'form': form,
|
||||
'return_url': parent.get_absolute_url(),
|
||||
})
|
||||
|
||||
def post(self, request, pk):
|
||||
|
||||
parent = get_object_or_404(self.parent_model, pk=pk)
|
||||
|
||||
form = self.form(parent, request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
new_components = []
|
||||
data = deepcopy(form.cleaned_data)
|
||||
|
||||
for name in form.cleaned_data['name_pattern']:
|
||||
component_data = {
|
||||
self.parent_field: parent.pk,
|
||||
'name': name,
|
||||
}
|
||||
# Replace objects with their primary key to keep component_form.clean() happy
|
||||
for k, v in data.items():
|
||||
if hasattr(v, 'pk'):
|
||||
component_data[k] = v.pk
|
||||
else:
|
||||
component_data[k] = v
|
||||
component_form = self.model_form(component_data)
|
||||
if component_form.is_valid():
|
||||
new_components.append(component_form.save(commit=False))
|
||||
else:
|
||||
for field, errors in component_form.errors.as_data().items():
|
||||
# Assign errors on the child form's name field to name_pattern on the parent form
|
||||
if field == 'name':
|
||||
field = 'name_pattern'
|
||||
for e in errors:
|
||||
form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
|
||||
|
||||
if not form.errors:
|
||||
self.model.objects.bulk_create(new_components)
|
||||
messages.success(request, "Added {} {} to {}.".format(
|
||||
len(new_components), self.model._meta.verbose_name_plural, parent
|
||||
))
|
||||
if '_addanother' in request.POST:
|
||||
return redirect(request.path)
|
||||
else:
|
||||
return redirect(parent.get_absolute_url())
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'parent': parent,
|
||||
'component_type': self.model._meta.verbose_name,
|
||||
'form': form,
|
||||
'return_url': parent.get_absolute_url(),
|
||||
})
|
||||
|
||||
|
||||
class ComponentEditView(ObjectEditView):
|
||||
parent_field = None
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return getattr(obj, self.parent_field).get_absolute_url()
|
||||
|
||||
|
||||
class ComponentDeleteView(ObjectDeleteView):
|
||||
parent_field = None
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return getattr(obj, self.parent_field).get_absolute_url()
|
||||
|
0
netbox/virtualization/__init__.py
Normal file
0
netbox/virtualization/__init__.py
Normal file
0
netbox/virtualization/api/__init__.py
Normal file
0
netbox/virtualization/api/__init__.py
Normal file
142
netbox/virtualization/api/serializers.py
Normal file
142
netbox/virtualization/api/serializers.py
Normal file
@ -0,0 +1,142 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.api.serializers import NestedPlatformSerializer
|
||||
from dcim.constants import VIFACE_FF_CHOICES
|
||||
from dcim.models import Interface
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from tenancy.api.serializers import NestedTenantSerializer
|
||||
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
|
||||
#
|
||||
# Cluster types
|
||||
#
|
||||
|
||||
class ClusterTypeSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ClusterType
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class NestedClusterTypeSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
|
||||
|
||||
class Meta:
|
||||
model = ClusterType
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Cluster groups
|
||||
#
|
||||
|
||||
class ClusterGroupSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ClusterGroup
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class NestedClusterGroupSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
|
||||
|
||||
class Meta:
|
||||
model = ClusterGroup
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Clusters
|
||||
#
|
||||
|
||||
class ClusterSerializer(CustomFieldModelSerializer):
|
||||
type = NestedClusterTypeSerializer()
|
||||
group = NestedClusterGroupSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = ['id', 'name', 'type', 'group', 'comments', 'custom_fields']
|
||||
|
||||
|
||||
class NestedClusterSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class WritableClusterSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = ['id', 'name', 'type', 'group', 'comments', 'custom_fields']
|
||||
|
||||
|
||||
#
|
||||
# Virtual machines
|
||||
#
|
||||
|
||||
class VirtualMachineSerializer(CustomFieldModelSerializer):
|
||||
cluster = NestedClusterSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
platform = NestedPlatformSerializer()
|
||||
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
fields = [
|
||||
'id', 'name', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields',
|
||||
]
|
||||
|
||||
|
||||
class NestedVirtualMachineSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
|
||||
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
fields = [
|
||||
'id', 'name', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# VM interfaces
|
||||
#
|
||||
|
||||
class InterfaceSerializer(serializers.ModelSerializer):
|
||||
virtual_machine = NestedVirtualMachineSerializer()
|
||||
form_factor = ChoiceFieldSerializer(choices=VIFACE_FF_CHOICES)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description',
|
||||
]
|
||||
|
||||
|
||||
class NestedInterfaceSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail')
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class WritableInterfaceSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description',
|
||||
]
|
29
netbox/virtualization/api/urls.py
Normal file
29
netbox/virtualization/api/urls.py
Normal file
@ -0,0 +1,29 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
class VirtualizationRootView(routers.APIRootView):
|
||||
"""
|
||||
Virtualization API root view
|
||||
"""
|
||||
def get_view_name(self):
|
||||
return 'Virtualization'
|
||||
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.APIRootView = VirtualizationRootView
|
||||
|
||||
# Clusters
|
||||
router.register(r'cluster-types', views.ClusterTypeViewSet)
|
||||
router.register(r'cluster-groups', views.ClusterGroupViewSet)
|
||||
router.register(r'clusters', views.ClusterViewSet)
|
||||
|
||||
# VirtualMachines
|
||||
router.register(r'virtual-machines', views.VirtualMachineViewSet)
|
||||
router.register(r'interfaces', views.InterfaceViewSet)
|
||||
|
||||
app_name = 'virtualization-api'
|
||||
urlpatterns = router.urls
|
48
netbox/virtualization/api/views.py
Normal file
48
netbox/virtualization/api/views.py
Normal file
@ -0,0 +1,48 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from dcim.models import Interface
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from utilities.api import WritableSerializerMixin
|
||||
from virtualization import filters
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
from . import serializers
|
||||
|
||||
|
||||
#
|
||||
# Clusters
|
||||
#
|
||||
|
||||
class ClusterTypeViewSet(ModelViewSet):
|
||||
queryset = ClusterType.objects.all()
|
||||
serializer_class = serializers.ClusterTypeSerializer
|
||||
|
||||
|
||||
class ClusterGroupViewSet(ModelViewSet):
|
||||
queryset = ClusterGroup.objects.all()
|
||||
serializer_class = serializers.ClusterGroupSerializer
|
||||
|
||||
|
||||
class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = Cluster.objects.select_related('type', 'group')
|
||||
serializer_class = serializers.ClusterSerializer
|
||||
write_serializer_class = serializers.WritableClusterSerializer
|
||||
filter_class = filters.ClusterFilter
|
||||
|
||||
|
||||
#
|
||||
# Virtual machines
|
||||
#
|
||||
|
||||
class VirtualMachineViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = VirtualMachine.objects.all()
|
||||
serializer_class = serializers.VirtualMachineSerializer
|
||||
write_serializer_class = serializers.WritableVirtualMachineSerializer
|
||||
filter_class = filters.VirtualMachineFilter
|
||||
|
||||
|
||||
class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
write_serializer_class = serializers.WritableInterfaceSerializer
|
7
netbox/virtualization/apps.py
Normal file
7
netbox/virtualization/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class VirtualizationConfig(AppConfig):
|
||||
name = 'virtualization'
|
99
netbox/virtualization/filters.py
Normal file
99
netbox/virtualization/filters.py
Normal file
@ -0,0 +1,99 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.models import Platform
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
|
||||
class ClusterFilter(CustomFieldFilterSet):
|
||||
id__in = NumericInFilter(name='id', lookup_expr='in')
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
group_id = NullableModelMultipleChoiceFilter(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
label='Parent group (ID)',
|
||||
)
|
||||
group = NullableModelMultipleChoiceFilter(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Parent group (slug)',
|
||||
)
|
||||
type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ClusterType.objects.all(),
|
||||
label='Cluster type (ID)',
|
||||
)
|
||||
type = django_filters.ModelMultipleChoiceFilter(
|
||||
name='type__slug',
|
||||
queryset=ClusterType.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Cluster type (slug)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = ['name']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class VirtualMachineFilter(CustomFieldFilterSet):
|
||||
id__in = NumericInFilter(name='id', lookup_expr='in')
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
cluster_group_id = NullableModelMultipleChoiceFilter(
|
||||
name='cluster__group',
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
label='Cluster group (ID)',
|
||||
)
|
||||
cluster_group = NullableModelMultipleChoiceFilter(
|
||||
name='cluster__group__slug',
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Cluster group (slug)',
|
||||
)
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
platform_id = NullableModelMultipleChoiceFilter(
|
||||
queryset=Platform.objects.all(),
|
||||
label='Platform (ID)',
|
||||
)
|
||||
platform = NullableModelMultipleChoiceFilter(
|
||||
name='platform',
|
||||
queryset=Platform.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Platform (slug)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
fields = ['name', 'cluster']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
269
netbox/virtualization/forms.py
Normal file
269
netbox/virtualization/forms.py
Normal file
@ -0,0 +1,269 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from mptt.forms import TreeNodeChoiceField
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Count
|
||||
|
||||
from dcim.constants import VIFACE_FF_CHOICES
|
||||
from dcim.formfields import MACAddressFormField
|
||||
from dcim.models import Device, Interface, Rack, Region, Site
|
||||
from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin,
|
||||
ChainedModelChoiceField, ChainedModelMultipleChoiceField, ComponentForm, ConfirmationForm, ExpandableNameField,
|
||||
FilterChoiceField, SlugField,
|
||||
)
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
|
||||
#
|
||||
# Cluster types
|
||||
#
|
||||
|
||||
class ClusterTypeForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
model = ClusterType
|
||||
fields = ['name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Cluster groups
|
||||
#
|
||||
|
||||
class ClusterGroupForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
model = ClusterGroup
|
||||
fields = ['name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Clusters
|
||||
#
|
||||
|
||||
class ClusterForm(BootstrapMixin, CustomFieldForm):
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = ['name', 'type', 'group']
|
||||
|
||||
|
||||
class ClusterCSVForm(forms.ModelForm):
|
||||
type = forms.ModelChoiceField(
|
||||
queryset=ClusterType.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text='Name of cluster type',
|
||||
error_messages={
|
||||
'invalid_choice': 'Invalid cluster type name.',
|
||||
}
|
||||
)
|
||||
group = forms.ModelChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text='Name of cluster group',
|
||||
error_messages={
|
||||
'invalid_choice': 'Invalid cluster group name.',
|
||||
}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
fields = ['name', 'type', 'group']
|
||||
|
||||
|
||||
class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Cluster
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
group = FilterChoiceField(
|
||||
queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None'),
|
||||
required=False,
|
||||
)
|
||||
type = FilterChoiceField(
|
||||
queryset=ClusterType.objects.annotate(filter_count=Count('clusters')),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
)
|
||||
|
||||
|
||||
class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
|
||||
region = TreeNodeChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'site', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
site = ChainedModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
chains=(
|
||||
('region', 'region'),
|
||||
),
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/sites/?region_id={{region}}',
|
||||
attrs={'filter-for': 'rack'}
|
||||
)
|
||||
)
|
||||
rack = ChainedModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/?site_id={{site}}',
|
||||
attrs={'filter-for': 'devices', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
devices = ChainedModelMultipleChoiceField(
|
||||
queryset=Device.objects.filter(cluster__isnull=True),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('rack', 'rack'),
|
||||
),
|
||||
label='Device',
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
|
||||
display_field='display_name',
|
||||
disabled_indicator='cluster'
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
fields = ['region', 'site', 'rack', 'devices']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(ClusterAddDevicesForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fields['devices'].choices = []
|
||||
|
||||
|
||||
class ClusterRemoveDevicesForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
#
|
||||
# Virtual Machines
|
||||
#
|
||||
|
||||
class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
cluster_group = forms.ModelChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'cluster', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
cluster = ChainedModelChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
chains=(
|
||||
('group', 'cluster_group'),
|
||||
),
|
||||
widget=APISelect(
|
||||
api_url='/api/virtualization/clusters/?group_id={{cluster_group}}'
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
fields = ['name', 'cluster_group', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
# Initialize helper selector
|
||||
instance = kwargs.get('instance')
|
||||
if instance.pk and instance.cluster is not None:
|
||||
initial = kwargs.get('initial', {}).copy()
|
||||
initial['cluster_group'] = instance.cluster.group
|
||||
kwargs['initial'] = initial
|
||||
|
||||
super(VirtualMachineForm, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class VirtualMachineCSVForm(forms.ModelForm):
|
||||
cluster = forms.ModelChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text='Name of parent cluster',
|
||||
error_messages={
|
||||
'invalid_choice': 'Invalid cluster name.',
|
||||
}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
fields = ['cluster', 'name', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments']
|
||||
|
||||
|
||||
class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False, label='Cluster')
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ['tenant']
|
||||
|
||||
|
||||
class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = VirtualMachine
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
cluster_group = FilterChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None'),
|
||||
)
|
||||
cluster_id = FilterChoiceField(
|
||||
queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')),
|
||||
label='Cluster'
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# VM interfaces
|
||||
#
|
||||
|
||||
class InterfaceForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description']
|
||||
widgets = {
|
||||
'virtual_machine': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class InterfaceCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES)
|
||||
enabled = forms.BooleanField(required=False)
|
||||
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
|
||||
mac_address = MACAddressFormField(required=False, label='MAC Address')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
# Set interfaces enabled by default
|
||||
kwargs['initial'] = kwargs.get('initial', {}).copy()
|
||||
kwargs['initial'].update({'enabled': True})
|
||||
|
||||
super(InterfaceCreateForm, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
virtual_machine = forms.ModelChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.HiddenInput)
|
||||
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
|
||||
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ['mtu', 'description']
|
89
netbox/virtualization/migrations/0001_virtualization.py
Normal file
89
netbox/virtualization/migrations/0001_virtualization.py
Normal file
@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.4 on 2017-08-31 14:15
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import extras.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0018_remove_service_uniqueness_constraint'),
|
||||
('dcim', '0043_device_component_name_lengths'),
|
||||
('tenancy', '0003_unicode_literals'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Cluster',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateField(auto_now_add=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('comments', models.TextField(blank=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
bases=(models.Model, extras.models.CustomFieldModel),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClusterGroup',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, unique=True)),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClusterType',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, unique=True)),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VirtualMachine',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateField(auto_now_add=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=64, unique=True)),
|
||||
('vcpus', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='vCPUs')),
|
||||
('memory', models.PositiveIntegerField(blank=True, null=True, verbose_name='Memory (MB)')),
|
||||
('disk', models.PositiveIntegerField(blank=True, null=True, verbose_name='Disk (GB)')),
|
||||
('comments', models.TextField(blank=True)),
|
||||
('cluster', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.Cluster')),
|
||||
('platform', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='virtual_machines', to='dcim.Platform')),
|
||||
('primary_ip4', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.IPAddress', verbose_name='Primary IPv4')),
|
||||
('primary_ip6', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.IPAddress', verbose_name='Primary IPv6')),
|
||||
('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='tenancy.Tenant')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
bases=(models.Model, extras.models.CustomFieldModel),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cluster',
|
||||
name='group',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='virtualization.ClusterGroup'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cluster',
|
||||
name='type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='virtualization.ClusterType'),
|
||||
),
|
||||
]
|
0
netbox/virtualization/migrations/__init__.py
Normal file
0
netbox/virtualization/migrations/__init__.py
Normal file
189
netbox/virtualization/models.py
Normal file
189
netbox/virtualization/models.py
Normal file
@ -0,0 +1,189 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
|
||||
from extras.models import CustomFieldModel, CustomFieldValue
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
|
||||
|
||||
#
|
||||
# Cluster types
|
||||
#
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class ClusterType(models.Model):
|
||||
"""
|
||||
A type of Cluster.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
unique=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug)
|
||||
|
||||
|
||||
#
|
||||
# Cluster groups
|
||||
#
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class ClusterGroup(models.Model):
|
||||
"""
|
||||
An organizational group of Clusters.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
unique=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug)
|
||||
|
||||
|
||||
#
|
||||
# Clusters
|
||||
#
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Cluster(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
type = models.ForeignKey(
|
||||
to=ClusterType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='clusters'
|
||||
)
|
||||
group = models.ForeignKey(
|
||||
to=ClusterGroup,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='clusters',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to=CustomFieldValue,
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('virtualization:cluster', args=[self.pk])
|
||||
|
||||
|
||||
#
|
||||
# Virtual machines
|
||||
#
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
A virtual machine which runs inside a Cluster.
|
||||
"""
|
||||
cluster = models.ForeignKey(
|
||||
to=Cluster,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='virtual_machines'
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='virtual_machines',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
platform = models.ForeignKey(
|
||||
to='dcim.Platform',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='virtual_machines',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=64,
|
||||
unique=True
|
||||
)
|
||||
primary_ip4 = models.OneToOneField(
|
||||
to='ipam.IPAddress',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Primary IPv4'
|
||||
)
|
||||
primary_ip6 = models.OneToOneField(
|
||||
to='ipam.IPAddress',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Primary IPv6'
|
||||
)
|
||||
vcpus = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='vCPUs'
|
||||
)
|
||||
memory = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Memory (MB)'
|
||||
)
|
||||
disk = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Disk (GB)'
|
||||
)
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to=CustomFieldValue,
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('virtualization:virtualmachine', args=[self.pk])
|
97
netbox/virtualization/tables.py
Normal file
97
netbox/virtualization/tables.py
Normal file
@ -0,0 +1,97 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Interface
|
||||
from utilities.tables import BaseTable, ToggleColumn
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
|
||||
CLUSTERTYPE_ACTIONS = """
|
||||
{% if perms.virtualization.change_clustertype %}
|
||||
<a href="{% url 'virtualization:clustertype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
CLUSTERGROUP_ACTIONS = """
|
||||
{% if perms.virtualization.change_clustergroup %}
|
||||
<a href="{% url 'virtualization:clustergroup_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# Cluster types
|
||||
#
|
||||
|
||||
class ClusterTypeTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
cluster_count = tables.Column(verbose_name='Clusters')
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=CLUSTERTYPE_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ClusterType
|
||||
fields = ('pk', 'name', 'cluster_count', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# Cluster groups
|
||||
#
|
||||
|
||||
class ClusterGroupTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
cluster_count = tables.Column(verbose_name='Clusters')
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=CLUSTERGROUP_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ClusterGroup
|
||||
fields = ('pk', 'name', 'cluster_count', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# Clusters
|
||||
#
|
||||
|
||||
class ClusterTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn()
|
||||
vm_count = tables.Column(verbose_name='VMs')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Cluster
|
||||
fields = ('pk', 'name', 'type', 'group', 'vm_count')
|
||||
|
||||
|
||||
#
|
||||
# Virtual machines
|
||||
#
|
||||
|
||||
class VirtualMachineTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn()
|
||||
cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')])
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VirtualMachine
|
||||
fields = ('pk', 'name', 'cluster', 'tenant', 'vcpus', 'memory', 'disk')
|
||||
|
||||
|
||||
#
|
||||
# VM components
|
||||
#
|
||||
|
||||
class InterfaceTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
fields = ('name', 'enabled', 'description')
|
0
netbox/virtualization/tests/__init__.py
Normal file
0
netbox/virtualization/tests/__init__.py
Normal file
297
netbox/virtualization/tests/test_api.py
Normal file
297
netbox/virtualization/tests/test_api.py
Normal file
@ -0,0 +1,297 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
|
||||
from users.models import Token
|
||||
from utilities.tests import HttpStatusMixin
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
|
||||
class ClusterTypeTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
self.clustertype1 = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
|
||||
self.clustertype2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
|
||||
self.clustertype3 = ClusterType.objects.create(name='Test Cluster Type 3', slug='test-cluster-type-3')
|
||||
|
||||
def test_get_clustertype(self):
|
||||
|
||||
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.clustertype1.name)
|
||||
|
||||
def test_list_clustertypes(self):
|
||||
|
||||
url = reverse('virtualization-api:clustertype-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_clustertype(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Cluster Type 4',
|
||||
'slug': 'test-cluster-type-4',
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:clustertype-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ClusterType.objects.count(), 4)
|
||||
clustertype4 = ClusterType.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(clustertype4.name, data['name'])
|
||||
self.assertEqual(clustertype4.slug, data['slug'])
|
||||
|
||||
def test_update_clustertype(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Cluster Type X',
|
||||
'slug': 'test-cluster-type-x',
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(ClusterType.objects.count(), 3)
|
||||
clustertype1 = ClusterType.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(clustertype1.name, data['name'])
|
||||
self.assertEqual(clustertype1.slug, data['slug'])
|
||||
|
||||
def test_delete_clustertype(self):
|
||||
|
||||
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(ClusterType.objects.count(), 2)
|
||||
|
||||
|
||||
class ClusterGroupTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
self.clustergroup1 = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
|
||||
self.clustergroup2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2')
|
||||
self.clustergroup3 = ClusterGroup.objects.create(name='Test Cluster Group 3', slug='test-cluster-group-3')
|
||||
|
||||
def test_get_clustergroup(self):
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.clustergroup1.name)
|
||||
|
||||
def test_list_clustergroups(self):
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_clustergroup(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Cluster Group 4',
|
||||
'slug': 'test-cluster-group-4',
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ClusterGroup.objects.count(), 4)
|
||||
clustergroup4 = ClusterGroup.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(clustergroup4.name, data['name'])
|
||||
self.assertEqual(clustergroup4.slug, data['slug'])
|
||||
|
||||
def test_update_clustergroup(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Cluster Group X',
|
||||
'slug': 'test-cluster-group-x',
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(ClusterGroup.objects.count(), 3)
|
||||
clustergroup1 = ClusterGroup.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(clustergroup1.name, data['name'])
|
||||
self.assertEqual(clustergroup1.slug, data['slug'])
|
||||
|
||||
def test_delete_clustergroup(self):
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(ClusterGroup.objects.count(), 2)
|
||||
|
||||
|
||||
class ClusterTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
|
||||
cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
|
||||
|
||||
self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
|
||||
self.cluster2 = Cluster.objects.create(name='Test Cluster 2', type=cluster_type, group=cluster_group)
|
||||
self.cluster3 = Cluster.objects.create(name='Test Cluster 3', type=cluster_type, group=cluster_group)
|
||||
|
||||
def test_get_cluster(self):
|
||||
|
||||
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.cluster1.name)
|
||||
|
||||
def test_list_clusters(self):
|
||||
|
||||
url = reverse('virtualization-api:cluster-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_cluster(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Cluster 4',
|
||||
'type': ClusterType.objects.first().pk,
|
||||
'group': ClusterGroup.objects.first().pk,
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:cluster-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Cluster.objects.count(), 4)
|
||||
cluster4 = Cluster.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(cluster4.name, data['name'])
|
||||
self.assertEqual(cluster4.type.pk, data['type'])
|
||||
self.assertEqual(cluster4.group.pk, data['group'])
|
||||
|
||||
def test_update_cluster(self):
|
||||
|
||||
cluster_type2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
|
||||
cluster_group2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2')
|
||||
data = {
|
||||
'name': 'Test Cluster X',
|
||||
'type': cluster_type2.pk,
|
||||
'group': cluster_group2.pk,
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Cluster.objects.count(), 3)
|
||||
cluster1 = Cluster.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(cluster1.name, data['name'])
|
||||
self.assertEqual(cluster1.type.pk, data['type'])
|
||||
self.assertEqual(cluster1.group.pk, data['group'])
|
||||
|
||||
def test_delete_cluster(self):
|
||||
|
||||
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Cluster.objects.count(), 2)
|
||||
|
||||
|
||||
class VirtualMachineTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
|
||||
cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
|
||||
cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
|
||||
|
||||
self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=cluster)
|
||||
self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=cluster)
|
||||
self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=cluster)
|
||||
|
||||
def test_get_virtualmachine(self):
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.virtualmachine1.name)
|
||||
|
||||
def test_list_virtualmachines(self):
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_virtualmachine(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Virtual Machine 4',
|
||||
'cluster': Cluster.objects.first().pk,
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VirtualMachine.objects.count(), 4)
|
||||
virtualmachine4 = VirtualMachine.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(virtualmachine4.name, data['name'])
|
||||
self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
|
||||
|
||||
def test_update_virtualmachine(self):
|
||||
|
||||
cluster2 = Cluster.objects.create(
|
||||
name='Test Cluster 2',
|
||||
type=ClusterType.objects.first(),
|
||||
group=ClusterGroup.objects.first()
|
||||
)
|
||||
data = {
|
||||
'name': 'Test Virtual Machine X',
|
||||
'cluster': cluster2.pk,
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(VirtualMachine.objects.count(), 3)
|
||||
virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(virtualmachine1.name, data['name'])
|
||||
self.assertEqual(virtualmachine1.cluster.pk, data['cluster'])
|
||||
|
||||
def test_delete_virtualmachine(self):
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(VirtualMachine.objects.count(), 2)
|
53
netbox/virtualization/urls.py
Normal file
53
netbox/virtualization/urls.py
Normal file
@ -0,0 +1,53 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf.urls import url
|
||||
|
||||
from ipam.views import ServiceCreateView
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = 'virtualization'
|
||||
urlpatterns = [
|
||||
|
||||
# Cluster types
|
||||
url(r'^cluster-types/$', views.ClusterTypeListView.as_view(), name='clustertype_list'),
|
||||
url(r'^cluster-types/add/$', views.ClusterTypeCreateView.as_view(), name='clustertype_add'),
|
||||
url(r'^cluster-types/delete/$', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
|
||||
url(r'^cluster-types/(?P<slug>[\w-]+)/edit/$', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
|
||||
|
||||
# Cluster groups
|
||||
url(r'^cluster-groups/$', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
|
||||
url(r'^cluster-groups/add/$', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'),
|
||||
url(r'^cluster-groups/delete/$', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
|
||||
url(r'^cluster-groups/(?P<slug>[\w-]+)/edit/$', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
|
||||
|
||||
# Clusters
|
||||
url(r'^clusters/$', views.ClusterListView.as_view(), name='cluster_list'),
|
||||
url(r'^clusters/add/$', views.ClusterCreateView.as_view(), name='cluster_add'),
|
||||
url(r'^clusters/import/$', views.ClusterBulkImportView.as_view(), name='cluster_import'),
|
||||
# url(r'^clusters/edit/$', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'),
|
||||
url(r'^clusters/(?P<pk>\d+)/$', views.ClusterView.as_view(), name='cluster'),
|
||||
url(r'^clusters/(?P<pk>\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'),
|
||||
url(r'^clusters/(?P<pk>\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'),
|
||||
url(r'^clusters/(?P<pk>\d+)/devices/add/$', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
|
||||
url(r'^clusters/(?P<pk>\d+)/devices/remove/$', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
|
||||
|
||||
# Virtual machines
|
||||
url(r'^virtual-machines/$', views.VirtualMachineListView.as_view(), name='virtualmachine_list'),
|
||||
url(r'^virtual-machines/add/$', views.VirtualMachineCreateView.as_view(), name='virtualmachine_add'),
|
||||
url(r'^virtual-machines/import/$', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'),
|
||||
# url(r'^virtual-machines/edit/$', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'),
|
||||
url(r'^virtual-machines/(?P<pk>\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'),
|
||||
url(r'^virtual-machines/(?P<pk>\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
|
||||
url(r'^virtual-machines/(?P<pk>\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
|
||||
url(r'^virtual-machines/(?P<virtualmachine>\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),
|
||||
|
||||
# VM interfaces
|
||||
# url(r'^virtual-machines/interfaces/add/$', views.VMBulkAddInterfaceView.as_view(), name='vm_bulk_add_interface'),
|
||||
url(r'^virtual-machines/(?P<pk>\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'),
|
||||
url(r'^virtual-machines/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
|
||||
url(r'^virtual-machines/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
|
||||
url(r'^vm-interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
|
||||
url(r'^vm-interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
|
||||
|
||||
]
|
323
netbox/virtualization/views.py
Normal file
323
netbox/virtualization/views.py
Normal file
@ -0,0 +1,323 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.generic import View
|
||||
|
||||
from dcim.models import Device, Interface
|
||||
from dcim.tables import DeviceTable
|
||||
from ipam.models import Service
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView,
|
||||
ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
from . import filters
|
||||
from . import forms
|
||||
from . import tables
|
||||
|
||||
|
||||
#
|
||||
# Cluster types
|
||||
#
|
||||
|
||||
class ClusterTypeListView(ObjectListView):
|
||||
queryset = ClusterType.objects.annotate(cluster_count=Count('clusters'))
|
||||
table = tables.ClusterTypeTable
|
||||
template_name = 'virtualization/clustertype_list.html'
|
||||
|
||||
|
||||
class ClusterTypeCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'virtualization.add_clustertype'
|
||||
model = ClusterType
|
||||
form_class = forms.ClusterTypeForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('virtualization:clustertype_list')
|
||||
|
||||
|
||||
class ClusterTypeEditView(ClusterTypeCreateView):
|
||||
permission_required = 'virtualization.change_clustertype'
|
||||
|
||||
|
||||
class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'virtualization.delete_clustertype'
|
||||
cls = ClusterType
|
||||
queryset = ClusterType.objects.annotate(cluster_count=Count('clusters'))
|
||||
table = tables.ClusterTypeTable
|
||||
default_return_url = 'virtualization:clustertype_list'
|
||||
|
||||
|
||||
#
|
||||
# Cluster groups
|
||||
#
|
||||
|
||||
class ClusterGroupListView(ObjectListView):
|
||||
queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters'))
|
||||
table = tables.ClusterGroupTable
|
||||
template_name = 'virtualization/clustergroup_list.html'
|
||||
|
||||
|
||||
class ClusterGroupCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'virtualization.add_clustergroup'
|
||||
model = ClusterGroup
|
||||
form_class = forms.ClusterGroupForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('virtualization:clustergroup_list')
|
||||
|
||||
|
||||
class ClusterGroupEditView(ClusterGroupCreateView):
|
||||
permission_required = 'virtualization.change_clustergroup'
|
||||
|
||||
|
||||
class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'virtualization.delete_clustergroup'
|
||||
cls = ClusterGroup
|
||||
queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters'))
|
||||
table = tables.ClusterGroupTable
|
||||
default_return_url = 'virtualization:clustergroup_list'
|
||||
|
||||
|
||||
#
|
||||
# Clusters
|
||||
#
|
||||
|
||||
class ClusterListView(ObjectListView):
|
||||
queryset = Cluster.objects.annotate(vm_count=Count('virtual_machines'))
|
||||
table = tables.ClusterTable
|
||||
filter = filters.ClusterFilter
|
||||
filter_form = forms.ClusterFilterForm
|
||||
template_name = 'virtualization/cluster_list.html'
|
||||
|
||||
|
||||
class ClusterView(View):
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
cluster = get_object_or_404(Cluster, pk=pk)
|
||||
devices = Device.objects.filter(cluster=cluster).select_related(
|
||||
'site', 'rack', 'tenant', 'device_type__manufacturer'
|
||||
)
|
||||
device_table = DeviceTable(list(devices), orderable=False)
|
||||
if request.user.has_perm('virtualization:change_cluster'):
|
||||
device_table.columns.show('pk')
|
||||
|
||||
return render(request, 'virtualization/cluster.html', {
|
||||
'cluster': cluster,
|
||||
'device_table': device_table,
|
||||
})
|
||||
|
||||
|
||||
class ClusterCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'virtualization.add_cluster'
|
||||
model = Cluster
|
||||
form_class = forms.ClusterForm
|
||||
|
||||
|
||||
class ClusterEditView(ClusterCreateView):
|
||||
permission_required = 'virtualization.change_cluster'
|
||||
|
||||
|
||||
class ClusterDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'virtualization.delete_cluster'
|
||||
model = Cluster
|
||||
default_return_url = 'virtualization:cluster_list'
|
||||
|
||||
|
||||
class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
permission_required = 'virtualization.add_cluster'
|
||||
model_form = forms.ClusterCSVForm
|
||||
table = tables.ClusterTable
|
||||
default_return_url = 'virtualization:cluster_list'
|
||||
|
||||
|
||||
class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'virtualization.delete_cluster'
|
||||
cls = Cluster
|
||||
queryset = Cluster.objects.annotate(vm_count=Count('virtual_machines'))
|
||||
table = tables.ClusterTable
|
||||
default_return_url = 'virtualization:cluster_list'
|
||||
|
||||
|
||||
class ClusterAddDevicesView(PermissionRequiredMixin, View):
|
||||
permission_required = 'virtualization.change_cluster'
|
||||
form = forms.ClusterAddDevicesForm
|
||||
template_name = 'virtualization/cluster_add_devices.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
cluster = get_object_or_404(Cluster, pk=pk)
|
||||
form = self.form()
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'cluster': cluster,
|
||||
'form': form,
|
||||
'return_url': reverse('virtualization:cluster', kwargs={'pk': pk}),
|
||||
})
|
||||
|
||||
def post(self, request, pk):
|
||||
|
||||
cluster = get_object_or_404(Cluster, pk=pk)
|
||||
form = self.form(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
# Assign the selected Devices to the Cluster
|
||||
devices = form.cleaned_data['devices']
|
||||
Device.objects.filter(pk__in=devices).update(cluster=cluster)
|
||||
|
||||
messages.success(request, "Added {} devices to cluster {}".format(
|
||||
len(devices), cluster
|
||||
))
|
||||
return redirect(cluster.get_absolute_url())
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'cluser': cluster,
|
||||
'form': form,
|
||||
'return_url': cluster.get_absolute_url(),
|
||||
})
|
||||
|
||||
|
||||
class ClusterRemoveDevicesView(PermissionRequiredMixin, View):
|
||||
permission_required = 'virtualization.change_cluster'
|
||||
form = forms.ClusterRemoveDevicesForm
|
||||
template_name = 'utilities/obj_bulk_remove.html'
|
||||
|
||||
def post(self, request, pk):
|
||||
|
||||
cluster = get_object_or_404(Cluster, pk=pk)
|
||||
|
||||
if '_confirm' in request.POST:
|
||||
form = self.form(request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
# Remove the selected Devices from the Cluster
|
||||
devices = form.cleaned_data['pk']
|
||||
Device.objects.filter(pk__in=devices).update(cluster=None)
|
||||
|
||||
messages.success(request, "Removed {} devices from cluster {}".format(
|
||||
len(devices), cluster
|
||||
))
|
||||
return redirect(cluster.get_absolute_url())
|
||||
|
||||
else:
|
||||
form = self.form(initial={'pk': request.POST.getlist('pk')})
|
||||
|
||||
selected_objects = Device.objects.filter(pk__in=form.initial['pk'])
|
||||
device_table = DeviceTable(list(selected_objects), orderable=False)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
'parent_obj': cluster,
|
||||
'table': device_table,
|
||||
'obj_type_plural': 'devices',
|
||||
'return_url': cluster.get_absolute_url(),
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Virtual machines
|
||||
#
|
||||
|
||||
class VirtualMachineListView(ObjectListView):
|
||||
queryset = VirtualMachine.objects.select_related('tenant')
|
||||
filter = filters.VirtualMachineFilter
|
||||
filter_form = forms.VirtualMachineFilterForm
|
||||
table = tables.VirtualMachineTable
|
||||
template_name = 'virtualization/virtualmachine_list.html'
|
||||
|
||||
|
||||
class VirtualMachineView(View):
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
vm = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk)
|
||||
interfaces = Interface.objects.filter(virtual_machine=vm)
|
||||
services = Service.objects.filter(virtual_machine=vm)
|
||||
|
||||
return render(request, 'virtualization/virtualmachine.html', {
|
||||
'vm': vm,
|
||||
'interfaces': interfaces,
|
||||
'services': services,
|
||||
})
|
||||
|
||||
|
||||
class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'virtualization.add_virtualmachine'
|
||||
model = VirtualMachine
|
||||
form_class = forms.VirtualMachineForm
|
||||
template_name = 'virtualization/virtualmachine_edit.html'
|
||||
default_return_url = 'virtualization:virtualmachine_list'
|
||||
|
||||
|
||||
class VirtualMachineEditView(VirtualMachineCreateView):
|
||||
permission_required = 'virtualization.change_virtualmachine'
|
||||
|
||||
|
||||
class VirtualMachineDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'virtualization.delete_virtualmachine'
|
||||
model = VirtualMachine
|
||||
default_return_url = 'virtualization:virtualmachine_list'
|
||||
|
||||
|
||||
class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
permission_required = 'virtualization.add_virtualmachine'
|
||||
model_form = forms.VirtualMachineCSVForm
|
||||
table = tables.VirtualMachineTable
|
||||
default_return_url = 'virtualization:virtualmachine_list'
|
||||
|
||||
|
||||
class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'virtualization.change_virtualmachine'
|
||||
cls = VirtualMachine
|
||||
queryset = VirtualMachine.objects.select_related('tenant')
|
||||
filter = filters.VirtualMachineFilter
|
||||
table = tables.VirtualMachineTable
|
||||
form = forms.VirtualMachineBulkEditForm
|
||||
default_return_url = 'virtualization:virtualmachine_list'
|
||||
|
||||
|
||||
#
|
||||
# VM interfaces
|
||||
#
|
||||
|
||||
class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
|
||||
permission_required = 'dcim.add_interface'
|
||||
parent_model = VirtualMachine
|
||||
parent_field = 'virtual_machine'
|
||||
model = Interface
|
||||
form = forms.InterfaceCreateForm
|
||||
model_form = forms.InterfaceForm
|
||||
template_name = 'virtualization/virtualmachine_component_add.html'
|
||||
|
||||
|
||||
class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_interface'
|
||||
model = Interface
|
||||
parent_field = 'virtual_machine'
|
||||
form_class = forms.InterfaceForm
|
||||
|
||||
|
||||
class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_interface'
|
||||
model = Interface
|
||||
parent_field = 'virtual_machine'
|
||||
|
||||
|
||||
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'dcim.change_interface'
|
||||
cls = Interface
|
||||
parent_cls = VirtualMachine
|
||||
table = tables.InterfaceTable
|
||||
form = forms.InterfaceBulkEditForm
|
||||
|
||||
|
||||
class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_interface'
|
||||
cls = Interface
|
||||
parent_cls = VirtualMachine
|
||||
table = tables.InterfaceTable
|
Loading…
Reference in New Issue
Block a user