diff --git a/docs/api/examples.md b/docs/api/examples.md index 76471c4eb..dce088cb6 100644 --- a/docs/api/examples.md +++ b/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. diff --git a/docs/installation/ldap.md b/docs/installation/ldap.md index 5aeec0eb1..d8053da48 100644 --- a/docs/installation/ldap.md +++ b/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() diff --git a/docs/miscellaneous/shell.md b/docs/miscellaneous/shell.md index df92cb7cd..5afd7876d 100644 --- a/docs/miscellaneous/shell.md +++ b/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') diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index d5065c75a..852d6b489 100644 --- a/netbox/dcim/forms.py +++ b/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( diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 752a6ccaa..07b5a9ae7 100644 --- a/netbox/extras/admin.py +++ b/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): diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 808413ba2..94f58c2d1 100644 --- a/netbox/extras/constants.py +++ b/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 diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 5713d4af4..f21c228db 100644 --- a/netbox/extras/filters.py +++ b/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): diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 22a604dd0..a923ae596 100644 --- a/netbox/extras/forms.py +++ b/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 diff --git a/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py b/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py index bf2711c43..ee838046d 100644 --- a/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py +++ b/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), ] diff --git a/netbox/extras/migrations/0009_topologymap_type.py b/netbox/extras/migrations/0009_topologymap_type.py new file mode 100644 index 000000000..b062c58af --- /dev/null +++ b/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), + ), + ] diff --git a/netbox/extras/migrations/0010_customfield_filter_logic.py b/netbox/extras/migrations/0010_customfield_filter_logic.py new file mode 100644 index 000000000..e35a2f835 --- /dev/null +++ b/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', + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index aa30f8cdc..341405016 100644 --- a/netbox/extras/models.py +++ b/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') - return graph.pipe(format=img_format) + 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 + + # 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) # diff --git a/netbox/ipam/migrations/0021_vrf_ordering.py b/netbox/ipam/migrations/0021_vrf_ordering.py new file mode 100644 index 000000000..878c02d8c --- /dev/null +++ b/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'}, + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 3ed673c78..d8e2aae97 100644 --- a/netbox/ipam/models.py +++ b/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' diff --git a/netbox/templates/circuits/circuit_list.html b/netbox/templates/circuits/circuit_list.html index de9922313..f05552f7d 100644 --- a/netbox/templates/circuits/circuit_list.html +++ b/netbox/templates/circuits/circuit_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/circuits/circuittype_list.html b/netbox/templates/circuits/circuittype_list.html index af48ecd0c..2b9469042 100644 --- a/netbox/templates/circuits/circuittype_list.html +++ b/netbox/templates/circuits/circuittype_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index cccdfe4c0..f96b27309 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/dcim/devicerole_list.html b/netbox/templates/dcim/devicerole_list.html index cf58d2b1d..6dd95b86d 100644 --- a/netbox/templates/dcim/devicerole_list.html +++ b/netbox/templates/dcim/devicerole_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/dcim/devicetype_list.html b/netbox/templates/dcim/devicetype_list.html index e0f365786..91745082a 100644 --- a/netbox/templates/dcim/devicetype_list.html +++ b/netbox/templates/dcim/devicetype_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/dcim/inc/device_header.html b/netbox/templates/dcim/inc/device_header.html index 73b5845ef..92acd297d 100644 --- a/netbox/templates/dcim/inc/device_header.html +++ b/netbox/templates/dcim/inc/device_header.html @@ -43,17 +43,23 @@

{{ device }}

{% include 'inc/created_updated.html' with obj=device %} diff --git a/netbox/templates/dcim/inc/device_napalm_tabs.html b/netbox/templates/dcim/inc/device_napalm_tabs.html new file mode 100644 index 000000000..073f2fb9b --- /dev/null +++ b/netbox/templates/dcim/inc/device_napalm_tabs.html @@ -0,0 +1,15 @@ +{% if not disabled_message %} + + + +{% else %} + + + +{% endif %} diff --git a/netbox/templates/dcim/inc/filter_rack_group.html b/netbox/templates/dcim/inc/filter_rack_group.html new file mode 100644 index 000000000..9c5582f87 --- /dev/null +++ b/netbox/templates/dcim/inc/filter_rack_group.html @@ -0,0 +1,29 @@ + diff --git a/netbox/templates/dcim/manufacturer_list.html b/netbox/templates/dcim/manufacturer_list.html index 0ca9c40b3..09b06ef29 100644 --- a/netbox/templates/dcim/manufacturer_list.html +++ b/netbox/templates/dcim/manufacturer_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/dcim/platform_list.html b/netbox/templates/dcim/platform_list.html index 66dce9252..123c863ea 100644 --- a/netbox/templates/dcim/platform_list.html +++ b/netbox/templates/dcim/platform_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index 5304538c1..38a821750 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -45,9 +45,10 @@ {% endblock %} {% block javascript %} - + {% include 'dcim/inc/filter_rack_group.html' %} + {% endblock %} diff --git a/netbox/templates/dcim/rack_list.html b/netbox/templates/dcim/rack_list.html index eb00800ec..d5734ee2b 100644 --- a/netbox/templates/dcim/rack_list.html +++ b/netbox/templates/dcim/rack_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
@@ -22,34 +21,6 @@ {% endblock %} {% block javascript %} - + {% include 'dcim/inc/filter_rack_group.html' %} {% endblock %} diff --git a/netbox/templates/dcim/rackgroup_list.html b/netbox/templates/dcim/rackgroup_list.html index 51989db0f..c16b1605f 100644 --- a/netbox/templates/dcim/rackgroup_list.html +++ b/netbox/templates/dcim/rackgroup_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/dcim/region_list.html b/netbox/templates/dcim/region_list.html index d6b9f1c5a..0f6d39c15 100644 --- a/netbox/templates/dcim/region_list.html +++ b/netbox/templates/dcim/region_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/ipam/aggregate_list.html b/netbox/templates/ipam/aggregate_list.html index 7b15479f6..73da9695d 100644 --- a/netbox/templates/ipam/aggregate_list.html +++ b/netbox/templates/ipam/aggregate_list.html @@ -1,7 +1,6 @@ {% extends '_base.html' %} {% load buttons %} {% load humanize %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 855fc3a98..1509f35cb 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/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' %}
{% endblock %} diff --git a/netbox/templates/ipam/ipaddress_list.html b/netbox/templates/ipam/ipaddress_list.html index 9e378de54..5f8fdeb88 100644 --- a/netbox/templates/ipam/ipaddress_list.html +++ b/netbox/templates/ipam/ipaddress_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 5c168e247..11c5fc405 100644 --- a/netbox/templates/ipam/prefix.html +++ b/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' %}
{% endblock %} diff --git a/netbox/templates/ipam/prefix_list.html b/netbox/templates/ipam/prefix_list.html index 8e6d28d49..d65904595 100644 --- a/netbox/templates/ipam/prefix_list.html +++ b/netbox/templates/ipam/prefix_list.html @@ -1,7 +1,6 @@ {% extends '_base.html' %} {% load buttons %} {% load helpers %} -{% load form_helpers %} {% block content %}
diff --git a/netbox/templates/ipam/rir_list.html b/netbox/templates/ipam/rir_list.html index 40a21fc25..67356b3cb 100644 --- a/netbox/templates/ipam/rir_list.html +++ b/netbox/templates/ipam/rir_list.html @@ -1,7 +1,6 @@ {% extends '_base.html' %} {% load buttons %} {% load humanize %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/ipam/role_list.html b/netbox/templates/ipam/role_list.html index bc493e15b..cd6fcd7aa 100644 --- a/netbox/templates/ipam/role_list.html +++ b/netbox/templates/ipam/role_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/ipam/vlan_list.html b/netbox/templates/ipam/vlan_list.html index 29fc6a79d..24e12595b 100644 --- a/netbox/templates/ipam/vlan_list.html +++ b/netbox/templates/ipam/vlan_list.html @@ -1,7 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} -{% load form_helpers %} {% block content %}
diff --git a/netbox/templates/ipam/vlangroup_list.html b/netbox/templates/ipam/vlangroup_list.html index 6eb63afdc..9333f95c7 100644 --- a/netbox/templates/ipam/vlangroup_list.html +++ b/netbox/templates/ipam/vlangroup_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/ipam/vrf_list.html b/netbox/templates/ipam/vrf_list.html index 479947554..23bd16495 100644 --- a/netbox/templates/ipam/vrf_list.html +++ b/netbox/templates/ipam/vrf_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} -{% load helpers %} -{% load form_helpers %} +{% load buttons %} {% block content %}
diff --git a/netbox/templates/secrets/secret_list.html b/netbox/templates/secrets/secret_list.html index 4e2aa9cb9..6dd92cd89 100644 --- a/netbox/templates/secrets/secret_list.html +++ b/netbox/templates/secrets/secret_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/secrets/secretrole_list.html b/netbox/templates/secrets/secretrole_list.html index c76c8f748..e968630f6 100644 --- a/netbox/templates/secrets/secretrole_list.html +++ b/netbox/templates/secrets/secretrole_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/tenancy/tenant_list.html b/netbox/templates/tenancy/tenant_list.html index c2181f1b8..e6fd61c37 100644 --- a/netbox/templates/tenancy/tenant_list.html +++ b/netbox/templates/tenancy/tenant_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/tenancy/tenantgroup_list.html b/netbox/templates/tenancy/tenantgroup_list.html index 26bbb86bd..a62594994 100644 --- a/netbox/templates/tenancy/tenantgroup_list.html +++ b/netbox/templates/tenancy/tenantgroup_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/virtualization/clustergroup_list.html b/netbox/templates/virtualization/clustergroup_list.html index a5d042f65..d724c2c43 100644 --- a/netbox/templates/virtualization/clustergroup_list.html +++ b/netbox/templates/virtualization/clustergroup_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/virtualization/clustertype_list.html b/netbox/templates/virtualization/clustertype_list.html index b05ae9afe..37f8cc31b 100644 --- a/netbox/templates/virtualization/clustertype_list.html +++ b/netbox/templates/virtualization/clustertype_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/virtualization/virtualmachine_edit.html b/netbox/templates/virtualization/virtualmachine_edit.html index 7c240857f..706591ab4 100644 --- a/netbox/templates/virtualization/virtualmachine_edit.html +++ b/netbox/templates/virtualization/virtualmachine_edit.html @@ -6,9 +6,7 @@
Virtual Machine
{% render_field form.name %} - {% render_field form.status %} {% render_field form.role %} - {% render_field form.platform %}
@@ -18,6 +16,15 @@ {% render_field form.cluster %}
+
+
Management
+
+ {% render_field form.status %} + {% render_field form.platform %} + {% render_field form.primary_ip4 %} + {% render_field form.primary_ip6 %} +
+
Resources
diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index ad494069b..5a2f11763 100644 --- a/netbox/virtualization/forms.py +++ b/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(