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

Merge pull request #1668 from digitalocean/develop

Release v2.2.3
Jeremy Stretch 8 лет назад
Родитель
Сommit
3067c3f262

+ 2 - 0
docs/miscellaneous/reports.md

@@ -94,6 +94,8 @@ The following methods are available to log results within a report:
 
 The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status.
 
+To perform additional tasks, such as sending an email or calling a webhook, after a report has been run, extend the `post_run()` method. The status of the report is available as `self.failed` and the results object is `self.result`.
+
 Once you have created a report, it will appear in the reports list. Initially, reports will have no results associated with them. To generate results, run the report.
 
 ## Running Reports

+ 3 - 3
netbox/circuits/filters.py

@@ -7,7 +7,7 @@ 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, NumericInFilter
+from utilities.filters import NumericInFilter
 from .models import Provider, Circuit, CircuitTermination, CircuitType
 
 
@@ -78,11 +78,11 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         label='Circuit type (slug)',
     )
-    tenant_id = NullableModelMultipleChoiceFilter(
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = NullableModelMultipleChoiceFilter(
+    tenant = django_filters.ModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         to_field_name='slug',

+ 20 - 9
netbox/circuits/tables.py

@@ -3,6 +3,8 @@ from __future__ import unicode_literals
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
+from django.utils.safestring import mark_safe
+
 from utilities.tables import BaseTable, ToggleColumn
 from .models import Circuit, CircuitType, Provider
 
@@ -14,6 +16,21 @@ CIRCUITTYPE_ACTIONS = """
 """
 
 
+class CircuitTerminationColumn(tables.Column):
+
+    def render(self, value):
+        if value.interface:
+            return mark_safe('<a href="{}" title="{}">{}</a>'.format(
+                value.interface.device.get_absolute_url(),
+                value.site,
+                value.interface.device
+            ))
+        return mark_safe('<a href="{}">{}</a>'.format(
+            value.site.get_absolute_url(),
+            value.site
+        ))
+
+
 #
 # Providers
 #
@@ -61,15 +78,9 @@ class CircuitTable(BaseTable):
     cid = tables.LinkColumn(verbose_name='ID')
     provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
-    a_side = tables.LinkColumn(
-        'dcim:site', accessor=Accessor('termination_a.site'), orderable=False,
-        args=[Accessor('termination_a.site.slug')]
-    )
-    z_side = tables.LinkColumn(
-        'dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
-        args=[Accessor('termination_z.site.slug')]
-    )
+    termination_a = CircuitTerminationColumn(orderable=False, verbose_name='A Side')
+    termination_z = CircuitTerminationColumn(orderable=False, verbose_name='Z Side')
 
     class Meta(BaseTable.Meta):
         model = Circuit
-        fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')
+        fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description')

+ 5 - 1
netbox/circuits/views.py

@@ -134,7 +134,11 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class CircuitListView(ObjectListView):
-    queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
+    queryset = Circuit.objects.select_related(
+        'provider', 'type', 'tenant'
+    ).prefetch_related(
+        'terminations__site', 'terminations__interface__device'
+    )
     filter = filters.CircuitFilter
     filter_form = forms.CircuitFilterForm
     table = tables.CircuitTable

+ 1 - 1
netbox/dcim/api/serializers.py

@@ -221,7 +221,7 @@ class WritableRackReservationSerializer(ValidatedModelSerializer):
 
     class Meta:
         model = RackReservation
-        fields = ['id', 'rack', 'units', 'description']
+        fields = ['id', 'rack', 'units', 'user', 'description']
 
 
 #

+ 22 - 22
netbox/dcim/filters.py

@@ -9,7 +9,7 @@ from django.db.models import Q
 
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
-from utilities.filters import NullableCharFieldFilter, NullableModelMultipleChoiceFilter, NumericInFilter
+from utilities.filters import NullableCharFieldFilter, NumericInFilter
 from virtualization.models import Cluster
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
@@ -21,11 +21,11 @@ from .models import (
 
 
 class RegionFilter(django_filters.FilterSet):
-    parent_id = NullableModelMultipleChoiceFilter(
+    parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Region.objects.all(),
         label='Parent region (ID)',
     )
-    parent = NullableModelMultipleChoiceFilter(
+    parent = django_filters.ModelMultipleChoiceFilter(
         queryset=Region.objects.all(),
         to_field_name='slug',
         label='Parent region (slug)',
@@ -42,20 +42,20 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
         method='search',
         label='Search',
     )
-    region_id = NullableModelMultipleChoiceFilter(
+    region_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Region.objects.all(),
         label='Region (ID)',
     )
-    region = NullableModelMultipleChoiceFilter(
+    region = django_filters.ModelMultipleChoiceFilter(
         queryset=Region.objects.all(),
         to_field_name='slug',
         label='Region (slug)',
     )
-    tenant_id = NullableModelMultipleChoiceFilter(
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = NullableModelMultipleChoiceFilter(
+    tenant = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
         to_field_name='slug',
         label='Tenant (slug)',
@@ -126,31 +126,31 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         label='Site (slug)',
     )
-    group_id = NullableModelMultipleChoiceFilter(
+    group_id = django_filters.ModelMultipleChoiceFilter(
         queryset=RackGroup.objects.all(),
         label='Group (ID)',
     )
-    group = NullableModelMultipleChoiceFilter(
+    group = django_filters.ModelMultipleChoiceFilter(
         name='group',
         queryset=RackGroup.objects.all(),
         to_field_name='slug',
         label='Group',
     )
-    tenant_id = NullableModelMultipleChoiceFilter(
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = NullableModelMultipleChoiceFilter(
+    tenant = django_filters.ModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         to_field_name='slug',
         label='Tenant (slug)',
     )
-    role_id = NullableModelMultipleChoiceFilter(
+    role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=RackRole.objects.all(),
         label='Role (ID)',
     )
-    role = NullableModelMultipleChoiceFilter(
+    role = django_filters.ModelMultipleChoiceFilter(
         name='role',
         queryset=RackRole.objects.all(),
         to_field_name='slug',
@@ -193,12 +193,12 @@ class RackReservationFilter(django_filters.FilterSet):
         to_field_name='slug',
         label='Site (slug)',
     )
-    group_id = NullableModelMultipleChoiceFilter(
+    group_id = django_filters.ModelMultipleChoiceFilter(
         name='rack__group',
         queryset=RackGroup.objects.all(),
         label='Group (ID)',
     )
-    group = NullableModelMultipleChoiceFilter(
+    group = django_filters.ModelMultipleChoiceFilter(
         name='rack__group',
         queryset=RackGroup.objects.all(),
         to_field_name='slug',
@@ -368,21 +368,21 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         label='Role (slug)',
     )
-    tenant_id = NullableModelMultipleChoiceFilter(
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = NullableModelMultipleChoiceFilter(
+    tenant = django_filters.ModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         to_field_name='slug',
         label='Tenant (slug)',
     )
-    platform_id = NullableModelMultipleChoiceFilter(
+    platform_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Platform.objects.all(),
         label='Platform (ID)',
     )
-    platform = NullableModelMultipleChoiceFilter(
+    platform = django_filters.ModelMultipleChoiceFilter(
         name='platform',
         queryset=Platform.objects.all(),
         to_field_name='slug',
@@ -405,12 +405,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         queryset=RackGroup.objects.all(),
         label='Rack group (ID)',
     )
-    rack_id = NullableModelMultipleChoiceFilter(
+    rack_id = django_filters.ModelMultipleChoiceFilter(
         name='rack',
         queryset=Rack.objects.all(),
         label='Rack (ID)',
     )
-    cluster_id = NullableModelMultipleChoiceFilter(
+    cluster_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Cluster.objects.all(),
         label='VM cluster (ID)',
     )
@@ -595,7 +595,7 @@ class DeviceBayFilter(DeviceComponentFilterSet):
 
 
 class InventoryItemFilter(DeviceComponentFilterSet):
-    parent_id = NullableModelMultipleChoiceFilter(
+    parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=InventoryItem.objects.all(),
         label='Parent inventory item (ID)',
     )

+ 13 - 1
netbox/dcim/forms.py

@@ -4,6 +4,7 @@ from mptt.forms import TreeNodeChoiceField
 import re
 
 from django import forms
+from django.contrib.auth.models import User
 from django.contrib.postgres.forms.array import SimpleArrayField
 from django.db.models import Count, Q
 
@@ -376,10 +377,11 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
 
 class RackReservationForm(BootstrapMixin, forms.ModelForm):
     units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10}))
+    user = forms.ModelChoiceField(queryset=User.objects.order_by('username'))
 
     class Meta:
         model = RackReservation
-        fields = ['units', 'description']
+        fields = ['units', 'user', 'description']
 
     def __init__(self, *args, **kwargs):
 
@@ -411,6 +413,15 @@ class RackReservationFilterForm(BootstrapMixin, forms.Form):
     )
 
 
+class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput)
+    user = forms.ModelChoiceField(queryset=User.objects.order_by('username'), required=False)
+    description = forms.CharField(max_length=100, required=False)
+
+    class Meta:
+        nullable_fields = []
+
+
 #
 # Manufacturers
 #
@@ -953,6 +964,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
     cluster = forms.ModelChoiceField(
         queryset=Cluster.objects.all(),
         to_field_name='name',
+        required=False,
         help_text='Virtualization cluster',
         error_messages={
             'invalid_choice': 'Invalid cluster name.',

+ 22 - 0
netbox/dcim/migrations/0049_rackreservation_change_user.py

@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.6 on 2017-10-31 17:32
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0048_rack_serial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='rackreservation',
+            name='user',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
+        ),
+    ]

+ 9 - 2
netbox/dcim/models.py

@@ -256,8 +256,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
 
     def clean(self):
 
-        # Validate that Rack is tall enough to house the installed Devices
         if self.pk:
+            # Validate that Rack is tall enough to house the installed Devices
             top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first()
             if top_device:
                 min_height = top_device.position + top_device.device_type.u_height - 1
@@ -267,6 +267,12 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
                             min_height
                         )
                     })
+            # Validate that Rack was assigned a group of its same site, if applicable
+            if self.group:
+                if self.group.site != self.site:
+                    raise ValidationError({
+                        'group': "Rack group must be from the same site, {}.".format(self.site)
+                    })
 
     def save(self, *args, **kwargs):
 
@@ -290,6 +296,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
             self.tenant.name if self.tenant else None,
             self.role.name if self.role else None,
             self.get_type_display() if self.type else None,
+            self.serial,
             self.width,
             self.u_height,
             self.desc_units,
@@ -411,7 +418,7 @@ class RackReservation(models.Model):
     rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE)
     units = ArrayField(models.PositiveSmallIntegerField())
     created = models.DateTimeField(auto_now_add=True)
-    user = models.ForeignKey(User, editable=False, on_delete=models.PROTECT)
+    user = models.ForeignKey(User, on_delete=models.PROTECT)
     description = models.CharField(max_length=100)
 
     class Meta:

+ 2 - 1
netbox/dcim/tables.py

@@ -362,6 +362,7 @@ class DeviceRoleTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn(verbose_name='Name')
     device_count = tables.Column(verbose_name='Devices')
+    vm_count = tables.Column(verbose_name='VMs')
     color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Label')
     slug = tables.Column(verbose_name='Slug')
     actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
@@ -369,7 +370,7 @@ class DeviceRoleTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = DeviceRole
-        fields = ('pk', 'name', 'device_count', 'color', 'vm_role', 'slug', 'actions')
+        fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'slug', 'actions')
 
 
 #

+ 58 - 5
netbox/dcim/tests/test_models.py

@@ -9,14 +9,29 @@ class RackTestCase(TestCase):
 
     def setUp(self):
 
-        self.site = Site.objects.create(
+        self.site1 = Site.objects.create(
             name='TestSite1',
-            slug='my-test-site'
+            slug='test-site-1'
+        )
+        self.site2 = Site.objects.create(
+            name='TestSite2',
+            slug='test-site-2'
+        )
+        self.group1 = RackGroup.objects.create(
+            name='TestGroup1',
+            slug='test-group-1',
+            site=self.site1
+        )
+        self.group2 = RackGroup.objects.create(
+            name='TestGroup2',
+            slug='test-group-2',
+            site=self.site2
         )
         self.rack = Rack.objects.create(
             name='TestRack1',
             facility_id='A101',
-            site=self.site,
+            site=self.site1,
+            group=self.group1,
             u_height=42
         )
         self.manufacturer = Manufacturer.objects.create(
@@ -57,13 +72,51 @@ class RackTestCase(TestCase):
 
         }
 
+    def test_rack_device_outside_height(self):
+
+        rack1 = Rack(
+            name='TestRack2',
+            facility_id='A102',
+            site=self.site1,
+            u_height=42
+        )
+        rack1.save()
+
+        device1 = Device(
+            name='TestSwitch1',
+            device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
+            device_role=DeviceRole.objects.get(slug='switch'),
+            site=self.site1,
+            rack=rack1,
+            position=43,
+            face=RACK_FACE_FRONT,
+        )
+        device1.save()
+
+        with self.assertRaises(ValidationError):
+            rack1.clean()
+
+    def test_rack_group_site(self):
+
+        rack_invalid_group = Rack(
+            name='TestRack2',
+            facility_id='A102',
+            site=self.site1,
+            u_height=42,
+            group=self.group2
+        )
+        rack_invalid_group.save()
+
+        with self.assertRaises(ValidationError):
+            rack_invalid_group.clean()
+
     def test_mount_single_device(self):
 
         device1 = Device(
             name='TestSwitch1',
             device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
             device_role=DeviceRole.objects.get(slug='switch'),
-            site=self.site,
+            site=self.site1,
             rack=self.rack,
             position=10,
             face=RACK_FACE_REAR,
@@ -92,7 +145,7 @@ class RackTestCase(TestCase):
             name='TestPDU',
             device_role=self.role.get('PDU'),
             device_type=self.device_type.get('cc5000'),
-            site=self.site,
+            site=self.site1,
             rack=self.rack,
             position=None,
             face=None,

+ 1 - 0
netbox/dcim/urls.py

@@ -45,6 +45,7 @@ urlpatterns = [
 
     # Rack reservations
     url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
+    url(r'^rack-reservations/edit/$', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
     url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
     url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
     url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),

+ 20 - 7
netbox/dcim/views.py

@@ -426,6 +426,16 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
         return obj.rack.get_absolute_url()
 
 
+class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_rackreservation'
+    cls = RackReservation
+    queryset = RackReservation.objects.select_related('rack', 'user')
+    filter = filters.RackReservationFilter
+    table = tables.RackReservationTable
+    form = forms.RackReservationBulkEditForm
+    default_return_url = 'dcim:rackreservation_list'
+
+
 class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackreservation'
     cls = RackReservation
@@ -517,12 +527,12 @@ class DeviceTypeView(View):
             show_header=False
         )
         if request.user.has_perm('dcim.change_devicetype'):
-            consoleport_table.base_columns['pk'].visible = True
-            consoleserverport_table.base_columns['pk'].visible = True
-            powerport_table.base_columns['pk'].visible = True
-            poweroutlet_table.base_columns['pk'].visible = True
-            interface_table.base_columns['pk'].visible = True
-            devicebay_table.base_columns['pk'].visible = True
+            consoleport_table.columns.show('pk')
+            consoleserverport_table.columns.show('pk')
+            powerport_table.columns.show('pk')
+            poweroutlet_table.columns.show('pk')
+            interface_table.columns.show('pk')
+            devicebay_table.columns.show('pk')
 
         return render(request, 'dcim/devicetype.html', {
             'devicetype': devicetype,
@@ -700,7 +710,10 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class DeviceRoleListView(ObjectListView):
-    queryset = DeviceRole.objects.annotate(device_count=Count('devices'))
+    queryset = DeviceRole.objects.annotate(
+        device_count=Count('devices', distinct=True),
+        vm_count=Count('virtual_machines', distinct=True)
+    )
     table = tables.DeviceRoleTable
     template_name = 'dcim/devicerole_list.html'
 

+ 19 - 8
netbox/extras/filters.py

@@ -19,17 +19,28 @@ class CustomFieldFilter(django_filters.Filter):
         super(CustomFieldFilter, self).__init__(*args, **kwargs)
 
     def filter(self, queryset, value):
+
         # Skip filter on empty value
         if not value.strip():
             return queryset
-        # Treat 0 as None for Select fields
-        try:
-            if self.cf_type == CF_TYPE_SELECT and int(value) == 0:
-                return queryset.exclude(
-                    custom_field_values__field__name=self.name,
-                )
-        except ValueError:
-            pass
+
+        # Selection fields get special treatment (values must be integers)
+        if self.cf_type == CF_TYPE_SELECT:
+            try:
+                # Treat 0 as None
+                if int(value) == 0:
+                    return queryset.exclude(
+                        custom_field_values__field__name=self.name,
+                    )
+                # Match on exact CustomFieldChoice PK
+                else:
+                    return queryset.filter(
+                        custom_field_values__field__name=self.name,
+                        custom_field_values__serialized_value=value,
+                    )
+            except ValueError:
+                return queryset.none()
+
         return queryset.filter(
             custom_field_values__field__name=self.name,
             custom_field_values__serialized_value__icontains=value,

+ 9 - 0
netbox/extras/reports.py

@@ -177,3 +177,12 @@ class Report(object):
         result = ReportResult(report=self.full_name, failed=self.failed, data=self._results)
         result.save()
         self.result = result
+
+        # Perform any post-run tasks
+        self.post_run()
+
+    def post_run(self):
+        """
+        Extend this method to include any tasks which should execute after the report has been run.
+        """
+        pass

+ 30 - 32
netbox/ipam/filters.py

@@ -9,7 +9,7 @@ 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, NumericInFilter
+from utilities.filters import NumericInFilter
 from virtualization.models import VirtualMachine
 from .models import (
     Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role,
@@ -23,11 +23,11 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
         method='search',
         label='Search',
     )
-    tenant_id = NullableModelMultipleChoiceFilter(
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = NullableModelMultipleChoiceFilter(
+    tenant = django_filters.ModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         to_field_name='slug',
@@ -110,37 +110,37 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         method='filter_mask_length',
         label='Mask length',
     )
-    vrf_id = NullableModelMultipleChoiceFilter(
+    vrf_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VRF.objects.all(),
         label='VRF',
     )
-    vrf = NullableModelMultipleChoiceFilter(
+    vrf = django_filters.ModelMultipleChoiceFilter(
         name='vrf',
         queryset=VRF.objects.all(),
         to_field_name='rd',
         label='VRF (RD)',
     )
-    tenant_id = NullableModelMultipleChoiceFilter(
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = NullableModelMultipleChoiceFilter(
+    tenant = django_filters.ModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         to_field_name='slug',
         label='Tenant (slug)',
     )
-    site_id = NullableModelMultipleChoiceFilter(
+    site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         label='Site (ID)',
     )
-    site = NullableModelMultipleChoiceFilter(
+    site = django_filters.ModelMultipleChoiceFilter(
         name='site',
         queryset=Site.objects.all(),
         to_field_name='slug',
         label='Site (slug)',
     )
-    vlan_id = NullableModelMultipleChoiceFilter(
+    vlan_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VLAN.objects.all(),
         label='VLAN (ID)',
     )
@@ -148,11 +148,11 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         name='vlan__vid',
         label='VLAN number (1-4095)',
     )
-    role_id = NullableModelMultipleChoiceFilter(
+    role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Role.objects.all(),
         label='Role (ID)',
     )
-    role = NullableModelMultipleChoiceFilter(
+    role = django_filters.ModelMultipleChoiceFilter(
         name='role',
         queryset=Role.objects.all(),
         to_field_name='slug',
@@ -207,21 +207,21 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
         method='filter_mask_length',
         label='Mask length',
     )
-    vrf_id = NullableModelMultipleChoiceFilter(
+    vrf_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VRF.objects.all(),
         label='VRF',
     )
-    vrf = NullableModelMultipleChoiceFilter(
+    vrf = django_filters.ModelMultipleChoiceFilter(
         name='vrf',
         queryset=VRF.objects.all(),
         to_field_name='rd',
         label='VRF (RD)',
     )
-    tenant_id = NullableModelMultipleChoiceFilter(
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = NullableModelMultipleChoiceFilter(
+    tenant = django_filters.ModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         to_field_name='slug',
@@ -267,12 +267,10 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
     def search(self, queryset, name, value):
         if not value.strip():
             return queryset
-        qs_filter = Q(description__icontains=value)
-        try:
-            ipaddress = str(IPNetwork(value.strip()))
-            qs_filter |= Q(address__net_host=ipaddress)
-        except (AddrFormatError, ValueError):
-            pass
+        qs_filter = (
+            Q(description__icontains=value) |
+            Q(address__istartswith=value)
+        )
         return queryset.filter(qs_filter)
 
     def search_by_parent(self, queryset, name, value):
@@ -292,11 +290,11 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
 class VLANGroupFilter(django_filters.FilterSet):
-    site_id = NullableModelMultipleChoiceFilter(
+    site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         label='Site (ID)',
     )
-    site = NullableModelMultipleChoiceFilter(
+    site = django_filters.ModelMultipleChoiceFilter(
         name='site',
         queryset=Site.objects.all(),
         to_field_name='slug',
@@ -314,41 +312,41 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
         method='search',
         label='Search',
     )
-    site_id = NullableModelMultipleChoiceFilter(
+    site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         label='Site (ID)',
     )
-    site = NullableModelMultipleChoiceFilter(
+    site = django_filters.ModelMultipleChoiceFilter(
         name='site',
         queryset=Site.objects.all(),
         to_field_name='slug',
         label='Site (slug)',
     )
-    group_id = NullableModelMultipleChoiceFilter(
+    group_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VLANGroup.objects.all(),
         label='Group (ID)',
     )
-    group = NullableModelMultipleChoiceFilter(
+    group = django_filters.ModelMultipleChoiceFilter(
         name='group',
         queryset=VLANGroup.objects.all(),
         to_field_name='slug',
         label='Group',
     )
-    tenant_id = NullableModelMultipleChoiceFilter(
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = NullableModelMultipleChoiceFilter(
+    tenant = django_filters.ModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         to_field_name='slug',
         label='Tenant (slug)',
     )
-    role_id = NullableModelMultipleChoiceFilter(
+    role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Role.objects.all(),
         label='Role (ID)',
     )
-    role = NullableModelMultipleChoiceFilter(
+    role = django_filters.ModelMultipleChoiceFilter(
         name='role',
         queryset=Role.objects.all(),
         to_field_name='slug',

+ 7 - 7
netbox/ipam/lookups.py

@@ -1,7 +1,7 @@
 from __future__ import unicode_literals
 
 from django.db.models import Lookup, Transform, IntegerField
-from django.db.models.lookups import BuiltinLookup
+from django.db.models import lookups
 
 
 class NetFieldDecoratorMixin(object):
@@ -13,27 +13,27 @@ class NetFieldDecoratorMixin(object):
         return lhs_string, lhs_params
 
 
-class EndsWith(NetFieldDecoratorMixin, BuiltinLookup):
+class EndsWith(NetFieldDecoratorMixin, lookups.EndsWith):
     lookup_name = 'endswith'
 
 
-class IEndsWith(NetFieldDecoratorMixin, BuiltinLookup):
+class IEndsWith(NetFieldDecoratorMixin, lookups.IEndsWith):
     lookup_name = 'iendswith'
 
 
-class StartsWith(NetFieldDecoratorMixin, BuiltinLookup):
+class StartsWith(NetFieldDecoratorMixin, lookups.StartsWith):
     lookup_name = 'startswith'
 
 
-class IStartsWith(NetFieldDecoratorMixin, BuiltinLookup):
+class IStartsWith(NetFieldDecoratorMixin, lookups.IStartsWith):
     lookup_name = 'istartswith'
 
 
-class Regex(NetFieldDecoratorMixin, BuiltinLookup):
+class Regex(NetFieldDecoratorMixin, lookups.Regex):
     lookup_name = 'regex'
 
 
-class IRegex(NetFieldDecoratorMixin, BuiltinLookup):
+class IRegex(NetFieldDecoratorMixin, lookups.IRegex):
     lookup_name = 'iregex'
 
 

+ 3 - 3
netbox/ipam/views.py

@@ -325,7 +325,7 @@ class AggregateView(View):
 
         prefix_table = tables.PrefixDetailTable(child_prefixes)
         if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
-            prefix_table.base_columns['pk'].visible = True
+            prefix_table.columns.show('pk')
 
         paginate = {
             'klass': EnhancedPaginator,
@@ -495,7 +495,7 @@ class PrefixView(View):
             child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
         child_prefix_table = tables.PrefixDetailTable(child_prefixes)
         if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
-            child_prefix_table.base_columns['pk'].visible = True
+            child_prefix_table.columns.show('pk')
 
         paginate = {
             'klass': EnhancedPaginator,
@@ -538,7 +538,7 @@ class PrefixIPAddressesView(View):
 
         ip_table = tables.IPAddressTable(ipaddresses)
         if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
-            ip_table.base_columns['pk'].visible = True
+            ip_table.columns.show('pk')
 
         paginate = {
             'klass': EnhancedPaginator,

+ 5 - 1
netbox/netbox/settings.py

@@ -13,7 +13,7 @@ except ImportError:
     )
 
 
-VERSION = '2.2.2'
+VERSION = '2.2.3'
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
@@ -206,6 +206,10 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH)
 # Secrets
 SECRETS_MIN_PUBKEY_SIZE = 2048
 
+# Django filters
+FILTERS_NULL_CHOICE_LABEL = 'None'
+FILTERS_NULL_CHOICE_VALUE = '0'  # Must be a string
+
 # Django REST framework (API)
 REST_FRAMEWORK_VERSION = VERSION[0:3]  # Use major.minor as API version
 REST_FRAMEWORK = {

+ 6 - 1
netbox/project-static/css/base.css

@@ -106,9 +106,14 @@ label {
 label.required {
     font-weight: bold;
 }
+input[name="pk"] {
+    margin-top: 0;
+}
 
 /* Tables */
-th.pk, td.pk {
+.table > tbody > tr > th.pk, .table > tbody > tr > td.pk {
+    padding-bottom: 6px;
+    padding-top: 10px;
     width: 30px;
 }
 tfoot td {

+ 16 - 4
netbox/templates/dcim/device_lldp_neighbors.html

@@ -54,15 +54,27 @@ $(document).ready(function() {
             $.each(json['get_lldp_neighbors'], function(iface, neighbors) {
                 var neighbor = neighbors[0];
                 var row = $('#' + iface.split(".")[0].replace(/(\/)/g, "\\$1"));
+
+                // Glean configured hostnames/interfaces from the DOM
                 var configured_device = row.children('td.configured_device').attr('data');
                 var configured_interface = row.children('td.configured_interface').attr('data');
+                if (configured_interface) {
+                    // Match long-form IOS names against short ones (e.g. Gi0/1 == GigabitEthernet0/1).
+                    configured_interface = configured_interface.replace(/^([A-Z][a-z])[^0-9]*([0-9\/]+)$/, "$1$2");
+                }
+
+                // Clean up hostnames/interfaces learned via LLDP
+                var lldp_device = neighbor['hostname'].split(".")[0];  // Strip off any trailing domain name
+                var lldp_interface = neighbor['port'].split(".")[0];   // Strip off any trailing subinterface ID
+
                 // Add LLDP neighbors to table
-                row.children('td.device').html(neighbor['hostname']);
-                row.children('td.interface').html(neighbor['port']);
+                row.children('td.device').html(lldp_device);
+                row.children('td.interface').html(lldp_interface);
+
                 // Apply colors to rows
-                if (!configured_device && neighbor['hostname']) {
+                if (!configured_device && lldp_device) {
                     row.addClass('info');
-                } else if (configured_device == neighbor['hostname'] && configured_interface == neighbor['port'].split(".")[0]) {
+                } else if (configured_device == lldp_device && configured_interface == lldp_interface) {
                     row.addClass('success');
                 } else {
                     row.addClass('danger');

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

@@ -120,7 +120,7 @@
                     </td>
                     <td>
                         <strong>Network Device</strong><br />
-                        <small class="text-muted">This device {% if devicetype.is_network_device %}has{% else %}does not have{% endif %} non-management network interfaces</small>
+                        <small class="text-muted">This device {% if devicetype.is_network_device %}has{% else %}does not have{% endif %} network interfaces</small>
                     </td>
                 </tr>
                 <tr>

+ 1 - 1
netbox/templates/dcim/inc/consoleserverport.html

@@ -5,7 +5,7 @@
         </td>
     {% endif %}
     <td>
-        <i class="fa fa-fw fa-keyboard-o"></i> {{ csp.name }}
+        <i class="fa fa-fw fa-keyboard-o"></i> {{ csp }}
     </td>
     <td></td>
     {% if csp.connected_console %}

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

@@ -5,7 +5,7 @@
 <h1>{% block title %}Rack Reservations{% endblock %}</h1>
 <div class="row">
 	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackreservation_bulk_delete' %}
+        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rackreservation_bulk_edit' bulk_delete_url='dcim:rackreservation_bulk_delete' %}
     </div>
 	<div class="col-md-3">
 		{% include 'inc/search_panel.html' %}

+ 3 - 3
netbox/tenancy/filters.py

@@ -5,7 +5,7 @@ import django_filters
 from django.db.models import Q
 
 from extras.filters import CustomFieldFilterSet
-from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
+from utilities.filters import NumericInFilter
 from .models import Tenant, TenantGroup
 
 
@@ -22,11 +22,11 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
         method='search',
         label='Search',
     )
-    group_id = NullableModelMultipleChoiceFilter(
+    group_id = django_filters.ModelMultipleChoiceFilter(
         queryset=TenantGroup.objects.all(),
         label='Group (ID)',
     )
-    group = NullableModelMultipleChoiceFilter(
+    group = django_filters.ModelMultipleChoiceFilter(
         name='group',
         queryset=TenantGroup.objects.all(),
         to_field_name='slug',

+ 0 - 49
netbox/utilities/filters.py

@@ -4,7 +4,6 @@ import django_filters
 import itertools
 
 from django import forms
-from django.db.models import Q
 from django.utils.encoding import force_text
 
 
@@ -66,51 +65,3 @@ class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField):
             stripped_value = value
         super(NullableModelMultipleChoiceField, self).clean(stripped_value)
         return value
-
-
-class NullableModelMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
-    """
-    This class extends ModelMultipleChoiceFilter to accept an additional value which implies "is null". The default
-    queryset filter argument is:
-
-        .filter(fieldname=value)
-
-    When filtering by the value representing "is null" ('0' by default) the argument is modified to:
-
-        .filter(fieldname__isnull=True)
-    """
-    field_class = NullableModelMultipleChoiceField
-
-    def __init__(self, *args, **kwargs):
-        self.null_value = kwargs.get('null_value', 0)
-        super(NullableModelMultipleChoiceFilter, self).__init__(*args, **kwargs)
-
-    def filter(self, qs, value):
-        value = value or ()  # Make sure we have an iterable
-
-        if self.is_noop(qs, value):
-            return qs
-
-        # Even though not a noop, no point filtering if empty
-        if not value:
-            return qs
-
-        q = Q()
-        for v in set(value):
-            # Filtering by "is null"
-            if v == force_text(self.null_value):
-                arg = {'{}__isnull'.format(self.name): True}
-            # Filtering by a related field (e.g. slug)
-            elif self.field.to_field_name is not None:
-                arg = {'{}__{}'.format(self.name, self.field.to_field_name): v}
-            # Filtering by primary key (default)
-            else:
-                arg = {self.name: v}
-            if self.conjoined:
-                qs = self.get_method(qs)(**arg)
-            else:
-                q |= Q(**arg)
-        if self.distinct:
-            return self.get_method(qs)(q).distinct()
-
-        return self.get_method(qs)(q)

+ 11 - 11
netbox/virtualization/filters.py

@@ -9,7 +9,7 @@ from django.db.models import Q
 from dcim.models import DeviceRole, Interface, Platform, Site
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
-from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
+from utilities.filters import NumericInFilter
 from .constants import STATUS_CHOICES
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
@@ -20,11 +20,11 @@ class ClusterFilter(CustomFieldFilterSet):
         method='search',
         label='Search',
     )
-    group_id = NullableModelMultipleChoiceFilter(
+    group_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ClusterGroup.objects.all(),
         label='Parent group (ID)',
     )
-    group = NullableModelMultipleChoiceFilter(
+    group = django_filters.ModelMultipleChoiceFilter(
         queryset=ClusterGroup.objects.all(),
         to_field_name='slug',
         label='Parent group (slug)',
@@ -72,12 +72,12 @@ class VirtualMachineFilter(CustomFieldFilterSet):
     status = django_filters.MultipleChoiceFilter(
         choices=STATUS_CHOICES
     )
-    cluster_group_id = NullableModelMultipleChoiceFilter(
+    cluster_group_id = django_filters.ModelMultipleChoiceFilter(
         name='cluster__group',
         queryset=ClusterGroup.objects.all(),
         label='Cluster group (ID)',
     )
-    cluster_group = NullableModelMultipleChoiceFilter(
+    cluster_group = django_filters.ModelMultipleChoiceFilter(
         name='cluster__group',
         queryset=ClusterGroup.objects.all(),
         to_field_name='slug',
@@ -87,29 +87,29 @@ class VirtualMachineFilter(CustomFieldFilterSet):
         queryset=Cluster.objects.all(),
         label='Cluster (ID)',
     )
-    role_id = NullableModelMultipleChoiceFilter(
+    role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DeviceRole.objects.all(),
         label='Role (ID)',
     )
-    role = NullableModelMultipleChoiceFilter(
+    role = django_filters.ModelMultipleChoiceFilter(
         queryset=DeviceRole.objects.all(),
         to_field_name='slug',
         label='Role (slug)',
     )
-    tenant_id = NullableModelMultipleChoiceFilter(
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = NullableModelMultipleChoiceFilter(
+    tenant = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
         to_field_name='slug',
         label='Tenant (slug)',
     )
-    platform_id = NullableModelMultipleChoiceFilter(
+    platform_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Platform.objects.all(),
         label='Platform (ID)',
     )
-    platform = NullableModelMultipleChoiceFilter(
+    platform = django_filters.ModelMultipleChoiceFilter(
         queryset=Platform.objects.all(),
         to_field_name='slug',
         label='Platform (slug)',

+ 91 - 0
netbox/virtualization/fixtures/initial_data.json

@@ -0,0 +1,91 @@
+[
+{
+    "model": "virtualization.clustertype",
+    "pk": 1,
+    "fields": {
+        "name": "Public Cloud",
+        "slug": "public-cloud"
+    }
+},
+{
+    "model": "virtualization.clustertype",
+    "pk": 2,
+    "fields": {
+        "name": "vSphere",
+        "slug": "vsphere"
+    }
+},
+{
+    "model": "virtualization.clustertype",
+    "pk": 3,
+    "fields": {
+        "name": "Hyper-V",
+        "slug": "hyper-v"
+    }
+},
+{
+    "model": "virtualization.clustertype",
+    "pk": 4,
+    "fields": {
+        "name": "libvirt",
+        "slug": "libvirt"
+    }
+},
+{
+    "model": "virtualization.clustertype",
+    "pk": 5,
+    "fields": {
+        "name": "LXD",
+        "slug": "lxd"
+    }
+},
+{
+    "model": "virtualization.clustertype",
+    "pk": 6,
+    "fields": {
+        "name": "Docker",
+        "slug": "docker"
+    }
+},
+{
+    "model": "virtualization.clustergroup",
+    "pk": 1,
+    "fields": {
+        "name": "VM Host",
+        "slug": "vm-host"
+    }
+},
+{
+    "model": "virtualization.cluster",
+    "pk": 1,
+    "fields": {
+        "name": "Digital Ocean",
+        "type": 1,
+        "group": 1,
+        "created": "2016-08-01",
+        "last_updated": "2016-08-01T15:22:42.289Z"
+    }
+},
+{
+    "model": "virtualization.cluster",
+    "pk": 2,
+    "fields": {
+        "name": "Amazon EC2",
+        "type": 1,
+        "group": 1,
+        "created": "2016-08-01",
+        "last_updated": "2016-08-01T15:22:42.289Z"
+    }
+},
+{
+    "model": "virtualization.cluster",
+    "pk": 3,
+    "fields": {
+        "name": "Microsoft Azure",
+        "type": 1,
+        "group": 1,
+        "created": "2016-08-01",
+        "last_updated": "2016-08-01T15:22:42.289Z"
+    }
+}
+]

+ 1 - 0
netbox/virtualization/views.py

@@ -314,6 +314,7 @@ class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'virtualization.delete_virtualmachine'
     cls = VirtualMachine
     queryset = VirtualMachine.objects.select_related('cluster', 'tenant')
+    filter = filters.VirtualMachineFilter
     table = tables.VirtualMachineTable
     default_return_url = 'virtualization:virtualmachine_list'
 

+ 1 - 1
requirements.txt

@@ -1,7 +1,7 @@
 Django>=1.11,<2.0
 django-cors-headers>=2.1
 django-debug-toolbar>=1.8
-django-filter>=1.0.4
+django-filter>=1.1.0
 django-mptt==0.8.7
 django-rest-swagger>=2.1.0
 django-tables2>=1.10.0