Parcourir la source

Merge branch 'develop' into develop-2.3

Jeremy Stretch il y a 7 ans
Parent
commit
8b33b888b2
46 fichiers modifiés avec 367 ajouts et 136 suppressions
  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
 * `POST`: Create a new object
 * `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
 
 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
 ```
 
-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
 
 # 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,
                                     "(objectClass=group)")
 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
@@ -86,7 +86,7 @@ The `count()` method can be appended to the queryset to return a count of object
 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')

+ 15 - 2
netbox/dcim/forms.py

@@ -1086,6 +1086,15 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     )
     status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
     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:
         model = Interface
         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',
         ]
         widgets = {
@@ -1768,7 +1777,11 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
     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')
     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)
     mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
     site = forms.ModelChoiceField(

+ 1 - 1
netbox/extras/admin.py

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

+ 20 - 0
netbox/extras/constants.py

@@ -26,6 +26,16 @@ CUSTOMFIELD_TYPE_CHOICES = (
     (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_TYPE_INTERFACE = 100
 GRAPH_TYPE_PROVIDER = 200
@@ -46,6 +56,16 @@ EXPORTTEMPLATE_MODELS = [
     '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
 ACTION_CREATE = 1
 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 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
 
 
@@ -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.
     """
 
-    def __init__(self, cf_type, *args, **kwargs):
-        self.cf_type = cf_type
+    def __init__(self, custom_field, *args, **kwargs):
+        self.cf_type = custom_field.type
+        self.filter_logic = custom_field.filter_logic
         super(CustomFieldFilter, self).__init__(*args, **kwargs)
 
     def filter(self, queryset, value):
@@ -41,10 +42,12 @@ class CustomFieldFilter(django_filters.Filter):
             except ValueError:
                 return queryset.none()
 
-        return queryset.filter(
-            custom_field_values__field__name=self.name,
-            custom_field_values__serialized_value__icontains=value,
-        )
+        # Apply the assigned filter logic (exact or loose)
+        queryset = queryset.filter(custom_field_values__field__name=self.name)
+        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):
@@ -56,9 +59,9 @@ class CustomFieldFilterSet(django_filters.FilterSet):
         super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
 
         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:
-            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):

+ 5 - 6
netbox/extras/forms.py

@@ -6,7 +6,7 @@ from django import forms
 from django.contrib.contenttypes.models import ContentType
 
 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
 
 
@@ -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
     """
     field_dict = OrderedDict()
-    kwargs = {'obj_type': content_type}
+    custom_fields = CustomField.objects.filter(obj_type=content_type)
     if filterable_only:
-        kwargs['is_filterable'] = True
-    custom_fields = CustomField.objects.filter(**kwargs)
+        custom_fields = custom_fields.exclude(filter_logic=CF_FILTER_DISABLED)
 
     for cf in custom_fields:
         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'),
                 (0, 'False'),
             )
-            if initial.lower() in ['true', 'yes', '1']:
+            if initial is not None and initial.lower() in ['true', 'yes', '1']:
                 initial = 1
-            elif initial.lower() in ['false', 'no', '0']:
+            elif initial is not None and initial.lower() in ['false', 'no', '0']:
                 initial = 0
             else:
                 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 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):
 
@@ -25,5 +17,4 @@ class Migration(migrations.Migration):
             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.'),
         ),
-        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.safestring import mark_safe
 
+from dcim.constants import CONNECTION_STATUS_CONNECTED
 from utilities.utils import foreground_color
 from .constants import *
 
@@ -54,22 +55,48 @@ class CustomFieldModel(object):
 
 @python_2_unicode_compatible
 class CustomField(models.Model):
-    obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
-                                      limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
-                                      help_text="The object(s) to which this field applies.")
-    type = models.PositiveSmallIntegerField(choices=CUSTOMFIELD_TYPE_CHOICES, default=CF_TYPE_TEXT)
-    name = models.CharField(max_length=50, unique=True)
-    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="Determines whether this field is required when creating "
-                                                            "new objects or editing an existing object.")
-    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 "
-                                                                     "\"false\" for booleans. N/A for selection "
-                                                                     "fields.")
-    weight = models.PositiveSmallIntegerField(default=100, help_text="Fields with higher weights appear lower in a "
-                                                                     "form")
+    obj_type = models.ManyToManyField(
+        to=ContentType,
+        related_name='custom_fields',
+        verbose_name='Object(s)',
+        limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
+        help_text='The object(s) to which this field applies.'
+    )
+    type = models.PositiveSmallIntegerField(
+        choices=CUSTOMFIELD_TYPE_CHOICES,
+        default=CF_TYPE_TEXT
+    )
+    name = models.CharField(
+        max_length=50,
+        unique=True
+    )
+    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:
         ordering = ['weight', 'name']
@@ -253,7 +280,17 @@ class ExportTemplate(models.Model):
 class TopologyMap(models.Model):
     name = models.CharField(max_length=50, 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(
         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. "
@@ -275,22 +312,26 @@ class TopologyMap(models.Model):
 
     def render(self, img_format='png'):
 
-        from circuits.models import CircuitTermination
-        from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection
+        from dcim.models import Device
 
         # Construct the graph
-        graph = graphviz.Graph()
-        graph.graph_attr['ranksep'] = '1'
+        if self.type == TOPOLOGYMAP_TYPE_NETWORK:
+            G = graphviz.Graph
+        else:
+            G = graphviz.Digraph
+        self.graph = G()
+        self.graph.graph_attr['ranksep'] = '1'
         seen = set()
         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['directed'] = 'true'
 
             # Add a pseudonode for each device_set to enforce hierarchical layout
             subgraph.node('set{}'.format(i), label='', shape='none', width='0')
             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
             devices = []
@@ -308,31 +349,64 @@ class TopologyMap(models.Model):
             for j in range(0, len(devices) - 1):
                 subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
 
-            graph.subgraph(subgraph)
+            self.graph.subgraph(subgraph)
 
         # Compile list of all devices
         device_superset = Q()
         for device_set in self.device_sets:
             for query in device_set.split(';'):  # Split regexes on semicolons
                 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
-        devices = Device.objects.filter(*(device_superset,))
         connections = InterfaceConnection.objects.filter(
             interface_a__device__in=devices, interface_b__device__in=devices
         )
         for c in connections:
             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
         for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
             peer_termination = termination.get_peer_termination()
             if (peer_termination is not None and peer_termination.interface is not None and
                     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']
 
     class Meta:
-        ordering = ['name']
+        ordering = ['name', 'rd']
         verbose_name = 'VRF'
         verbose_name_plural = 'VRFs'
 

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

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

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

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

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

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

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

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

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

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

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

@@ -43,17 +43,23 @@
 <h1>{{ device }}</h1>
 {% include 'inc/created_updated.html' with obj=device %}
 <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 == 'inventory' %} class="active"{% endif %}><a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a></li>
+    <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 == 'inventory' %} class="active"{% endif %}>
+        <a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a>
+    </li>
     {% if perms.dcim.napalm_read %}
-        {% if device.status == 1 and device.platform.napalm_driver and device.primary_ip %}
-            <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>
+        {% if device.status != 1 %}
+            {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %}
+        {% elif not device.platform %}
+            {% 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 %}
-            <li role="presentation" class="disabled"><a href="#">Status</a></li>
-            <li role="presentation" class="disabled"><a href="#">LLDP Neighbors</a></li>
-            <li role="presentation" class="disabled"><a href="#">Configuration</a></li>
+            {% include 'dcim/inc/device_napalm_tabs.html' %}
         {% endif %}
     {% endif %}
 </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' %}
 {% load buttons %}
-{% load helpers %}
 
 {% block content %}
 <div class="pull-right">

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

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

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

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

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

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% load buttons %}
-{% load helpers %}
 
 {% block content %}
 <div class="pull-right">
@@ -22,34 +21,6 @@
 {% endblock %}
 
 {% block javascript %}
-<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>
+    {% include 'dcim/inc/filter_rack_group.html' %}
 {% endblock %}
 

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

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

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

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

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

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

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

@@ -144,7 +144,7 @@
         {% if duplicate_ips_table.rows %}
             {% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
         {% 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>
 {% endblock %}

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

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

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

@@ -136,7 +136,7 @@
         {% if duplicate_prefix_table.rows %}
             {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
         {% 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>
 {% endblock %}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -1,6 +1,5 @@
 {% extends '_base.html' %}
 {% load buttons %}
-{% load helpers %}
 
 {% block content %}
 <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-body">
             {% render_field form.name %}
-            {% render_field form.status %}
             {% render_field form.role %}
-            {% render_field form.platform %}
         </div>
     </div>
     <div class="panel panel-default">
@@ -19,6 +17,15 @@
         </div>
     </div>
     <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-body">
             {% 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.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
+from ipam.models import IPAddress
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
@@ -246,8 +247,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     class Meta:
         model = VirtualMachine
         fields = [
-            'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
-            'comments',
+            'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
+            'vcpus', 'memory', 'disk', 'comments',
         ]
 
     def __init__(self, *args, **kwargs):
@@ -261,6 +262,41 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 
         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):
     status = CSVChoiceField(