Merge pull request #989 from digitalocean/develop

Release v1.9.3
This commit is contained in:
Jeremy Stretch 2017-03-23 16:27:06 -04:00 committed by GitHub
commit be393a9d10
16 changed files with 147 additions and 25 deletions

View File

@ -9,9 +9,7 @@ env:
language: python language: python
python: python:
- "2.7" - "2.7"
- "3.4"
- "3.5" - "3.5"
- "3.6"
install: install:
- pip install -r requirements.txt - pip install -r requirements.txt
- pip install pep8 - pip install pep8

View File

@ -1,3 +1,7 @@
**The [2017 NetBox User Survey](https://goo.gl/forms/75HnNS2iE0Y1hVFH3) is open!** Please consider taking a moment to respond. Your feedback helps shape the pace and focus of NetBox development. The survey will remain open until 2017-03-31. Results will be published on the mailing list.
---
![NetBox](docs/netbox_logo.png "NetBox logo") ![NetBox](docs/netbox_logo.png "NetBox logo")
NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers.

View File

@ -90,6 +90,22 @@ NetBox does not have the ability to generate graphs natively, but this feature a
* **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`. * **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`.
* **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`. * **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`.
## Examples
You only need to define one graph object for each graph you want to include when viewing an object. For example, if you want to include a graph of traffic through an interface over the past five minutes, your graph source might looks like this:
```
https://my.nms.local/graphs/?node={{ obj.device.name }}&interface={{ obj.name }}&duration=5m
```
You can define several graphs to provide multiple contexts when viewing an object. For example:
```
https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m
https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=24h
https://my.nms.local/graphs/?type=errors&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m
```
# Topology Maps # Topology Maps
NetBox can generate simple topology maps from the physical network connections recorded in its database. First, you'll need to create a topology map definition under the admin UI at Extras > Topology Maps. NetBox can generate simple topology maps from the physical network connections recorded in its database. First, you'll need to create a topology map definition under the admin UI at Extras > Topology Maps.

View File

@ -5,12 +5,13 @@ from django.db.models import Q
from dcim.models import Site from dcim.models import Site
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .models import Provider, Circuit, CircuitType from .models import Provider, Circuit, CircuitType
class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -42,6 +43,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',

View File

@ -5,7 +5,7 @@ from django.db.models import Q
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .models import ( from .models import (
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection,
Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site,
@ -14,6 +14,7 @@ from .models import (
class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -81,6 +82,7 @@ class RackGroupFilter(django_filters.FilterSet):
class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -157,6 +159,7 @@ class RackReservationFilter(django_filters.FilterSet):
class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -191,6 +194,7 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -405,6 +409,10 @@ class InterfaceFilter(django_filters.FilterSet):
method='filter_type', method='filter_type',
label='Interface type', label='Interface type',
) )
mac_address = django_filters.CharFilter(
method='_mac_address',
label='MAC address',
)
class Meta: class Meta:
model = Interface model = Interface
@ -420,48 +428,73 @@ class InterfaceFilter(django_filters.FilterSet):
return queryset.filter(form_factor=IFACE_FF_LAG) return queryset.filter(form_factor=IFACE_FF_LAG)
return queryset return queryset
def _mac_address(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
try:
return queryset.filter(mac_address=value)
except AddrFormatError:
return queryset.none()
class ConsoleConnectionFilter(django_filters.FilterSet): class ConsoleConnectionFilter(django_filters.FilterSet):
site = django_filters.CharFilter( site = django_filters.CharFilter(
method='filter_site', method='filter_site',
label='Site (slug)', label='Site (slug)',
) )
device = django_filters.CharFilter(
class Meta: method='filter_device',
model = ConsoleServerPort label='Device',
fields = [] )
def filter_site(self, queryset, name, value): def filter_site(self, queryset, name, value):
if not value.strip(): if not value.strip():
return queryset return queryset
return queryset.filter(cs_port__device__site__slug=value) return queryset.filter(cs_port__device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(device__name__icontains=value) |
Q(cs_port__device__name__icontains=value)
)
class PowerConnectionFilter(django_filters.FilterSet): class PowerConnectionFilter(django_filters.FilterSet):
site = django_filters.CharFilter( site = django_filters.CharFilter(
method='filter_site', method='filter_site',
label='Site (slug)', label='Site (slug)',
) )
device = django_filters.CharFilter(
class Meta: method='filter_device',
model = PowerOutlet label='Device',
fields = [] )
def filter_site(self, queryset, name, value): def filter_site(self, queryset, name, value):
if not value.strip(): if not value.strip():
return queryset return queryset
return queryset.filter(power_outlet__device__site__slug=value) return queryset.filter(power_outlet__device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(device__name__icontains=value) |
Q(power_outlet__device__name__icontains=value)
)
class InterfaceConnectionFilter(django_filters.FilterSet): class InterfaceConnectionFilter(django_filters.FilterSet):
site = django_filters.CharFilter( site = django_filters.CharFilter(
method='filter_site', method='filter_site',
label='Site (slug)', label='Site (slug)',
) )
device = django_filters.CharFilter(
class Meta: method='filter_device',
model = InterfaceConnection label='Device',
fields = [] )
def filter_site(self, queryset, name, value): def filter_site(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -470,3 +503,11 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
Q(interface_a__device__site__slug=value) | Q(interface_a__device__site__slug=value) |
Q(interface_b__device__site__slug=value) Q(interface_b__device__site__slug=value)
) )
def filter_device(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(interface_a__device__name__icontains=value) |
Q(interface_b__device__name__icontains=value)
)

View File

@ -23,7 +23,7 @@ from .models import (
Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
VIRTUAL_IFACE_TYPES SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES
) )
@ -375,6 +375,21 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')), queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
to_field_name='slug' to_field_name='slug'
) )
is_console_server = forms.BooleanField(
required=False, label='Is a console server', widget=forms.CheckboxInput(attrs={'value': 'True'}))
is_pdu = forms.BooleanField(
required=False, label='Is a PDU', widget=forms.CheckboxInput(attrs={'value': 'True'})
)
is_network_device = forms.BooleanField(
required=False, label='Is a network device', widget=forms.CheckboxInput(attrs={'value': 'True'})
)
subdevice_role = forms.NullBooleanField(
required=False, label='Subdevice role', widget=forms.Select(choices=(
('', '---------'),
(SUBDEVICE_ROLE_PARENT, 'Parent'),
(SUBDEVICE_ROLE_CHILD, 'Child'),
))
)
# #
@ -1643,14 +1658,17 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
device = forms.CharField(required=False, label='Device name')
class PowerConnectionFilterForm(BootstrapMixin, forms.Form): class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
device = forms.CharField(required=False, label='Device name')
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
device = forms.CharField(required=False, label='Device name')
# #

View File

@ -100,6 +100,10 @@ DEVICE_PRIMARY_IP = """
{{ record.primary_ip4.address.ip|default:"" }} {{ record.primary_ip4.address.ip|default:"" }}
""" """
SUBDEVICE_ROLE_TEMPLATE = """
{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}—{% endif %}
"""
UTILIZATION_GRAPH = """ UTILIZATION_GRAPH = """
{% load helpers %} {% load helpers %}
{% utilization_graph value %} {% utilization_graph value %}
@ -249,11 +253,18 @@ class DeviceTypeTable(BaseTable):
model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type') model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
part_number = tables.Column(verbose_name='Part Number') part_number = tables.Column(verbose_name='Part Number')
is_full_depth = tables.BooleanColumn(verbose_name='Full Depth') is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
is_console_server = tables.BooleanColumn(verbose_name='CS')
is_pdu = tables.BooleanColumn(verbose_name='PDU')
is_network_device = tables.BooleanColumn(verbose_name='Net')
subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
instance_count = tables.Column(verbose_name='Instances') instance_count = tables.Column(verbose_name='Instances')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = DeviceType model = DeviceType
fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count') fields = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
'is_network_device', 'subdevice_role', 'instance_count'
)
# #

View File

@ -90,7 +90,12 @@ class ComponentCreateView(View):
self.parent_field: parent.pk, self.parent_field: parent.pk,
'name': name, 'name': name,
} }
component_data.update(data) # Replace objects with their primary key to keep component_form.clean() happy
for k, v in data.items():
if hasattr(v, 'pk'):
component_data[k] = v.pk
else:
component_data[k] = v
component_form = self.model_form(component_data) component_form = self.model_form(component_data)
if component_form.is_valid(): if component_form.is_valid():
new_components.append(component_form.save(commit=False)) new_components.append(component_form.save(commit=False))

View File

@ -7,12 +7,13 @@ from django.db.models import Q
from dcim.models import Site, Device, Interface from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -44,6 +45,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
class RIRFilter(django_filters.FilterSet): class RIRFilter(django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
class Meta: class Meta:
model = RIR model = RIR
@ -51,6 +53,7 @@ class RIRFilter(django_filters.FilterSet):
class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -84,6 +87,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -182,6 +186,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -283,6 +288,7 @@ class VLANGroupFilter(django_filters.FilterSet):
class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',

View File

@ -12,7 +12,7 @@ except ImportError:
"the documentation.") "the documentation.")
VERSION = '1.9.2' VERSION = '1.9.3'
# Import local configuration # Import local configuration
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:

View File

@ -4,9 +4,11 @@ from django.db.models import Q
from .models import Secret, SecretRole from .models import Secret, SecretRole
from dcim.models import Device from dcim.models import Device
from utilities.filters import NumericInFilter
class SecretFilter(django_filters.FilterSet): class SecretFilter(django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',

View File

@ -295,7 +295,8 @@
<p class="text-muted"> <p class="text-muted">
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> &middot; <i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> &middot;
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'django.swagger.base.view' %}">API</a> &middot; <i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'django.swagger.base.view' %}">API</a> &middot;
<i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a> <i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a> &middot;
<i class="fa fa-fw fa-support text-primary"></i> <a href="https://github.com/digitalocean/netbox/wiki">Help</a>
</p> </p>
</div> </div>
</div> </div>

View File

@ -73,7 +73,7 @@
</tr> </tr>
<tr> <tr>
<td>Rack</td> <td>Rack</td>
<td>Rack name</td> <td>Rack name (optional)</td>
<td>R101</td> <td>R101</td>
</tr> </tr>
<tr> <tr>

View File

@ -35,7 +35,13 @@
<td colspan="2"> <td colspan="2">
<i class="fa fa-fw fa-globe" title="Circuit"></i> <i class="fa fa-fw fa-globe" title="Circuit"></i>
{% if peer_termination %} {% if peer_termination %}
<a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">{{ peer_termination.site }}</a> via {% if peer_termination.interface %}
<a href="{% url 'dcim:device' pk=peer_termination.interface.device.pk %}">{{ peer_termination.interface.device }}</a>
(<a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">{{ peer_termination.site }}</a>)
{% else %}
<a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">{{ peer_termination.site }}</a>
{% endif %}
via
{% endif %} {% endif %}
<a href="{% url 'circuits:circuit' pk=iface.circuit_termination.circuit_id %}">{{ iface.circuit_termination.circuit }}</a> <a href="{% url 'circuits:circuit' pk=iface.circuit_termination.circuit_id %}">{{ iface.circuit_termination.circuit }}</a>
</td> </td>

View File

@ -3,11 +3,12 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet
from utilities.filters import NullableModelMultipleChoiceFilter from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .models import Tenant, TenantGroup from .models import Tenant, TenantGroup
class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',

View File

@ -6,6 +6,17 @@ from django.db.models import Q
from django.utils.encoding import force_text from django.utils.encoding import force_text
#
# Filters
#
class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
"""
Filters for a set of numeric values. Example: id__in=100,200,300
"""
pass
class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField): class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField):
""" """
This field operates like a normal ModelMultipleChoiceField except that it allows for one additional choice which is This field operates like a normal ModelMultipleChoiceField except that it allows for one additional choice which is