Browse Source

Merge branch 'develop' into develop-2.3

Jeremy Stretch 7 years ago
parent
commit
8b33b888b2
46 changed files with 367 additions and 136 deletions
  1. 2 2
      docs/api/examples.md
  2. 1 1
      docs/installation/ldap.md
  3. 2 2
      docs/miscellaneous/shell.md
  4. 15 2
      netbox/dcim/forms.py
  5. 1 1
      netbox/extras/admin.py
  6. 20 0
      netbox/extras/constants.py
  7. 12 9
      netbox/extras/filters.py
  8. 5 6
      netbox/extras/forms.py
  9. 0 9
      netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py
  10. 20 0
      netbox/extras/migrations/0009_topologymap_type.py
  11. 51 0
      netbox/extras/migrations/0010_customfield_filter_logic.py
  12. 102 28
      netbox/extras/models.py
  13. 19 0
      netbox/ipam/migrations/0021_vrf_ordering.py
  14. 1 1
      netbox/ipam/models.py
  15. 0 1
      netbox/templates/circuits/circuit_list.html
  16. 0 1
      netbox/templates/circuits/circuittype_list.html
  17. 0 1
      netbox/templates/dcim/device_list.html
  18. 0 1
      netbox/templates/dcim/devicerole_list.html
  19. 0 1
      netbox/templates/dcim/devicetype_list.html
  20. 15 9
      netbox/templates/dcim/inc/device_header.html
  21. 15 0
      netbox/templates/dcim/inc/device_napalm_tabs.html
  22. 29 0
      netbox/templates/dcim/inc/filter_rack_group.html
  23. 0 1
      netbox/templates/dcim/manufacturer_list.html
  24. 0 1
      netbox/templates/dcim/platform_list.html
  25. 6 5
      netbox/templates/dcim/rack_elevation_list.html
  26. 1 30
      netbox/templates/dcim/rack_list.html
  27. 0 1
      netbox/templates/dcim/rackgroup_list.html
  28. 0 1
      netbox/templates/dcim/region_list.html
  29. 0 1
      netbox/templates/ipam/aggregate_list.html
  30. 1 1
      netbox/templates/ipam/ipaddress.html
  31. 0 1
      netbox/templates/ipam/ipaddress_list.html
  32. 1 1
      netbox/templates/ipam/prefix.html
  33. 0 1
      netbox/templates/ipam/prefix_list.html
  34. 0 1
      netbox/templates/ipam/rir_list.html
  35. 0 1
      netbox/templates/ipam/role_list.html
  36. 0 2
      netbox/templates/ipam/vlan_list.html
  37. 0 1
      netbox/templates/ipam/vlangroup_list.html
  38. 1 2
      netbox/templates/ipam/vrf_list.html
  39. 0 1
      netbox/templates/secrets/secret_list.html
  40. 0 1
      netbox/templates/secrets/secretrole_list.html
  41. 0 1
      netbox/templates/tenancy/tenant_list.html
  42. 0 1
      netbox/templates/tenancy/tenantgroup_list.html
  43. 0 1
      netbox/templates/virtualization/clustergroup_list.html
  44. 0 1
      netbox/templates/virtualization/clustertype_list.html
  45. 9 2
      netbox/templates/virtualization/virtualmachine_edit.html
  46. 38 2
      netbox/virtualization/forms.py

+ 2 - 2
docs/api/examples.md

@@ -5,7 +5,7 @@ Supported HTTP methods:
 * `GET`: Retrieve an object or list of objects
 * `GET`: Retrieve an object or list of objects
 * `POST`: Create a new object
 * `POST`: Create a new object
 * `PUT`: Update an existing object, all mandatory fields must be specified
 * `PUT`: Update an existing object, all mandatory fields must be specified
-* `PATCH`: Updates an existing object, only specifiying the field to be changed
+* `PATCH`: Updates an existing object, only specifying the field to be changed
 * `DELETE`: Delete an existing object
 * `DELETE`: Delete an existing object
 
 
 To authenticate a request, attach your token in an `Authorization` header:
 To authenticate a request, attach your token in an `Authorization` header:
@@ -144,4 +144,4 @@ $ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f
 * Closing connection 0
 * Closing connection 0
 ```
 ```
 
 
-The response to a successfull `DELETE` request will have code 204 (No Content); the body of the response will be empty.
+The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty.

+ 1 - 1
docs/installation/ldap.md

@@ -87,7 +87,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
 from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
 from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
 
 
 # This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
 # This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
-# heirarchy.
+# hierarchy.
 AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE,
 AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE,
                                     "(objectClass=group)")
                                     "(objectClass=group)")
 AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()
 AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()

+ 2 - 2
docs/miscellaneous/shell.md

@@ -1,4 +1,4 @@
-NetBox includes a Python shell withing which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
+NetBox includes a Python shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
 
 
 ```
 ```
 ./manage.py nbshell
 ./manage.py nbshell
@@ -86,7 +86,7 @@ The `count()` method can be appended to the queryset to return a count of object
 982
 982
 ```
 ```
 
 
-Relationships with other models can be traversed by concatenting field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
+Relationships with other models can be traversed by concatenating field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
 
 
 ```
 ```
 >>> Device.objects.filter(tenant__name='Pied Piper')
 >>> Device.objects.filter(tenant__name='Pied Piper')

+ 15 - 2
netbox/dcim/forms.py

@@ -1086,6 +1086,15 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     )
     )
     status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
     status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
     mac_address = forms.CharField(required=False, label='MAC address')
     mac_address = forms.CharField(required=False, label='MAC address')
+    has_primary_ip = forms.NullBooleanField(
+        required=False,
+        label='Has a primary IP',
+        widget=forms.Select(choices=[
+            ('', '---------'),
+            ('True', 'Yes'),
+            ('False', 'No'),
+        ])
+    )
 
 
 
 
 #
 #
@@ -1688,7 +1697,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         fields = [
-            'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
+            'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
             'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
             'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
         ]
         ]
         widgets = {
         widgets = {
@@ -1768,7 +1777,11 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
     lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
     lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
     mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
     mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
     mac_address = MACAddressFormField(required=False, label='MAC Address')
     mac_address = MACAddressFormField(required=False, label='MAC Address')
-    mgmt_only = forms.BooleanField(required=False, label='OOB Management')
+    mgmt_only = forms.BooleanField(
+        required=False,
+        label='OOB Management',
+        help_text='This interface is used only for out-of-band management'
+    )
     description = forms.CharField(max_length=100, required=False)
     description = forms.CharField(max_length=100, required=False)
     mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
     mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
     site = forms.ModelChoiceField(
     site = forms.ModelChoiceField(

+ 1 - 1
netbox/extras/admin.py

@@ -39,7 +39,7 @@ class CustomFieldChoiceAdmin(admin.TabularInline):
 @admin.register(CustomField)
 @admin.register(CustomField)
 class CustomFieldAdmin(admin.ModelAdmin):
 class CustomFieldAdmin(admin.ModelAdmin):
     inlines = [CustomFieldChoiceAdmin]
     inlines = [CustomFieldChoiceAdmin]
-    list_display = ['name', 'models', 'type', 'required', 'default', 'weight', 'description']
+    list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description']
     form = CustomFieldForm
     form = CustomFieldForm
 
 
     def models(self, obj):
     def models(self, obj):

+ 20 - 0
netbox/extras/constants.py

@@ -26,6 +26,16 @@ CUSTOMFIELD_TYPE_CHOICES = (
     (CF_TYPE_SELECT, 'Selection'),
     (CF_TYPE_SELECT, 'Selection'),
 )
 )
 
 
+# Custom field filter logic choices
+CF_FILTER_DISABLED = 0
+CF_FILTER_LOOSE = 1
+CF_FILTER_EXACT = 2
+CF_FILTER_CHOICES = (
+    (CF_FILTER_DISABLED, 'Disabled'),
+    (CF_FILTER_LOOSE, 'Loose'),
+    (CF_FILTER_EXACT, 'Exact'),
+)
+
 # Graph types
 # Graph types
 GRAPH_TYPE_INTERFACE = 100
 GRAPH_TYPE_INTERFACE = 100
 GRAPH_TYPE_PROVIDER = 200
 GRAPH_TYPE_PROVIDER = 200
@@ -46,6 +56,16 @@ EXPORTTEMPLATE_MODELS = [
     'cluster', 'virtualmachine',                                                    # Virtualization
     'cluster', 'virtualmachine',                                                    # Virtualization
 ]
 ]
 
 
+# Topology map types
+TOPOLOGYMAP_TYPE_NETWORK = 1
+TOPOLOGYMAP_TYPE_CONSOLE = 2
+TOPOLOGYMAP_TYPE_POWER = 3
+TOPOLOGYMAP_TYPE_CHOICES = (
+    (TOPOLOGYMAP_TYPE_NETWORK, 'Network'),
+    (TOPOLOGYMAP_TYPE_CONSOLE, 'Console'),
+    (TOPOLOGYMAP_TYPE_POWER, 'Power'),
+)
+
 # User action types
 # User action types
 ACTION_CREATE = 1
 ACTION_CREATE = 1
 ACTION_IMPORT = 2
 ACTION_IMPORT = 2

+ 12 - 9
netbox/extras/filters.py

@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 
 
 from dcim.models import Site
 from dcim.models import Site
-from .constants import CF_TYPE_SELECT
+from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
 from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction
 from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction
 
 
 
 
@@ -14,8 +14,9 @@ class CustomFieldFilter(django_filters.Filter):
     Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
     Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
     """
     """
 
 
-    def __init__(self, cf_type, *args, **kwargs):
+    def __init__(self, custom_field, *args, **kwargs):
-        self.cf_type = cf_type
+        self.cf_type = custom_field.type
+        self.filter_logic = custom_field.filter_logic
         super(CustomFieldFilter, self).__init__(*args, **kwargs)
         super(CustomFieldFilter, self).__init__(*args, **kwargs)
 
 
     def filter(self, queryset, value):
     def filter(self, queryset, value):
@@ -41,10 +42,12 @@ class CustomFieldFilter(django_filters.Filter):
             except ValueError:
             except ValueError:
                 return queryset.none()
                 return queryset.none()
 
 
-        return queryset.filter(
+        # Apply the assigned filter logic (exact or loose)
-            custom_field_values__field__name=self.name,
+        queryset = queryset.filter(custom_field_values__field__name=self.name)
-            custom_field_values__serialized_value__icontains=value,
+        if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT:
-        )
+            return queryset.filter(custom_field_values__serialized_value=value)
+        else:
+            return queryset.filter(custom_field_values__serialized_value__icontains=value)
 
 
 
 
 class CustomFieldFilterSet(django_filters.FilterSet):
 class CustomFieldFilterSet(django_filters.FilterSet):
@@ -56,9 +59,9 @@ class CustomFieldFilterSet(django_filters.FilterSet):
         super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
         super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
 
 
         obj_type = ContentType.objects.get_for_model(self._meta.model)
         obj_type = ContentType.objects.get_for_model(self._meta.model)
-        custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
+        custom_fields = CustomField.objects.filter(obj_type=obj_type).exclude(filter_logic=CF_FILTER_DISABLED)
         for cf in custom_fields:
         for cf in custom_fields:
-            self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)
+            self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, custom_field=cf)
 
 
 
 
 class GraphFilter(django_filters.FilterSet):
 class GraphFilter(django_filters.FilterSet):

+ 5 - 6
netbox/extras/forms.py

@@ -6,7 +6,7 @@ from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 
 
 from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
 from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
-from .constants import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
+from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
 from .models import CustomField, CustomFieldValue, ImageAttachment
 from .models import CustomField, CustomFieldValue, ImageAttachment
 
 
 
 
@@ -15,10 +15,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
     Retrieve all CustomFields applicable to the given ContentType
     Retrieve all CustomFields applicable to the given ContentType
     """
     """
     field_dict = OrderedDict()
     field_dict = OrderedDict()
-    kwargs = {'obj_type': content_type}
+    custom_fields = CustomField.objects.filter(obj_type=content_type)
     if filterable_only:
     if filterable_only:
-        kwargs['is_filterable'] = True
+        custom_fields = custom_fields.exclude(filter_logic=CF_FILTER_DISABLED)
-    custom_fields = CustomField.objects.filter(**kwargs)
 
 
     for cf in custom_fields:
     for cf in custom_fields:
         field_name = 'cf_{}'.format(str(cf.name))
         field_name = 'cf_{}'.format(str(cf.name))
@@ -35,9 +34,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
                 (1, 'True'),
                 (1, 'True'),
                 (0, 'False'),
                 (0, 'False'),
             )
             )
-            if initial.lower() in ['true', 'yes', '1']:
+            if initial is not None and initial.lower() in ['true', 'yes', '1']:
                 initial = 1
                 initial = 1
-            elif initial.lower() in ['false', 'no', '0']:
+            elif initial is not None and initial.lower() in ['false', 'no', '0']:
                 initial = 0
                 initial = 0
             else:
             else:
                 initial = None
                 initial = None

+ 0 - 9
netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py

@@ -4,14 +4,6 @@ from __future__ import unicode_literals
 
 
 from django.db import migrations, models
 from django.db import migrations, models
 
 
-from extras.models import TopologyMap
-
-
-def commas_to_semicolons(apps, schema_editor):
-    for tm in TopologyMap.objects.filter(device_patterns__contains=','):
-        tm.device_patterns = tm.device_patterns.replace(',', ';')
-        tm.save()
-
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
@@ -25,5 +17,4 @@ class Migration(migrations.Migration):
             name='device_patterns',
             name='device_patterns',
             field=models.TextField(help_text=b'Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'),
             field=models.TextField(help_text=b'Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'),
         ),
         ),
-        migrations.RunPython(commas_to_semicolons),
     ]
     ]

+ 20 - 0
netbox/extras/migrations/0009_topologymap_type.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.9 on 2018-02-15 16:28
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0008_reports'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='topologymap',
+            name='type',
+            field=models.PositiveSmallIntegerField(choices=[(1, 'Network'), (2, 'Console'), (3, 'Power')], default=1),
+        ),
+    ]

+ 51 - 0
netbox/extras/migrations/0010_customfield_filter_logic.py

@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.9 on 2018-02-21 19:48
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT
+
+
+def is_filterable_to_filter_logic(apps, schema_editor):
+    CustomField = apps.get_model('extras', 'CustomField')
+    CustomField.objects.filter(is_filterable=False).update(filter_logic=CF_FILTER_DISABLED)
+    CustomField.objects.filter(is_filterable=True).update(filter_logic=CF_FILTER_LOOSE)
+    # Select fields match on primary key only
+    CustomField.objects.filter(is_filterable=True, type=CF_TYPE_SELECT).update(filter_logic=CF_FILTER_EXACT)
+
+
+def filter_logic_to_is_filterable(apps, schema_editor):
+    CustomField = apps.get_model('extras', 'CustomField')
+    CustomField.objects.filter(filter_logic=CF_FILTER_DISABLED).update(is_filterable=False)
+    CustomField.objects.exclude(filter_logic=CF_FILTER_DISABLED).update(is_filterable=True)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0009_topologymap_type'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='customfield',
+            name='filter_logic',
+            field=models.PositiveSmallIntegerField(choices=[(0, 'Disabled'), (1, 'Loose'), (2, 'Exact')], default=1, help_text='Loose matches any instance of a given string; exact matches the entire field.'),
+        ),
+        migrations.AlterField(
+            model_name='customfield',
+            name='required',
+            field=models.BooleanField(default=False, help_text='If true, this field is required when creating new objects or editing an existing object.'),
+        ),
+        migrations.AlterField(
+            model_name='customfield',
+            name='weight',
+            field=models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form.'),
+        ),
+        migrations.RunPython(is_filterable_to_filter_logic, filter_logic_to_is_filterable),
+        migrations.RemoveField(
+            model_name='customfield',
+            name='is_filterable',
+        ),
+    ]

+ 102 - 28
netbox/extras/models.py

@@ -16,6 +16,7 @@ from django.template import Template, Context
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 
 
+from dcim.constants import CONNECTION_STATUS_CONNECTED
 from utilities.utils import foreground_color
 from utilities.utils import foreground_color
 from .constants import *
 from .constants import *
 
 
@@ -54,22 +55,48 @@ class CustomFieldModel(object):
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
 class CustomField(models.Model):
 class CustomField(models.Model):
-    obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
+    obj_type = models.ManyToManyField(
-                                      limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
+        to=ContentType,
-                                      help_text="The object(s) to which this field applies.")
+        related_name='custom_fields',
-    type = models.PositiveSmallIntegerField(choices=CUSTOMFIELD_TYPE_CHOICES, default=CF_TYPE_TEXT)
+        verbose_name='Object(s)',
-    name = models.CharField(max_length=50, unique=True)
+        limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
-    label = models.CharField(max_length=50, blank=True, help_text="Name of the field as displayed to users (if not "
+        help_text='The object(s) to which this field applies.'
-                                                                  "provided, the field's name will be used)")
+    )
-    description = models.CharField(max_length=100, blank=True)
+    type = models.PositiveSmallIntegerField(
-    required = models.BooleanField(default=False, help_text="Determines whether this field is required when creating "
+        choices=CUSTOMFIELD_TYPE_CHOICES,
-                                                            "new objects or editing an existing object.")
+        default=CF_TYPE_TEXT
-    is_filterable = models.BooleanField(default=True, help_text="This field can be used to filter objects.")
+    )
-    default = models.CharField(max_length=100, blank=True, help_text="Default value for the field. Use \"true\" or "
+    name = models.CharField(
-                                                                     "\"false\" for booleans. N/A for selection "
+        max_length=50,
-                                                                     "fields.")
+        unique=True
-    weight = models.PositiveSmallIntegerField(default=100, help_text="Fields with higher weights appear lower in a "
+    )
-                                                                     "form")
+    label = models.CharField(
+        max_length=50,
+        blank=True,
+        help_text='Name of the field as displayed to users (if not provided, the field\'s name will be used)'
+    )
+    description = models.CharField(
+        max_length=100,
+        blank=True
+    )
+    required = models.BooleanField(
+        default=False,
+        help_text='If true, this field is required when creating new objects or editing an existing object.'
+    )
+    filter_logic = models.PositiveSmallIntegerField(
+        choices=CF_FILTER_CHOICES,
+        default=CF_FILTER_LOOSE,
+        help_text="Loose matches any instance of a given string; exact matches the entire field."
+    )
+    default = models.CharField(
+        max_length=100,
+        blank=True,
+        help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.'
+    )
+    weight = models.PositiveSmallIntegerField(
+        default=100,
+        help_text='Fields with higher weights appear lower in a form.'
+    )
 
 
     class Meta:
     class Meta:
         ordering = ['weight', 'name']
         ordering = ['weight', 'name']
@@ -253,7 +280,17 @@ class ExportTemplate(models.Model):
 class TopologyMap(models.Model):
 class TopologyMap(models.Model):
     name = models.CharField(max_length=50, unique=True)
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
     slug = models.SlugField(unique=True)
-    site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True, on_delete=models.CASCADE)
+    type = models.PositiveSmallIntegerField(
+        choices=TOPOLOGYMAP_TYPE_CHOICES,
+        default=TOPOLOGYMAP_TYPE_NETWORK
+    )
+    site = models.ForeignKey(
+        to='dcim.Site',
+        related_name='topology_maps',
+        blank=True,
+        null=True,
+        on_delete=models.CASCADE
+    )
     device_patterns = models.TextField(
     device_patterns = models.TextField(
         help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
         help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
                   "result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
                   "result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
@@ -275,22 +312,26 @@ class TopologyMap(models.Model):
 
 
     def render(self, img_format='png'):
     def render(self, img_format='png'):
 
 
-        from circuits.models import CircuitTermination
+        from dcim.models import Device
-        from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection
 
 
         # Construct the graph
         # Construct the graph
-        graph = graphviz.Graph()
+        if self.type == TOPOLOGYMAP_TYPE_NETWORK:
-        graph.graph_attr['ranksep'] = '1'
+            G = graphviz.Graph
+        else:
+            G = graphviz.Digraph
+        self.graph = G()
+        self.graph.graph_attr['ranksep'] = '1'
         seen = set()
         seen = set()
         for i, device_set in enumerate(self.device_sets):
         for i, device_set in enumerate(self.device_sets):
 
 
-            subgraph = graphviz.Graph(name='sg{}'.format(i))
+            subgraph = G(name='sg{}'.format(i))
             subgraph.graph_attr['rank'] = 'same'
             subgraph.graph_attr['rank'] = 'same'
+            subgraph.graph_attr['directed'] = 'true'
 
 
             # Add a pseudonode for each device_set to enforce hierarchical layout
             # Add a pseudonode for each device_set to enforce hierarchical layout
             subgraph.node('set{}'.format(i), label='', shape='none', width='0')
             subgraph.node('set{}'.format(i), label='', shape='none', width='0')
             if i:
             if i:
-                graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
+                self.graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
 
 
             # Add each device to the graph
             # Add each device to the graph
             devices = []
             devices = []
@@ -308,31 +349,64 @@ class TopologyMap(models.Model):
             for j in range(0, len(devices) - 1):
             for j in range(0, len(devices) - 1):
                 subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
                 subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
 
 
-            graph.subgraph(subgraph)
+            self.graph.subgraph(subgraph)
 
 
         # Compile list of all devices
         # Compile list of all devices
         device_superset = Q()
         device_superset = Q()
         for device_set in self.device_sets:
         for device_set in self.device_sets:
             for query in device_set.split(';'):  # Split regexes on semicolons
             for query in device_set.split(';'):  # Split regexes on semicolons
                 device_superset = device_superset | Q(name__regex=query)
                 device_superset = device_superset | Q(name__regex=query)
+        devices = Device.objects.filter(*(device_superset,))
+
+        # Draw edges depending on graph type
+        if self.type == TOPOLOGYMAP_TYPE_NETWORK:
+            self.add_network_connections(devices)
+        elif self.type == TOPOLOGYMAP_TYPE_CONSOLE:
+            self.add_console_connections(devices)
+        elif self.type == TOPOLOGYMAP_TYPE_POWER:
+            self.add_power_connections(devices)
+
+        return self.graph.pipe(format=img_format)
+
+    def add_network_connections(self, devices):
+
+        from circuits.models import CircuitTermination
+        from dcim.models import InterfaceConnection
 
 
         # Add all interface connections to the graph
         # Add all interface connections to the graph
-        devices = Device.objects.filter(*(device_superset,))
         connections = InterfaceConnection.objects.filter(
         connections = InterfaceConnection.objects.filter(
             interface_a__device__in=devices, interface_b__device__in=devices
             interface_a__device__in=devices, interface_b__device__in=devices
         )
         )
         for c in connections:
         for c in connections:
             style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
             style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
-            graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
+            self.graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
 
 
         # Add all circuits to the graph
         # Add all circuits to the graph
         for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
         for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
             peer_termination = termination.get_peer_termination()
             peer_termination = termination.get_peer_termination()
             if (peer_termination is not None and peer_termination.interface is not None and
             if (peer_termination is not None and peer_termination.interface is not None and
                     peer_termination.interface.device in devices):
                     peer_termination.interface.device in devices):
-                graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
+                self.graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
+
+    def add_console_connections(self, devices):
+
+        from dcim.models import ConsolePort
+
+        # Add all console connections to the graph
+        console_ports = ConsolePort.objects.filter(device__in=devices, cs_port__device__in=devices)
+        for cp in console_ports:
+            style = 'solid' if cp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
+            self.graph.edge(cp.cs_port.device.name, cp.device.name, style=style)
+
+    def add_power_connections(self, devices):
+
+        from dcim.models import PowerPort
 
 
-        return graph.pipe(format=img_format)
+        # Add all power connections to the graph
+        power_ports = PowerPort.objects.filter(device__in=devices, power_outlet__device__in=devices)
+        for pp in power_ports:
+            style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
+            self.graph.edge(pp.power_outlet.device.name, pp.device.name, style=style)
 
 
 
 
 #
 #

+ 19 - 0
netbox/ipam/migrations/0021_vrf_ordering.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.9 on 2018-02-07 18:37
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0020_ipaddress_add_role_carp'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='vrf',
+            options={'ordering': ['name', 'rd'], 'verbose_name': 'VRF', 'verbose_name_plural': 'VRFs'},
+        ),
+    ]

+ 1 - 1
netbox/ipam/models.py

@@ -37,7 +37,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
     csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
     csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
 
 
     class Meta:
     class Meta:
-        ordering = ['name']
+        ordering = ['name', 'rd']
         verbose_name = 'VRF'
         verbose_name = 'VRF'
         verbose_name_plural = 'VRFs'
         verbose_name_plural = 'VRFs'
 
 

+ 0 - 1
netbox/templates/circuits/circuit_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/circuits/circuittype_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/dcim/device_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/dcim/devicerole_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/dcim/devicetype_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 15 - 9
netbox/templates/dcim/inc/device_header.html

@@ -43,17 +43,23 @@
 <h1>{{ device }}</h1>
 <h1>{{ device }}</h1>
 {% include 'inc/created_updated.html' with obj=device %}
 {% include 'inc/created_updated.html' with obj=device %}
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
-    <li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}><a href="{% url 'dcim:device' pk=device.pk %}">Info</a></li>
+    <li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}>
-    <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}><a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a></li>
+        <a href="{% url 'dcim:device' pk=device.pk %}">Info</a>
+    </li>
+    <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
+        <a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a>
+    </li>
     {% if perms.dcim.napalm_read %}
     {% if perms.dcim.napalm_read %}
-        {% if device.status == 1 and device.platform.napalm_driver and device.primary_ip %}
+        {% if device.status != 1 %}
-            <li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}><a href="{% url 'dcim:device_status' pk=device.pk %}">Status</a></li>
+            {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %}
-            <li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}><a href="{% url 'dcim:device_lldp_neighbors' pk=device.pk %}">LLDP Neighbors</a></li>
+        {% elif not device.platform %}
-            <li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}><a href="{% url 'dcim:device_config' pk=device.pk %}">Configuration</a></li>
+            {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %}
+        {% elif not device.platform.napalm_driver %}
+            {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %}
+        {% elif not device.primary_ip %}
+            {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No primary IP address assigned to this device' %}
         {% else %}
         {% else %}
-            <li role="presentation" class="disabled"><a href="#">Status</a></li>
+            {% include 'dcim/inc/device_napalm_tabs.html' %}
-            <li role="presentation" class="disabled"><a href="#">LLDP Neighbors</a></li>
-            <li role="presentation" class="disabled"><a href="#">Configuration</a></li>
         {% endif %}
         {% endif %}
     {% endif %}
     {% endif %}
 </ul>
 </ul>

+ 15 - 0
netbox/templates/dcim/inc/device_napalm_tabs.html

@@ -0,0 +1,15 @@
+{% if not disabled_message %}
+    <li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}>
+        <a href="{% url 'dcim:device_status' pk=device.pk %}">Status</a>
+    </li>
+    <li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}>
+        <a href="{% url 'dcim:device_lldp_neighbors' pk=device.pk %}">LLDP Neighbors</a>
+    </li>
+    <li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}>
+        <a href="{% url 'dcim:device_config' pk=device.pk %}">Configuration</a>
+    </li>
+{% else %}
+    <li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Status</a></li>
+    <li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">LLDP Neighbors</a></li>
+    <li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Configuration</a></li>
+{% endif %}

+ 29 - 0
netbox/templates/dcim/inc/filter_rack_group.html

@@ -0,0 +1,29 @@
+<script type="text/javascript">
+$(document).ready(function() {
+
+    var site_list = $('#id_site');
+    var rack_group_list = $('#id_group_id');
+
+    // Update rack group and rack options based on selected site
+    site_list.change(function() {
+        var selected_sites = $(this).val();
+        if (selected_sites) {
+
+            // Update rack group options
+            rack_group_list.empty();
+            $.ajax({
+                url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
+                dataType: 'json',
+                success: function (response, status) {
+                    $.each(response["results"], function (index, group) {
+                        var option = $("<option></option>").attr("value", group.id).text(group.name);
+                        rack_group_list.append(option);
+                    });
+                }
+            });
+
+        }
+    });
+
+});
+</script>

+ 0 - 1
netbox/templates/dcim/manufacturer_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/dcim/platform_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 6 - 5
netbox/templates/dcim/rack_elevation_list.html

@@ -45,9 +45,10 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block javascript %}
 {% block javascript %}
-<script type="text/javascript">
+    {% include 'dcim/inc/filter_rack_group.html' %}
-$(function() {
+    <script type="text/javascript">
-  $('[data-toggle="popover"]').popover()
+    $(function() {
-})
+        $('[data-toggle="popover"]').popover()
-</script>
+    })
+    </script>
 {% endblock %}
 {% endblock %}

+ 1 - 30
netbox/templates/dcim/rack_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
@@ -22,34 +21,6 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block javascript %}
 {% block javascript %}
-<script type="text/javascript">
+    {% include 'dcim/inc/filter_rack_group.html' %}
-$(document).ready(function() {
-
-    var site_list = $('#id_site');
-    var rack_group_list = $('#id_group_id');
-
-    // Update rack group and rack options based on selected site
-    site_list.change(function() {
-        var selected_sites = $(this).val();
-        if (selected_sites) {
-
-            // Update rack group options
-            rack_group_list.empty();
-            $.ajax({
-                url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
-                dataType: 'json',
-                success: function (response, status) {
-                    $.each(response["results"], function (index, group) {
-                        var option = $("<option></option>").attr("value", group.id).text(group.name);
-                        rack_group_list.append(option);
-                    });
-                }
-            });
-
-        }
-    });
-
-});
-</script>
 {% endblock %}
 {% endblock %}
 
 

+ 0 - 1
netbox/templates/dcim/rackgroup_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/dcim/region_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/ipam/aggregate_list.html

@@ -1,7 +1,6 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
 {% load humanize %}
 {% load humanize %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 1 - 1
netbox/templates/ipam/ipaddress.html

@@ -144,7 +144,7 @@
         {% if duplicate_ips_table.rows %}
         {% if duplicate_ips_table.rows %}
             {% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
             {% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
         {% endif %}
         {% endif %}
-        {% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
+        {% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' panel_class='default' %}
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 0 - 1
netbox/templates/ipam/ipaddress_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 1 - 1
netbox/templates/ipam/prefix.html

@@ -136,7 +136,7 @@
         {% if duplicate_prefix_table.rows %}
         {% if duplicate_prefix_table.rows %}
             {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
             {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
         {% endif %}
         {% endif %}
-        {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' %}
+        {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %}
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 0 - 1
netbox/templates/ipam/prefix_list.html

@@ -1,7 +1,6 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
 {% load helpers %}
 {% load helpers %}
-{% load form_helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/ipam/rir_list.html

@@ -1,7 +1,6 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
 {% load humanize %}
 {% load humanize %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/ipam/role_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 2
netbox/templates/ipam/vlan_list.html

@@ -1,7 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
-{% load form_helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/ipam/vlangroup_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 1 - 2
netbox/templates/ipam/vrf_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
-{% load form_helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/secrets/secret_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/secrets/secretrole_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/tenancy/tenant_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/tenancy/tenantgroup_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/virtualization/clustergroup_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 0 - 1
netbox/templates/virtualization/clustertype_list.html

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">

+ 9 - 2
netbox/templates/virtualization/virtualmachine_edit.html

@@ -6,9 +6,7 @@
         <div class="panel-heading"><strong>Virtual Machine</strong></div>
         <div class="panel-heading"><strong>Virtual Machine</strong></div>
         <div class="panel-body">
         <div class="panel-body">
             {% render_field form.name %}
             {% render_field form.name %}
-            {% render_field form.status %}
             {% render_field form.role %}
             {% render_field form.role %}
-            {% render_field form.platform %}
         </div>
         </div>
     </div>
     </div>
     <div class="panel panel-default">
     <div class="panel panel-default">
@@ -19,6 +17,15 @@
         </div>
         </div>
     </div>
     </div>
     <div class="panel panel-default">
     <div class="panel panel-default">
+        <div class="panel-heading"><strong>Management</strong></div>
+        <div class="panel-body">
+            {% render_field form.status %}
+            {% render_field form.platform %}
+            {% render_field form.primary_ip4 %}
+            {% render_field form.primary_ip6 %}
+        </div>
+    </div>
+    <div class="panel panel-default">
         <div class="panel-heading"><strong>Resources</strong></div>
         <div class="panel-heading"><strong>Resources</strong></div>
         <div class="panel-body">
         <div class="panel-body">
             {% render_field form.vcpus %}
             {% render_field form.vcpus %}

+ 38 - 2
netbox/virtualization/forms.py

@@ -9,6 +9,7 @@ from dcim.constants import IFACE_FF_VIRTUAL
 from dcim.formfields import MACAddressFormField
 from dcim.formfields import MACAddressFormField
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
 from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
+from ipam.models import IPAddress
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
@@ -246,8 +247,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
         fields = [
         fields = [
-            'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
+            'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
-            'comments',
+            'vcpus', 'memory', 'disk', 'comments',
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -261,6 +262,41 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 
 
         super(VirtualMachineForm, self).__init__(*args, **kwargs)
         super(VirtualMachineForm, self).__init__(*args, **kwargs)
 
 
+        if self.instance.pk:
+
+            # Compile list of choices for primary IPv4 and IPv6 addresses
+            for family in [4, 6]:
+                ip_choices = [(None, '---------')]
+                # Collect interface IPs
+                interface_ips = IPAddress.objects.select_related('interface').filter(
+                    family=family, interface__virtual_machine=self.instance
+                )
+                if interface_ips:
+                    ip_choices.append(
+                        ('Interface IPs', [
+                            (ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
+                        ])
+                    )
+                # Collect NAT IPs
+                nat_ips = IPAddress.objects.select_related('nat_inside').filter(
+                    family=family, nat_inside__interface__virtual_machine=self.instance
+                )
+                if nat_ips:
+                    ip_choices.append(
+                        ('NAT IPs', [
+                            (ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
+                        ])
+                    )
+                self.fields['primary_ip{}'.format(family)].choices = ip_choices
+
+        else:
+
+            # An object that doesn't exist yet can't have any IPs assigned to it
+            self.fields['primary_ip4'].choices = []
+            self.fields['primary_ip4'].widget.attrs['readonly'] = True
+            self.fields['primary_ip6'].choices = []
+            self.fields['primary_ip6'].widget.attrs['readonly'] = True
+
 
 
 class VirtualMachineCSVForm(forms.ModelForm):
 class VirtualMachineCSVForm(forms.ModelForm):
     status = CSVChoiceField(
     status = CSVChoiceField(