فهرست منبع

Merge pull request #989 from digitalocean/develop

Release v1.9.3
Jeremy Stretch 9 سال پیش
والد
کامیت
be393a9d10

+ 0 - 2
.travis.yml

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

+ 4 - 0
README.md

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

+ 16 - 0
docs/data-model/extras.md

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

+ 3 - 1
netbox/circuits/filters.py

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

+ 54 - 13
netbox/dcim/filters.py

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

+ 19 - 1
netbox/dcim/forms.py

@@ -23,7 +23,7 @@ from .models import (
     Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
     Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
     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')),
         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):
     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):
     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):
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
+    device = forms.CharField(required=False, label='Device name')
 
 
 #

+ 12 - 1
netbox/dcim/tables.py

@@ -100,6 +100,10 @@ DEVICE_PRIMARY_IP = """
 {{ 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 = """
 {% load helpers %}
 {% utilization_graph value %}
@@ -249,11 +253,18 @@ class DeviceTypeTable(BaseTable):
     model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
     part_number = tables.Column(verbose_name='Part Number')
     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')
 
     class Meta(BaseTable.Meta):
         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'
+        )
 
 
 #

+ 6 - 1
netbox/dcim/views.py

@@ -90,7 +90,12 @@ class ComponentCreateView(View):
                     self.parent_field: parent.pk,
                     '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)
                 if component_form.is_valid():
                     new_components.append(component_form.save(commit=False))

+ 7 - 1
netbox/ipam/filters.py

@@ -7,12 +7,13 @@ from django.db.models import Q
 from dcim.models import Site, Device, Interface
 from extras.filters import CustomFieldFilterSet
 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
 
 
 class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
+    id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -44,6 +45,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
 class RIRFilter(django_filters.FilterSet):
+    id__in = NumericInFilter(name='id', lookup_expr='in')
 
     class Meta:
         model = RIR
@@ -51,6 +53,7 @@ class RIRFilter(django_filters.FilterSet):
 
 
 class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
+    id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -84,6 +87,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
 class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
+    id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -182,6 +186,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
 class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
+    id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -283,6 +288,7 @@ class VLANGroupFilter(django_filters.FilterSet):
 
 
 class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
+    id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
         method='search',
         label='Search',

+ 1 - 1
netbox/netbox/settings.py

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

+ 2 - 0
netbox/secrets/filters.py

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

+ 2 - 1
netbox/templates/_base.html

@@ -295,7 +295,8 @@
                     <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-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>
                 </div>
             </div>

+ 1 - 1
netbox/templates/dcim/device_import.html

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

+ 7 - 1
netbox/templates/dcim/inc/interface.html

@@ -35,7 +35,13 @@
             <td colspan="2">
                 <i class="fa fa-fw fa-globe" title="Circuit"></i>
                 {% 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 %}
                 <a href="{% url 'circuits:circuit' pk=iface.circuit_termination.circuit_id %}">{{ iface.circuit_termination.circuit }}</a>
             </td>

+ 2 - 1
netbox/tenancy/filters.py

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

+ 11 - 0
netbox/utilities/filters.py

@@ -6,6 +6,17 @@ from django.db.models import Q
 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):
     """
     This field operates like a normal ModelMultipleChoiceField except that it allows for one additional choice which is