diff --git a/docs/data-model/ipam.md b/docs/data-model/ipam.md index 8b6d53184..6848804fd 100644 --- a/docs/data-model/ipam.md +++ b/docs/data-model/ipam.md @@ -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.) diff --git a/docs/data-model/virtualization.md b/docs/data-model/virtualization.md new file mode 100644 index 000000000..d49f7b323 --- /dev/null +++ b/docs/data-model/virtualization.md @@ -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) diff --git a/mkdocs.yml b/mkdocs.yml index f204749d5..b1a4b188a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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' diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index ebfb781e0..9f9b0f6a6 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -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): diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 9c2993b60..b3d434c85 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -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)', diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index a9f58d2a4..50a4e0f98 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -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(), diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 440c12623..747092b3d 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -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') diff --git a/netbox/dcim/migrations/0044_virtualization.py b/netbox/dcim/migrations/0044_virtualization.py new file mode 100644 index 000000000..b1e250bc2 --- /dev/null +++ b/netbox/dcim/migrations/0044_virtualization.py @@ -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'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index a44097f51..4b3d41304 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -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 diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 172f634fb..3c0b30bcb 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -126,7 +126,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), url(r'^devices/(?P\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'), url(r'^devices/(?P\d+)/add-secret/$', secret_add, name='device_addsecret'), - url(r'^devices/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'), + url(r'^devices/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='device_service_assign'), url(r'^devices/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), # Console ports diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d2c75fc24..671e514dc 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -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' diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 3ef152ebe..a5b64fca6 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -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'] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 9cf93cb4b..50c9c8a95 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -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 diff --git a/netbox/ipam/fixtures/ipam.json b/netbox/ipam/fixtures/ipam.json index 1a981a941..10e22b2d7 100644 --- a/netbox/ipam/fixtures/ipam.json +++ b/netbox/ipam/fixtures/ipam.json @@ -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": "" } diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index e19376e8e..a91c783d1 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -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 = [] diff --git a/netbox/ipam/migrations/0019_virtualization.py b/netbox/ipam/migrations/0019_virtualization.py new file mode 100644 index 000000000..955ff8b4a --- /dev/null +++ b/netbox/ipam/migrations/0019_virtualization.py @@ -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'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index bbd5e1827..f34d55db7 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -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.") diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 96127aec5..4482dca05 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -77,9 +77,9 @@ IPADDRESS_LINK = """ {% endif %} """ -IPADDRESS_DEVICE = """ +IPADDRESS_PARENT = """ {% if record.interface %} - {{ record.interface.device }} + {{ record.interface.parent }} {% 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', ) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index d0225e567..edaa31869 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -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): diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 02cb5953d..b2b85a8f9 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -146,6 +146,7 @@ INSTALLED_APPS = ( 'tenancy', 'users', 'utilities', + 'virtualization', ) # Middleware diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index ddddf27a2..1c8051c06 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -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 diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index d5224b462..faf3f2ee7 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -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): diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 66f23a4cb..ad8afb4b1 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -202,6 +202,34 @@ {% endif %} +