Merge branch 'develop' into develop-2.3

This commit is contained in:
Jeremy Stretch 2018-02-21 16:16:20 -05:00
commit 8b33b888b2
46 changed files with 367 additions and 136 deletions

View File

@ -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.

View File

@ -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()

View File

@ -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')

View File

@ -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(

View File

@ -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):

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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),
] ]

View File

@ -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),
),
]

View File

@ -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',
),
]

View File

@ -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')
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)
# #

View File

@ -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'},
),
]

View File

@ -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'

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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>

View File

@ -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 %}

View File

@ -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>

View File

@ -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">

View File

@ -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">

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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 %}

View File

@ -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">

View File

@ -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 %}

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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">
@ -18,6 +16,15 @@
{% render_field form.cluster %} {% render_field form.cluster %}
</div> </div>
</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 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">

View File

@ -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(