Просмотр исходного кода

Merge pull request #989 from digitalocean/develop

Release v1.9.3
Jeremy Stretch 9 лет назад
Родитель
Сommit
be393a9d10

+ 0 - 2
.travis.yml

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

+ 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](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.

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

+ 3 - 1
netbox/circuits/filters.py

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

+ 54 - 13
netbox/dcim/filters.py

@@ -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)',
     )
     )
-
-    class Meta:
-        model = ConsoleServerPort
-        fields = []
+    device = django_filters.CharFilter(
+        method='filter_device',
+        label='Device',
+    )
 
 
     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)',
     )
     )
-
-    class Meta:
-        model = PowerOutlet
-        fields = []
+    device = django_filters.CharFilter(
+        method='filter_device',
+        label='Device',
+    )
 
 
     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)',
     )
     )
-
-    class Meta:
-        model = InterfaceConnection
-        fields = []
+    device = django_filters.CharFilter(
+        method='filter_device',
+        label='Device',
+    )
 
 
     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)
+        )

+ 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,
     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')
 
 
 
 
 #
 #

+ 12 - 1
netbox/dcim/tables.py

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

+ 6 - 1
netbox/dcim/views.py

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

+ 7 - 1
netbox/ipam/filters.py

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

+ 1 - 1
netbox/netbox/settings.py

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

+ 2 - 0
netbox/secrets/filters.py

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

+ 2 - 1
netbox/templates/_base.html

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

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

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

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

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

+ 2 - 1
netbox/tenancy/filters.py

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

+ 11 - 0
netbox/utilities/filters.py

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