Selaa lähdekoodia

Merge v2.9.4 release

Jeremy Stretch 5 vuotta sitten
vanhempi
commit
9b16d6df2e
46 muutettua tiedostoa jossa 343 lisäystä ja 129 poistoa
  1. 8 0
      docs/configuration/optional-settings.md
  2. 2 5
      docs/configuration/required-settings.md
  3. 0 2
      docs/installation/3-netbox.md
  4. 1 1
      docs/models/dcim/rearporttemplate.md
  5. 33 0
      docs/release-notes/version-2.9.md
  6. 1 1
      netbox/dcim/constants.py
  7. 10 1
      netbox/dcim/forms.py
  8. 34 0
      netbox/dcim/migrations/0116_rearport_max_positions.py
  9. 1 1
      netbox/dcim/migrations/0117_custom_field_data.py
  10. 1 1
      netbox/dcim/migrations/0118_inventoryitem_mptt.py
  11. 1 1
      netbox/dcim/migrations/0119_inventoryitem_mptt_rebuild.py
  12. 8 2
      netbox/dcim/models/device_component_templates.py
  13. 8 2
      netbox/dcim/models/device_components.py
  14. 13 4
      netbox/dcim/views.py
  15. 11 5
      netbox/extras/api/serializers.py
  16. 11 0
      netbox/extras/filters.py
  17. 2 1
      netbox/extras/forms.py
  18. 31 5
      netbox/extras/management/commands/runreport.py
  19. 1 1
      netbox/extras/migrations/0051_migrate_customfields.py
  20. 5 6
      netbox/extras/tests/test_filters.py
  21. 18 0
      netbox/extras/tests/test_tags.py
  22. 2 2
      netbox/extras/views.py
  23. 4 9
      netbox/ipam/forms.py
  24. 14 19
      netbox/ipam/models.py
  25. 25 10
      netbox/ipam/tables.py
  26. 2 1
      netbox/ipam/urls.py
  27. 25 6
      netbox/ipam/views.py
  28. 3 2
      netbox/netbox/configuration.example.py
  29. 0 2
      netbox/netbox/configuration.testing.py
  30. 9 5
      netbox/netbox/settings.py
  31. 2 5
      netbox/templates/dcim/device.html
  32. 1 0
      netbox/templates/dcim/device_edit.html
  33. 10 1
      netbox/templates/dcim/rack.html
  34. 9 3
      netbox/templates/dcim/rack_elevation_list.html
  35. 3 3
      netbox/templates/dcim/site.html
  36. 8 7
      netbox/templates/exceptions/import_error.html
  37. 2 2
      netbox/templates/home.html
  38. 1 1
      netbox/templates/inc/nav_menu.html
  39. 5 2
      netbox/templates/ipam/vlan.html
  40. 1 3
      netbox/templates/ipam/vlan_interfaces.html
  41. 9 0
      netbox/templates/ipam/vlan_vminterfaces.html
  42. 1 1
      netbox/utilities/forms/widgets.py
  43. 4 4
      netbox/utilities/tables.py
  44. 1 1
      netbox/utilities/testing/api.py
  45. 1 1
      netbox/utilities/views.py
  46. 1 0
      netbox/virtualization/migrations/0016_replicate_interfaces.py

+ 8 - 0
docs/configuration/optional-settings.md

@@ -491,6 +491,14 @@ The file path to the location where custom reports will be kept. By default, thi
 
 ---
 
+## RQ_DEFAULT_TIMEOUT
+
+Default: `300`
+
+The maximum execution time of a background task (such as running a custom script), in seconds.
+
+---
+
 ## SCRIPTS_ROOT
 
 Default: `$INSTALL_ROOT/netbox/scripts/`

+ 2 - 5
docs/configuration/required-settings.md

@@ -65,7 +65,6 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
 * `PORT` - TCP port of the Redis service; leave blank for default port (6379)
 * `PASSWORD` - Redis password (if set)
 * `DATABASE` - Numeric database ID
-* `DEFAULT_TIMEOUT` - Connection timeout in seconds
 * `SSL` - Use SSL connection to Redis
 
 An example configuration is provided below:
@@ -77,7 +76,6 @@ REDIS = {
         'PORT': 1234,
         'PASSWORD': 'foobar',
         'DATABASE': 0,
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
     },
     'caching': {
@@ -85,7 +83,6 @@ REDIS = {
         'PORT': 6379,
         'PASSWORD': '',
         'DATABASE': 1,
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
     }
 }
@@ -109,6 +106,7 @@ above and the addition of two new keys.
 * `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address 
 of the Redis server and port for each sentinel instance to connect to
 * `SENTINEL_SERVICE`: Name of the master / service to connect to
+* `SENTINEL_TIMEOUT`: Connection timeout, in seconds
 
 Example:
 
@@ -117,9 +115,9 @@ REDIS = {
     'tasks': {
         'SENTINELS': [('mysentinel.redis.example.com', 6379)],
         'SENTINEL_SERVICE': 'netbox',
+        'SENTINEL_TIMEOUT': 10,
         'PASSWORD': '',
         'DATABASE': 0,
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
     },
     'caching': {
@@ -130,7 +128,6 @@ REDIS = {
         'SENTINEL_SERVICE': 'netbox',
         'PASSWORD': '',
         'DATABASE': 1,
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
     }
 }

+ 0 - 2
docs/installation/3-netbox.md

@@ -163,7 +163,6 @@ REDIS = {
         'PORT': 6379,             # Redis port
         'PASSWORD': '',           # Redis password (optional)
         'DATABASE': 0,            # Database ID
-        'DEFAULT_TIMEOUT': 300,   # Timeout (seconds)
         'SSL': False,             # Use SSL (optional)
     },
     'caching': {
@@ -171,7 +170,6 @@ REDIS = {
         'PORT': 6379,
         'PASSWORD': '',
         'DATABASE': 1,            # Unique ID for second database
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
     }
 }

+ 1 - 1
docs/models/dcim/rearporttemplate.md

@@ -1,3 +1,3 @@
 ## Rear Port Templates
 
-A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 64).
+A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 1024).

+ 33 - 0
docs/release-notes/version-2.9.md

@@ -1,5 +1,37 @@
 # NetBox v2.9
 
+## v2.9.4 (2020-09-23)
+
+**NOTE:** This release removes support for the `DEFAULT_TIMEOUT` parameter under `REDIS` database configuration. Set `RQ_DEFAULT_TIMEOUT` as a global configuration parameter instead.
+
+**NOTE:** Any permissions referencing the legacy ReportResult model (e.g. `extras.view_reportresult`) should be updated to reference the Report model.
+
+### Enhancements
+
+* [#1755](https://github.com/netbox-community/netbox/issues/1755) - Toggle order in which rack elevations are displayed
+* [#5128](https://github.com/netbox-community/netbox/issues/5128) - Increase maximum rear port positions from 64 to 1024
+* [#5134](https://github.com/netbox-community/netbox/issues/5134) - Display full hierarchy in breadcrumbs for sites/racks
+* [#5149](https://github.com/netbox-community/netbox/issues/5149) - Add rack group field to device edit form
+* [#5164](https://github.com/netbox-community/netbox/issues/5164) - Show total rack count per rack group under site view
+* [#5171](https://github.com/netbox-community/netbox/issues/5171) - Introduce the `RQ_DEFAULT_TIMEOUT` configuration parameter
+
+### Bug Fixes
+
+* [#5050](https://github.com/netbox-community/netbox/issues/5050) - Fix potential failure on `0016_replicate_interfaces` schema migration from old release
+* [#5066](https://github.com/netbox-community/netbox/issues/5066) - Update `view_reportresult` to `view_report` permission
+* [#5075](https://github.com/netbox-community/netbox/issues/5075) - Include a VLAN membership view for VM interfaces
+* [#5105](https://github.com/netbox-community/netbox/issues/5105) - Validation should fail when reassigning a primary IP from device to VM
+* [#5109](https://github.com/netbox-community/netbox/issues/5109) - Fix representation of custom choice field values for webhook data
+* [#5108](https://github.com/netbox-community/netbox/issues/5108) - Fix execution of reports via CLI
+* [#5111](https://github.com/netbox-community/netbox/issues/5111) - Allow use of tuples when specifying ObjectVar `query_params`
+* [#5118](https://github.com/netbox-community/netbox/issues/5118) - Specifying an empty list of tags should clear assigned tags (REST API)
+* [#5133](https://github.com/netbox-community/netbox/issues/5133) - Fix disassociation of an IP address from a VM interface
+* [#5136](https://github.com/netbox-community/netbox/issues/5136) - Fix exception when bulk editing interface 802.1Q mode
+* [#5156](https://github.com/netbox-community/netbox/issues/5156) - Add missing "add" button to rack reservations list
+* [#5167](https://github.com/netbox-community/netbox/issues/5167) - Support filtering ObjectChanges by multiple users
+
+---
+
 ## v2.9.3 (2020-09-04)
 
 ### Enhancements
@@ -121,6 +153,7 @@ Two new REST API endpoints have been added to facilitate the retrieval and manip
 * If using NetBox's built-in remote authentication backend, update `REMOTE_AUTH_BACKEND` to `'netbox.authentication.RemoteUserBackend'`, as the authentication class has moved.
 * If using LDAP authentication, set `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
 * `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
+* Backward compatibility for the old `webhooks` Redis queue name has been dropped. Ensure that your `REDIS` configuration parameter specifies both the `tasks` and `caching` databases.
 
 ### REST API Changes
 

+ 1 - 1
netbox/dcim/constants.py

@@ -18,7 +18,7 @@ RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
 #
 
 REARPORT_POSITIONS_MIN = 1
-REARPORT_POSITIONS_MAX = 64
+REARPORT_POSITIONS_MAX = 1024
 
 
 #

+ 10 - 1
netbox/dcim/forms.py

@@ -1680,12 +1680,21 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             'region_id': '$region'
         }
     )
+    rack_group = DynamicModelChoiceField(
+        queryset=RackGroup.objects.all(),
+        required=False,
+        display_field='display_name',
+        query_params={
+            'site_id': '$site'
+        }
+    )
     rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
         required=False,
         display_field='display_name',
         query_params={
-            'site_id': '$site'
+            'site_id': '$site',
+            'group_id': '$rack_group',
         }
     )
     position = forms.TypedChoiceField(

+ 34 - 0
netbox/dcim/migrations/0116_rearport_max_positions.py

@@ -0,0 +1,34 @@
+# Generated by Django 3.1 on 2020-09-16 16:51
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0115_rackreservation_order'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='frontport',
+            name='rear_port_position',
+            field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
+        ),
+        migrations.AlterField(
+            model_name='frontporttemplate',
+            name='rear_port_position',
+            field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
+        ),
+        migrations.AlterField(
+            model_name='rearport',
+            name='positions',
+            field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
+        ),
+        migrations.AlterField(
+            model_name='rearporttemplate',
+            name='positions',
+            field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
+        ),
+    ]

+ 1 - 1
netbox/dcim/migrations/0116_custom_field_data.py → netbox/dcim/migrations/0117_custom_field_data.py

@@ -5,7 +5,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0115_rackreservation_order'),
+        ('dcim', '0116_rearport_max_positions'),
     ]
 
     operations = [

+ 1 - 1
netbox/dcim/migrations/0117_inventoryitem_mptt.py → netbox/dcim/migrations/0118_inventoryitem_mptt.py

@@ -6,7 +6,7 @@ import mptt.fields
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0116_custom_field_data'),
+        ('dcim', '0117_custom_field_data'),
     ]
 
     operations = [

+ 1 - 1
netbox/dcim/migrations/0118_inventoryitem_mptt_rebuild.py → netbox/dcim/migrations/0119_inventoryitem_mptt_rebuild.py

@@ -15,7 +15,7 @@ def rebuild_mptt(apps, schema_editor):
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0117_inventoryitem_mptt'),
+        ('dcim', '0118_inventoryitem_mptt'),
     ]
 
     operations = [

+ 8 - 2
netbox/dcim/models/device_component_templates.py

@@ -264,7 +264,10 @@ class FrontPortTemplate(ComponentTemplateModel):
     )
     rear_port_position = models.PositiveSmallIntegerField(
         default=1,
-        validators=[MinValueValidator(1), MaxValueValidator(64)]
+        validators=[
+            MinValueValidator(REARPORT_POSITIONS_MIN),
+            MaxValueValidator(REARPORT_POSITIONS_MAX)
+        ]
     )
 
     class Meta:
@@ -315,7 +318,10 @@ class RearPortTemplate(ComponentTemplateModel):
     )
     positions = models.PositiveSmallIntegerField(
         default=1,
-        validators=[MinValueValidator(1), MaxValueValidator(64)]
+        validators=[
+            MinValueValidator(REARPORT_POSITIONS_MIN),
+            MaxValueValidator(REARPORT_POSITIONS_MAX)
+        ]
     )
 
     class Meta:

+ 8 - 2
netbox/dcim/models/device_components.py

@@ -811,7 +811,10 @@ class FrontPort(CableTermination, ComponentModel):
     )
     rear_port_position = models.PositiveSmallIntegerField(
         default=1,
-        validators=[MinValueValidator(1), MaxValueValidator(64)]
+        validators=[
+            MinValueValidator(REARPORT_POSITIONS_MIN),
+            MaxValueValidator(REARPORT_POSITIONS_MAX)
+        ]
     )
     tags = TaggableManager(through=TaggedItem)
 
@@ -866,7 +869,10 @@ class RearPort(CableTermination, ComponentModel):
     )
     positions = models.PositiveSmallIntegerField(
         default=1,
-        validators=[MinValueValidator(1), MaxValueValidator(64)]
+        validators=[
+            MinValueValidator(REARPORT_POSITIONS_MIN),
+            MaxValueValidator(REARPORT_POSITIONS_MAX)
+        ]
     )
     tags = TaggableManager(through=TaggedItem)
 

+ 13 - 4
netbox/dcim/views.py

@@ -168,9 +168,13 @@ class SiteView(ObjectView):
             'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=site).count(),
             'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=site).count(),
         }
-        rack_groups = RackGroup.objects.restrict(request.user, 'view').filter(site=site).annotate(
-            rack_count=Count('racks')
-        )
+        rack_groups = RackGroup.objects.add_related_count(
+            RackGroup.objects.all(),
+            Rack,
+            'group',
+            'rack_count',
+            cumulative=True
+        ).restrict(request.user, 'view').filter(site=site)
 
         return render(request, 'dcim/site.html', {
             'site': site,
@@ -307,6 +311,11 @@ class RackElevationListView(ObjectListView):
         racks = filters.RackFilterSet(request.GET, self.queryset).qs
         total_count = racks.count()
 
+        # Determine ordering
+        reverse = bool(request.GET.get('reverse', False))
+        if reverse:
+            racks = racks.reverse()
+
         # Pagination
         per_page = request.GET.get('per_page', settings.PAGINATE_COUNT)
         page_number = request.GET.get('page', 1)
@@ -327,6 +336,7 @@ class RackElevationListView(ObjectListView):
             'paginator': paginator,
             'page': page,
             'total_count': total_count,
+            'reverse': reverse,
             'rack_face': rack_face,
             'filter_form': forms.RackElevationFilterForm(request.GET),
         })
@@ -405,7 +415,6 @@ class RackReservationListView(ObjectListView):
     filterset = filters.RackReservationFilterSet
     filterset_form = forms.RackReservationFilterForm
     table = tables.RackReservationTable
-    action_buttons = ('export',)
 
 
 class RackReservationView(ObjectView):

+ 11 - 5
netbox/extras/api/serializers.py

@@ -57,24 +57,30 @@ class TaggedObjectSerializer(serializers.Serializer):
     tags = NestedTagSerializer(many=True, required=False)
 
     def create(self, validated_data):
-        tags = validated_data.pop('tags', [])
+        tags = validated_data.pop('tags', None)
         instance = super().create(validated_data)
 
-        return self._save_tags(instance, tags)
+        if tags is not None:
+            return self._save_tags(instance, tags)
+        return instance
 
     def update(self, instance, validated_data):
-        tags = validated_data.pop('tags', [])
+        tags = validated_data.pop('tags', None)
 
         # Cache tags on instance for change logging
-        instance._tags = tags
+        instance._tags = tags or []
 
         instance = super().update(instance, validated_data)
 
-        return self._save_tags(instance, tags)
+        if tags is not None:
+            return self._save_tags(instance, tags)
+        return instance
 
     def _save_tags(self, instance, tags):
         if tags:
             instance.tags.set(*[t.name for t in tags])
+        else:
+            instance.tags.clear()
 
         return instance
 

+ 11 - 0
netbox/extras/filters.py

@@ -1,4 +1,5 @@
 import django_filters
+from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 
@@ -236,6 +237,16 @@ class ObjectChangeFilterSet(BaseFilterSet):
     )
     time = django_filters.DateTimeFromToRangeFilter()
     changed_object_type = ContentTypeFilter()
+    user_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=User.objects.all(),
+        label='User (ID)',
+    )
+    user = django_filters.ModelMultipleChoiceFilter(
+        field_name='user__username',
+        queryset=User.objects.all(),
+        to_field_name='username',
+        label='User name',
+    )
 
     class Meta:
         model = ObjectChange

+ 2 - 1
netbox/extras/forms.py

@@ -353,10 +353,11 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
         required=False,
         widget=StaticSelect2()
     )
-    user = DynamicModelMultipleChoiceField(
+    user_id = DynamicModelMultipleChoiceField(
         queryset=User.objects.all(),
         required=False,
         display_field='username',
+        label='User',
         widget=APISelectMultiple(
             api_url='/api/users/users/',
         )

+ 31 - 5
netbox/extras/management/commands/runreport.py

@@ -1,7 +1,12 @@
+import time
+
+from django.contrib.contenttypes.models import ContentType
 from django.core.management.base import BaseCommand
 from django.utils import timezone
 
-from extras.reports import get_reports
+from extras.choices import JobResultStatusChoices
+from extras.models import JobResult
+from extras.reports import get_reports, run_report
 
 
 class Command(BaseCommand):
@@ -20,15 +25,33 @@ class Command(BaseCommand):
             for report in report_list:
                 if module_name in options['reports'] or report.full_name in options['reports']:
 
-                    # Run the report and create a new ReportResult
+                    # Run the report and create a new JobResult
                     self.stdout.write(
                         "[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
                     )
-                    report.run()
+
+                    report_content_type = ContentType.objects.get(app_label='extras', model='report')
+                    job_result = JobResult.enqueue_job(
+                        run_report,
+                        report.full_name,
+                        report_content_type,
+                        None
+                    )
+
+                    # Wait on the job to finish
+                    while job_result.status not in JobResultStatusChoices.TERMINAL_STATE_CHOICES:
+                        time.sleep(1)
+                        job_result = JobResult.objects.get(pk=job_result.pk)
 
                     # Report on success/failure
-                    status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS')
-                    for test_name, attrs in report.result.data.items():
+                    if job_result.status == JobResultStatusChoices.STATUS_FAILED:
+                        status = self.style.ERROR('FAILED')
+                    elif job_result == JobResultStatusChoices.STATUS_ERRORED:
+                        status = self.style.ERROR('ERRORED')
+                    else:
+                        status = self.style.SUCCESS('SUCCESS')
+
+                    for test_name, attrs in job_result.data.items():
                         self.stdout.write(
                             "\t{}: {} success, {} info, {} warning, {} failure".format(
                                 test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
@@ -37,6 +60,9 @@ class Command(BaseCommand):
                     self.stdout.write(
                         "[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
                     )
+                    self.stdout.write(
+                        "[{:%H:%M:%S}] {}: Duration {}".format(timezone.now(), report.full_name, job_result.duration)
+                    )
 
         # Wrap things up
         self.stdout.write(

+ 1 - 1
netbox/extras/migrations/0051_migrate_customfields.py

@@ -55,7 +55,7 @@ class Migration(migrations.Migration):
 
     dependencies = [
         ('circuits', '0020_custom_field_data'),
-        ('dcim', '0116_custom_field_data'),
+        ('dcim', '0117_custom_field_data'),
         ('extras', '0050_customfield_add_choices'),
         ('ipam', '0038_custom_field_data'),
         ('secrets', '0010_custom_field_data'),

+ 5 - 6
netbox/extras/tests/test_filters.py

@@ -381,12 +381,11 @@ class ObjectChangeTestCase(TestCase):
         params = {'id': self.queryset.values_list('pk', flat=True)[:3]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
-    # TODO: Merge #5167 from develop
-    # def test_user(self):
-    #     params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
-    #     self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
-    #     params = {'user': ['user1', 'user2']}
-    #     self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+    def test_user(self):
+        params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'user': ['user1', 'user2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
     def test_user_name(self):
         params = {'user_name': ['user1', 'user2']}

+ 18 - 0
netbox/extras/tests/test_tags.py

@@ -59,3 +59,21 @@ class TaggedItemTest(APITestCase):
             sorted([t.name for t in site.tags.all()]),
             sorted(["Foo", "Bar", "New Tag"])
         )
+
+    def test_clear_tagged_item(self):
+        site = Site.objects.create(
+            name='Test Site',
+            slug='test-site'
+        )
+        site.tags.add("Foo", "Bar", "Baz")
+        data = {
+            'tags': []
+        }
+        self.add_permissions('dcim.change_site')
+        url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
+
+        response = self.client.patch(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(len(response.data['tags']), 0)
+        site = Site.objects.get(pk=response.data['id'])
+        self.assertEqual(len(site.tags.all()), 0)

+ 2 - 2
netbox/extras/views.py

@@ -315,7 +315,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
     Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
     """
     def get_required_permission(self):
-        return 'extras.view_reportresult'
+        return 'extras.view_report'
 
     def get(self, request):
 
@@ -347,7 +347,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
     Display a single Report and its associated JobResult (if any).
     """
     def get_required_permission(self):
-        return 'extras.view_reportresult'
+        return 'extras.view_report'
 
     def get(self, request, module, name):
 

+ 4 - 9
netbox/ipam/forms.py

@@ -641,11 +641,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
                 self.initial['primary_for_parent'] = True
 
     def clean(self):
-        super().clean()
 
         # Cannot select both a device interface and a VM interface
         if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
             raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface")
+        self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
 
         # Primary IP assignment is only available if an interface has been assigned.
         interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
@@ -655,26 +655,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
             )
 
     def save(self, *args, **kwargs):
-
-        # Set assigned object
-        interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
-        if interface:
-            self.instance.assigned_object = interface
-
         ipaddress = super().save(*args, **kwargs)
 
         # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
+        interface = self.instance.assigned_object
         if interface and self.cleaned_data['primary_for_parent']:
             if ipaddress.address.version == 4:
                 interface.parent.primary_ip4 = ipaddress
             else:
-                interface.primary_ip6 = ipaddress
+                interface.parent.primary_ip6 = ipaddress
             interface.parent.save()
         elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
             interface.parent.primary_ip4 = None
             interface.parent.save()
         elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress:
-            interface.parent.primary_ip4 = None
+            interface.parent.primary_ip6 = None
             interface.parent.save()
 
         return ipaddress

+ 14 - 19
netbox/ipam/models.py

@@ -707,30 +707,18 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
                     })
 
         # Check for primary IP assignment that doesn't match the assigned device/VM
-        if self.pk and type(self.assigned_object) is Interface:
+        if self.pk:
             device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
             if device:
-                if self.assigned_object is None:
+                if getattr(self.assigned_object, 'device', None) != device:
                     raise ValidationError({
-                        'interface': f"IP address is primary for device {device} but not assigned to an interface"
+                        'interface': f"IP address is primary for device {device} but not assigned to it!"
                     })
-                elif self.assigned_object.device != device:
-                    raise ValidationError({
-                        'interface': f"IP address is primary for device {device} but assigned to "
-                                     f"{self.assigned_object.device} ({self.assigned_object})"
-                    })
-        elif self.pk and type(self.assigned_object) is VMInterface:
             vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
             if vm:
-                if self.assigned_object is None:
-                    raise ValidationError({
-                        'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an "
-                                       f"interface"
-                    })
-                elif self.assigned_object.virtual_machine != vm:
+                if getattr(self.assigned_object, 'virtual_machine', None) != vm:
                     raise ValidationError({
-                        'vminterface': f"IP address is primary for virtual machine {vm} but assigned to "
-                                       f"{self.assigned_object.virtual_machine} ({self.assigned_object})"
+                        'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to it!"
                     })
 
         # Validate IP status selection
@@ -973,13 +961,20 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
     def get_status_class(self):
         return self.STATUS_CLASS_MAP[self.status]
 
-    def get_members(self):
-        # Return all interfaces assigned to this VLAN
+    def get_interfaces(self):
+        # Return all device interfaces assigned to this VLAN
         return Interface.objects.filter(
             Q(untagged_vlan_id=self.pk) |
             Q(tagged_vlans=self.pk)
         ).distinct()
 
+    def get_vminterfaces(self):
+        # Return all VM interfaces assigned to this VLAN
+        return VMInterface.objects.filter(
+            Q(untagged_vlan_id=self.pk) |
+            Q(tagged_vlans=self.pk)
+        ).distinct()
+
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Service(ChangeLoggedModel, CustomFieldModel):

+ 25 - 10
netbox/ipam/tables.py

@@ -4,6 +4,7 @@ from django_tables2.utils import Accessor
 from dcim.models import Interface
 from tenancy.tables import COL_TENANT
 from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn
+from virtualization.models import VMInterface
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
 RIR_UTILIZATION = """
@@ -124,9 +125,11 @@ VLANGROUP_ADD_VLAN = """
 {% endwith %}
 """
 
-VLAN_MEMBER_UNTAGGED = """
+VLAN_MEMBER_TAGGED = """
 {% if record.untagged_vlan_id == vlan.pk %}
-    <i class="glyphicon glyphicon-ok">
+    <span class="text-danger"><i class="fa fa-close"></i></span>
+{% else %}
+    <span class="text-success"><i class="fa fa-check"></i></span>
 {% endif %}
 """
 
@@ -415,7 +418,7 @@ class IPAddressDetailTable(IPAddressTable):
     tenant = tables.TemplateColumn(
         template_code=COL_TENANT
     )
-    assigned = tables.BooleanColumn(
+    assigned = BooleanColumn(
         accessor='assigned_object_id',
         verbose_name='Assigned'
     )
@@ -553,15 +556,15 @@ class VLANDetailTable(VLANTable):
         default_columns = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
 
 
-class VLANMemberTable(BaseTable):
-    parent = tables.LinkColumn(
-        order_by=['device', 'virtual_machine']
-    )
+class VLANMembersTable(BaseTable):
+    """
+    Base table for Interface and VMInterface assignments
+    """
     name = tables.LinkColumn(
         verbose_name='Interface'
     )
-    untagged = tables.TemplateColumn(
-        template_code=VLAN_MEMBER_UNTAGGED,
+    tagged = tables.TemplateColumn(
+        template_code=VLAN_MEMBER_TAGGED,
         orderable=False
     )
     actions = tables.TemplateColumn(
@@ -570,9 +573,21 @@ class VLANMemberTable(BaseTable):
         verbose_name=''
     )
 
+
+class VLANDevicesTable(VLANMembersTable):
+    device = tables.LinkColumn()
+
     class Meta(BaseTable.Meta):
         model = Interface
-        fields = ('parent', 'name', 'untagged', 'actions')
+        fields = ('device', 'name', 'tagged', 'actions')
+
+
+class VLANVirtualMachinesTable(VLANMembersTable):
+    virtual_machine = tables.LinkColumn()
+
+    class Meta(BaseTable.Meta):
+        model = VMInterface
+        fields = ('virtual_machine', 'name', 'tagged', 'actions')
 
 
 class InterfaceVLANTable(BaseTable):

+ 2 - 1
netbox/ipam/urls.py

@@ -90,7 +90,8 @@ urlpatterns = [
     path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
     path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
     path('vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'),
-    path('vlans/<int:pk>/members/', views.VLANMembersView.as_view(), name='vlan_members'),
+    path('vlans/<int:pk>/interfaces/', views.VLANInterfacesView.as_view(), name='vlan_interfaces'),
+    path('vlans/<int:pk>/vm-interfaces/', views.VLANVMInterfacesView.as_view(), name='vlan_vminterfaces'),
     path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
     path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
     path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),

+ 25 - 6
netbox/ipam/views.py

@@ -749,15 +749,34 @@ class VLANView(ObjectView):
         })
 
 
-class VLANMembersView(ObjectView):
+class VLANInterfacesView(ObjectView):
     queryset = VLAN.objects.all()
 
     def get(self, request, pk):
-
         vlan = get_object_or_404(self.queryset, pk=pk)
-        members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine')
+        interfaces = vlan.get_interfaces().prefetch_related('device')
+        members_table = tables.VLANDevicesTable(interfaces)
+
+        paginate = {
+            'paginator_class': EnhancedPaginator,
+            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+        }
+        RequestConfig(request, paginate).configure(members_table)
+
+        return render(request, 'ipam/vlan_interfaces.html', {
+            'vlan': vlan,
+            'members_table': members_table,
+            'active_tab': 'interfaces',
+        })
+
 
-        members_table = tables.VLANMemberTable(members)
+class VLANVMInterfacesView(ObjectView):
+    queryset = VLAN.objects.all()
+
+    def get(self, request, pk):
+        vlan = get_object_or_404(self.queryset, pk=pk)
+        interfaces = vlan.get_vminterfaces().prefetch_related('virtual_machine')
+        members_table = tables.VLANVirtualMachinesTable(interfaces)
 
         paginate = {
             'paginator_class': EnhancedPaginator,
@@ -765,10 +784,10 @@ class VLANMembersView(ObjectView):
         }
         RequestConfig(request, paginate).configure(members_table)
 
-        return render(request, 'ipam/vlan_members.html', {
+        return render(request, 'ipam/vlan_vminterfaces.html', {
             'vlan': vlan,
             'members_table': members_table,
-            'active_tab': 'members',
+            'active_tab': 'vminterfaces',
         })
 
 

+ 3 - 2
netbox/netbox/configuration.example.py

@@ -33,7 +33,6 @@ REDIS = {
         # 'SENTINEL_SERVICE': 'netbox',
         'PASSWORD': '',
         'DATABASE': 0,
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
     },
     'caching': {
@@ -44,7 +43,6 @@ REDIS = {
         # 'SENTINEL_SERVICE': 'netbox',
         'PASSWORD': '',
         'DATABASE': 1,
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
     }
 }
@@ -232,6 +230,9 @@ RELEASE_CHECK_URL = None
 # this setting is derived from the installed location.
 # REPORTS_ROOT = '/opt/netbox/netbox/reports'
 
+# Maximum execution time for background tasks, in seconds.
+RQ_DEFAULT_TIMEOUT = 300
+
 # The file path where custom scripts will be stored. A trailing slash is not needed. Note that the default value of
 # this setting is derived from the installed location.
 # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'

+ 0 - 2
netbox/netbox/configuration.testing.py

@@ -24,7 +24,6 @@ REDIS = {
         'PORT': 6379,
         'PASSWORD': '',
         'DATABASE': 0,
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
     },
     'caching': {
@@ -32,7 +31,6 @@ REDIS = {
         'PORT': 6379,
         'PASSWORD': '',
         'DATABASE': 1,
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
     }
 }

+ 9 - 5
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '2.9.4-dev'
+VERSION = '2.10-beta1'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -110,6 +110,7 @@ REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_U
 RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
 RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600)
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
+RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
 SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
 SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
@@ -132,6 +133,7 @@ if RELEASE_CHECK_URL:
 if RELEASE_CHECK_TIMEOUT < 3600:
     raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)")
 
+
 #
 # Database
 #
@@ -201,10 +203,13 @@ TASKS_REDIS_USING_SENTINEL = all([
     len(TASKS_REDIS_SENTINELS) > 0
 ])
 TASKS_REDIS_SENTINEL_SERVICE = TASKS_REDIS.get('SENTINEL_SERVICE', 'default')
+TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10)
 TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
 TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
-TASKS_REDIS_DEFAULT_TIMEOUT = TASKS_REDIS.get('DEFAULT_TIMEOUT', 300)
 TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
+# TODO: Remove in v2.10 (see #5171)
+if 'DEFAULT_TIMEOUT' in TASKS_REDIS:
+    warnings.warn('DEFAULT_TIMEOUT is no longer supported under REDIS configuration. Set RQ_DEFAULT_TIMEOUT instead.')
 
 # Caching
 if 'caching' not in REDIS:
@@ -222,7 +227,6 @@ CACHING_REDIS_USING_SENTINEL = all([
 CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default')
 CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
 CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0)
-CACHING_REDIS_DEFAULT_TIMEOUT = CACHING_REDIS.get('DEFAULT_TIMEOUT', 300)
 CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
 
 
@@ -538,7 +542,7 @@ if TASKS_REDIS_USING_SENTINEL:
         'PASSWORD': TASKS_REDIS_PASSWORD,
         'SOCKET_TIMEOUT': None,
         'CONNECTION_KWARGS': {
-            'socket_connect_timeout': TASKS_REDIS_DEFAULT_TIMEOUT
+            'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
         },
     }
 else:
@@ -547,8 +551,8 @@ else:
         'PORT': TASKS_REDIS_PORT,
         'DB': TASKS_REDIS_DATABASE,
         'PASSWORD': TASKS_REDIS_PASSWORD,
-        'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT,
         'SSL': TASKS_REDIS_SSL,
+        'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
     }
 
 RQ_QUEUES = {

+ 2 - 5
netbox/templates/dcim/device.html

@@ -11,11 +11,8 @@
     <div class="row noprint">
         <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
-            <li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li>
-            {% if device.rack %}
-                <li><a href="{% url 'dcim:rack_list' %}?site={{ device.site.slug }}">Racks</a></li>
-                <li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li>
-            {% endif %}
+            <li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
+            <li><a href="{% url 'dcim:device_list' %}?site={{ device.site.slug }}">{{ device.site }}</a></li>
             {% if device.parent_bay %}
                 <li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
                 <li>{{ device.parent_bay }}</li>

+ 1 - 0
netbox/templates/dcim/device_edit.html

@@ -23,6 +23,7 @@
         <div class="panel-body">
             {% render_field form.region %}
             {% render_field form.site %}
+            {% render_field form.rack_group %}
             {% render_field form.rack %}
             {% if obj.device_type.is_child_device and obj.parent_bay %}
                 <div class="form-group">

+ 10 - 1
netbox/templates/dcim/rack.html

@@ -11,6 +11,12 @@
             <ol class="breadcrumb">
                 <li><a href="{% url 'dcim:rack_list' %}">Racks</a></li>
                 <li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li>
+                {% if rack.group %}
+                    {% for group in rack.group.get_ancestors %}
+                        <li><a href="{{ group.get_absolute_url }}">{{ group }}</a></li>
+                    {% endfor %}
+                    <li><a href="{{ rack.group.get_absolute_url }}">{{ rack.group }}</a></li>
+                {% endif %}
                 <li>{{ rack }}</li>
             </ol>
         </div>
@@ -87,7 +93,10 @@
                     <td>Group</td>
                     <td>
                         {% if rack.group %}
-                            <a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}&group={{ rack.group.slug }}">{{ rack.group }}</a>
+                            {% for group in rack.group.get_ancestors %}
+                                <a href="{{ group.get_absolute_url }}">{{ group }}</a> <i class="fa fa-caret-right"></i>
+                            {% endfor %}
+                            <a href="{{ rack.group.get_absolute_url }}">{{ rack.group }}</a>
                         {% else %}
                             <span class="text-muted">None</span>
                         {% endif %}

+ 9 - 3
netbox/templates/dcim/rack_elevation_list.html

@@ -3,12 +3,18 @@
 {% load static %}
 
 {% block content %}
-<div class="btn-group pull-right noprint" role="group">
+<div class="btn-toolbar pull-right noprint" role="toolbar">
     <button class="btn btn-default toggle-images" selected="selected">
         <span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
     </button>
-    <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
-    <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
+    <div class="btn-group" role="group">
+        <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
+        <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
+    </div>
+    <div class="btn-group" role="group">
+        <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request %}" class="btn btn-default{% if not reverse %} active{% endif %}">Normal</a>
+        <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse='true' %}" class="btn btn-default{% if reverse %} active{% endif %}">Reversed</a>
+    </div>
 </div>
 <h1>{% block title %}Rack Elevations{% endblock %}</h1>
 <div class="row">

+ 3 - 3
netbox/templates/dcim/site.html

@@ -12,7 +12,7 @@
             <ol class="breadcrumb">
                 <li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
                 {% if site.region %}
-                    {% for region in site.region.get_ancestors.unrestricted %}
+                    {% for region in site.region.get_ancestors %}
                         <li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
                     {% endfor %}
                     <li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
@@ -80,7 +80,7 @@
                     <td>Region</td>
                     <td>
                         {% if site.region %}
-                            {% for region in site.region.get_ancestors.unrestricted %}
+                            {% for region in site.region.get_ancestors %}
                                 <a href="{{ region.get_absolute_url }}">{{ region }}</a>
                                 <i class="fa fa-angle-right"></i>
                             {% endfor %}
@@ -249,7 +249,7 @@
             <table class="table table-hover panel-body">
                 {% for rg in rack_groups %}
                     <tr>
-                        <td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
+                        <td style="padding-left: {{ rg.level }}8px"><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
                         <td>{{ rg.rack_count }}</td>
                         <td class="text-right noprint">
                             <a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations">

+ 8 - 7
netbox/templates/exceptions/import_error.html

@@ -5,14 +5,15 @@
         A module import error occurred during this request. Common causes include the following:
     </p>
     <p>
-        <i class="fa fa-warning"></i> <strong>Missing required packages</strong> - This installation of NetBox might be missing one or more required
-        Python packages. These packages are listed in <code>requirements.txt</code> and are normally installed as part
-        of the installation or upgrade process. To verify installed packages, run <code>pip freeze</code> from the
-        console and compare the output to the list of required packages.
+        <i class="fa fa-warning"></i> <strong>Missing required packages</strong> - This installation of NetBox might be
+        missing one or more required Python packages. These packages are listed in <code>requirements.txt</code> and
+        <code>local_requirements.txt</code>, and are normally installed as part of the installation or upgrade process.
+        To verify installed packages, run <code>pip freeze</code> from the console and compare the output to the list of
+        required packages.
     </p>
     <p>
-        <i class="fa fa-warning"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation has recently been upgraded,
-        check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This ensures that the new code is
-        running.
+        <i class="fa fa-warning"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation
+        has recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This
+        ensures that the new code is running.
     </p>
 {% endblock %}

+ 2 - 2
netbox/templates/home.html

@@ -276,7 +276,7 @@
             <div class="panel-heading">
                 <strong>Reports</strong>
             </div>
-            {% if report_results and perms.extras.view_reportresult %}
+            {% if report_results and perms.extras.view_report %}
                 <table class="table table-hover panel-body">
                     {% for result in report_results %}
                         <tr>
@@ -285,7 +285,7 @@
                         </tr>
                     {% endfor %}
                 </table>
-            {% elif perms.extras.view_reportresult %}
+            {% elif perms.extras.view_report %}
                 <div class="panel-body text-muted">
                     None found
                 </div>

+ 1 - 1
netbox/templates/inc/nav_menu.html

@@ -518,7 +518,7 @@
                         <li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
                             <a href="{% url 'extras:script_list' %}">Scripts</a>
                         </li>
-                        <li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}>
+                        <li{% if not perms.extras.view_report %} class="disabled"{% endif %}>
                             <a href="{% url 'extras:report_list' %}">Reports</a>
                         </li>
                     </ul>

+ 5 - 2
netbox/templates/ipam/vlan.html

@@ -52,8 +52,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a>
         </li>
-        <li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}>
-            <a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a>
+        <li role="presentation"{% if active_tab == 'interfaces' %} class="active"{% endif %}>
+            <a href="{% url 'ipam:vlan_interfaces' pk=vlan.pk %}">Device Interfaces <span class="badge">{{ vlan.get_interfaces.count }}</span></a>
+        </li>
+        <li role="presentation"{% if active_tab == 'vminterfaces' %} class="active"{% endif %}>
+            <a href="{% url 'ipam:vlan_vminterfaces' pk=vlan.pk %}">VM Interfaces <span class="badge">{{ vlan.get_vminterfaces.count }}</span></a>
         </li>
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>

+ 1 - 3
netbox/templates/ipam/vlan_members.html → netbox/templates/ipam/vlan_interfaces.html

@@ -1,11 +1,9 @@
 {% extends 'ipam/vlan.html' %}
 
-{% block title %}{{ block.super }} - Members{% endblock %}
-
 {% block content %}
     <div class="row">
         <div class="col-md-12">
-            {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %}
+            {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Device Interfaces' parent=vlan %}
         </div>
     </div>
 {% endblock %}

+ 9 - 0
netbox/templates/ipam/vlan_vminterfaces.html

@@ -0,0 +1,9 @@
+{% extends 'ipam/vlan.html' %}
+
+{% block content %}
+    <div class="row">
+        <div class="col-md-12">
+            {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Virtual Machine Interfaces' parent=vlan %}
+        </div>
+    </div>
+{% endblock %}

+ 1 - 1
netbox/utilities/forms/widgets.py

@@ -141,7 +141,7 @@ class APISelect(SelectWithDisabled):
         key = f'data-query-param-{name}'
 
         values = json.loads(self.attrs.get(key, '[]'))
-        if type(value) is list:
+        if type(value) in (list, tuple):
             values.extend([str(v) for v in value])
         else:
             values.append(str(value))

+ 4 - 4
netbox/utilities/tables.py

@@ -114,12 +114,12 @@ class BooleanColumn(tables.Column):
     character.
     """
     def render(self, value):
-        if value is True:
+        if value:
             rendered = '<span class="text-success"><i class="fa fa-check"></i></span>'
-        elif value is False:
-            rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
-        else:
+        elif value is None:
             rendered = '<span class="text-muted">&mdash;</span>'
+        else:
+            rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
         return mark_safe(rendered)
 
 

+ 1 - 1
netbox/utilities/testing/api.py

@@ -267,7 +267,7 @@ class APIViewTestCases:
             response = self.client.patch(url, update_data, format='json', **self.header)
             self.assertHttpStatus(response, status.HTTP_200_OK)
             instance.refresh_from_db()
-            self.assertInstanceEqual(instance, self.update_data, api=True)
+            self.assertInstanceEqual(instance, update_data, api=True)
 
         def test_bulk_update_objects(self):
             """

+ 1 - 1
netbox/utilities/views.py

@@ -936,7 +936,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
                                 # ManyToManyFields
                                 elif isinstance(model_field, ManyToManyField):
-                                    if form.cleaned_data[name].count() > 0:
+                                    if form.cleaned_data[name]:
                                         getattr(obj, name).set(form.cleaned_data[name])
                                 # Normal fields
                                 elif form.cleaned_data[name] not in (None, ''):

+ 1 - 0
netbox/virtualization/migrations/0016_replicate_interfaces.py

@@ -83,6 +83,7 @@ def replicate_interfaces(apps, schema_editor):
 class Migration(migrations.Migration):
 
     dependencies = [
+        ('dcim', '0082_3569_interface_fields'),
         ('ipam', '0037_ipaddress_assignment'),
         ('virtualization', '0015_vminterface'),
     ]