Jeremy Stretch 5 лет назад
Родитель
Сommit
9b16d6df2e
46 измененных файлов с 343 добавлено и 129 удалено
  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
 ## SCRIPTS_ROOT
 
 
 Default: `$INSTALL_ROOT/netbox/scripts/`
 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)
 * `PORT` - TCP port of the Redis service; leave blank for default port (6379)
 * `PASSWORD` - Redis password (if set)
 * `PASSWORD` - Redis password (if set)
 * `DATABASE` - Numeric database ID
 * `DATABASE` - Numeric database ID
-* `DEFAULT_TIMEOUT` - Connection timeout in seconds
 * `SSL` - Use SSL connection to Redis
 * `SSL` - Use SSL connection to Redis
 
 
 An example configuration is provided below:
 An example configuration is provided below:
@@ -77,7 +76,6 @@ REDIS = {
         'PORT': 1234,
         'PORT': 1234,
         'PASSWORD': 'foobar',
         'PASSWORD': 'foobar',
         'DATABASE': 0,
         'DATABASE': 0,
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
         'SSL': False,
     },
     },
     'caching': {
     'caching': {
@@ -85,7 +83,6 @@ REDIS = {
         'PORT': 6379,
         'PORT': 6379,
         'PASSWORD': '',
         'PASSWORD': '',
         'DATABASE': 1,
         'DATABASE': 1,
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
         '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 
 * `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
 of the Redis server and port for each sentinel instance to connect to
 * `SENTINEL_SERVICE`: Name of the master / service to connect to
 * `SENTINEL_SERVICE`: Name of the master / service to connect to
+* `SENTINEL_TIMEOUT`: Connection timeout, in seconds
 
 
 Example:
 Example:
 
 
@@ -117,9 +115,9 @@ REDIS = {
     'tasks': {
     'tasks': {
         'SENTINELS': [('mysentinel.redis.example.com', 6379)],
         'SENTINELS': [('mysentinel.redis.example.com', 6379)],
         'SENTINEL_SERVICE': 'netbox',
         'SENTINEL_SERVICE': 'netbox',
+        'SENTINEL_TIMEOUT': 10,
         'PASSWORD': '',
         'PASSWORD': '',
         'DATABASE': 0,
         'DATABASE': 0,
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
         'SSL': False,
     },
     },
     'caching': {
     'caching': {
@@ -130,7 +128,6 @@ REDIS = {
         'SENTINEL_SERVICE': 'netbox',
         'SENTINEL_SERVICE': 'netbox',
         'PASSWORD': '',
         'PASSWORD': '',
         'DATABASE': 1,
         'DATABASE': 1,
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
         'SSL': False,
     }
     }
 }
 }

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

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

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

@@ -1,3 +1,3 @@
 ## Rear Port Templates
 ## 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
 # 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)
 ## v2.9.3 (2020-09-04)
 
 
 ### Enhancements
 ### 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 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.)
 * 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}`.
 * `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
 ### 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_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'
             'region_id': '$region'
         }
         }
     )
     )
+    rack_group = DynamicModelChoiceField(
+        queryset=RackGroup.objects.all(),
+        required=False,
+        display_field='display_name',
+        query_params={
+            'site_id': '$site'
+        }
+    )
     rack = DynamicModelChoiceField(
     rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         required=False,
         required=False,
         display_field='display_name',
         display_field='display_name',
         query_params={
         query_params={
-            'site_id': '$site'
+            'site_id': '$site',
+            'group_id': '$rack_group',
         }
         }
     )
     )
     position = forms.TypedChoiceField(
     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):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('dcim', '0115_rackreservation_order'),
+        ('dcim', '0116_rearport_max_positions'),
     ]
     ]
 
 
     operations = [
     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):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('dcim', '0116_custom_field_data'),
+        ('dcim', '0117_custom_field_data'),
     ]
     ]
 
 
     operations = [
     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):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('dcim', '0117_inventoryitem_mptt'),
+        ('dcim', '0118_inventoryitem_mptt'),
     ]
     ]
 
 
     operations = [
     operations = [

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

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

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

@@ -811,7 +811,10 @@ class FrontPort(CableTermination, ComponentModel):
     )
     )
     rear_port_position = models.PositiveSmallIntegerField(
     rear_port_position = models.PositiveSmallIntegerField(
         default=1,
         default=1,
-        validators=[MinValueValidator(1), MaxValueValidator(64)]
+        validators=[
+            MinValueValidator(REARPORT_POSITIONS_MIN),
+            MaxValueValidator(REARPORT_POSITIONS_MAX)
+        ]
     )
     )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
@@ -866,7 +869,10 @@ class RearPort(CableTermination, ComponentModel):
     )
     )
     positions = models.PositiveSmallIntegerField(
     positions = models.PositiveSmallIntegerField(
         default=1,
         default=1,
-        validators=[MinValueValidator(1), MaxValueValidator(64)]
+        validators=[
+            MinValueValidator(REARPORT_POSITIONS_MIN),
+            MaxValueValidator(REARPORT_POSITIONS_MAX)
+        ]
     )
     )
     tags = TaggableManager(through=TaggedItem)
     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(),
             '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(),
             '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', {
         return render(request, 'dcim/site.html', {
             'site': site,
             'site': site,
@@ -307,6 +311,11 @@ class RackElevationListView(ObjectListView):
         racks = filters.RackFilterSet(request.GET, self.queryset).qs
         racks = filters.RackFilterSet(request.GET, self.queryset).qs
         total_count = racks.count()
         total_count = racks.count()
 
 
+        # Determine ordering
+        reverse = bool(request.GET.get('reverse', False))
+        if reverse:
+            racks = racks.reverse()
+
         # Pagination
         # Pagination
         per_page = request.GET.get('per_page', settings.PAGINATE_COUNT)
         per_page = request.GET.get('per_page', settings.PAGINATE_COUNT)
         page_number = request.GET.get('page', 1)
         page_number = request.GET.get('page', 1)
@@ -327,6 +336,7 @@ class RackElevationListView(ObjectListView):
             'paginator': paginator,
             'paginator': paginator,
             'page': page,
             'page': page,
             'total_count': total_count,
             'total_count': total_count,
+            'reverse': reverse,
             'rack_face': rack_face,
             'rack_face': rack_face,
             'filter_form': forms.RackElevationFilterForm(request.GET),
             'filter_form': forms.RackElevationFilterForm(request.GET),
         })
         })
@@ -405,7 +415,6 @@ class RackReservationListView(ObjectListView):
     filterset = filters.RackReservationFilterSet
     filterset = filters.RackReservationFilterSet
     filterset_form = forms.RackReservationFilterForm
     filterset_form = forms.RackReservationFilterForm
     table = tables.RackReservationTable
     table = tables.RackReservationTable
-    action_buttons = ('export',)
 
 
 
 
 class RackReservationView(ObjectView):
 class RackReservationView(ObjectView):

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

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

+ 11 - 0
netbox/extras/filters.py

@@ -1,4 +1,5 @@
 import django_filters
 import django_filters
+from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.db.models import Q
 
 
@@ -236,6 +237,16 @@ class ObjectChangeFilterSet(BaseFilterSet):
     )
     )
     time = django_filters.DateTimeFromToRangeFilter()
     time = django_filters.DateTimeFromToRangeFilter()
     changed_object_type = ContentTypeFilter()
     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:
     class Meta:
         model = ObjectChange
         model = ObjectChange

+ 2 - 1
netbox/extras/forms.py

@@ -353,10 +353,11 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
         required=False,
         required=False,
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
-    user = DynamicModelMultipleChoiceField(
+    user_id = DynamicModelMultipleChoiceField(
         queryset=User.objects.all(),
         queryset=User.objects.all(),
         required=False,
         required=False,
         display_field='username',
         display_field='username',
+        label='User',
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url='/api/users/users/',
             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.core.management.base import BaseCommand
 from django.utils import timezone
 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):
 class Command(BaseCommand):
@@ -20,15 +25,33 @@ class Command(BaseCommand):
             for report in report_list:
             for report in report_list:
                 if module_name in options['reports'] or report.full_name in options['reports']:
                 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(
                     self.stdout.write(
                         "[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
                         "[{:%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
                     # 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(
                         self.stdout.write(
                             "\t{}: {} success, {} info, {} warning, {} failure".format(
                             "\t{}: {} success, {} info, {} warning, {} failure".format(
                                 test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
                                 test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
@@ -37,6 +60,9 @@ class Command(BaseCommand):
                     self.stdout.write(
                     self.stdout.write(
                         "[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
                         "[{:%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
         # Wrap things up
         self.stdout.write(
         self.stdout.write(

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

@@ -55,7 +55,7 @@ class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
         ('circuits', '0020_custom_field_data'),
         ('circuits', '0020_custom_field_data'),
-        ('dcim', '0116_custom_field_data'),
+        ('dcim', '0117_custom_field_data'),
         ('extras', '0050_customfield_add_choices'),
         ('extras', '0050_customfield_add_choices'),
         ('ipam', '0038_custom_field_data'),
         ('ipam', '0038_custom_field_data'),
         ('secrets', '0010_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]}
         params = {'id': self.queryset.values_list('pk', flat=True)[:3]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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):
     def test_user_name(self):
         params = {'user_name': ['user1', 'user2']}
         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([t.name for t in site.tags.all()]),
             sorted(["Foo", "Bar", "New Tag"])
             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.
     Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
     """
     """
     def get_required_permission(self):
     def get_required_permission(self):
-        return 'extras.view_reportresult'
+        return 'extras.view_report'
 
 
     def get(self, request):
     def get(self, request):
 
 
@@ -347,7 +347,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
     Display a single Report and its associated JobResult (if any).
     Display a single Report and its associated JobResult (if any).
     """
     """
     def get_required_permission(self):
     def get_required_permission(self):
-        return 'extras.view_reportresult'
+        return 'extras.view_report'
 
 
     def get(self, request, module, name):
     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
                 self.initial['primary_for_parent'] = True
 
 
     def clean(self):
     def clean(self):
-        super().clean()
 
 
         # Cannot select both a device interface and a VM interface
         # Cannot select both a device interface and a VM interface
         if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
         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")
             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.
         # Primary IP assignment is only available if an interface has been assigned.
         interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
         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):
     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)
         ipaddress = super().save(*args, **kwargs)
 
 
         # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
         # 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 interface and self.cleaned_data['primary_for_parent']:
             if ipaddress.address.version == 4:
             if ipaddress.address.version == 4:
                 interface.parent.primary_ip4 = ipaddress
                 interface.parent.primary_ip4 = ipaddress
             else:
             else:
-                interface.primary_ip6 = ipaddress
+                interface.parent.primary_ip6 = ipaddress
             interface.parent.save()
             interface.parent.save()
         elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
         elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
             interface.parent.primary_ip4 = None
             interface.parent.primary_ip4 = None
             interface.parent.save()
             interface.parent.save()
         elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress:
         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()
             interface.parent.save()
 
 
         return ipaddress
         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
         # 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()
             device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
             if device:
             if device:
-                if self.assigned_object is None:
+                if getattr(self.assigned_object, 'device', None) != device:
                     raise ValidationError({
                     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()
             vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
             if vm:
             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({
                     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
         # Validate IP status selection
@@ -973,13 +961,20 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
     def get_status_class(self):
     def get_status_class(self):
         return self.STATUS_CLASS_MAP[self.status]
         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(
         return Interface.objects.filter(
             Q(untagged_vlan_id=self.pk) |
             Q(untagged_vlan_id=self.pk) |
             Q(tagged_vlans=self.pk)
             Q(tagged_vlans=self.pk)
         ).distinct()
         ).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')
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Service(ChangeLoggedModel, CustomFieldModel):
 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 dcim.models import Interface
 from tenancy.tables import COL_TENANT
 from tenancy.tables import COL_TENANT
 from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn
 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
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
 
 RIR_UTILIZATION = """
 RIR_UTILIZATION = """
@@ -124,9 +125,11 @@ VLANGROUP_ADD_VLAN = """
 {% endwith %}
 {% endwith %}
 """
 """
 
 
-VLAN_MEMBER_UNTAGGED = """
+VLAN_MEMBER_TAGGED = """
 {% if record.untagged_vlan_id == vlan.pk %}
 {% 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 %}
 {% endif %}
 """
 """
 
 
@@ -415,7 +418,7 @@ class IPAddressDetailTable(IPAddressTable):
     tenant = tables.TemplateColumn(
     tenant = tables.TemplateColumn(
         template_code=COL_TENANT
         template_code=COL_TENANT
     )
     )
-    assigned = tables.BooleanColumn(
+    assigned = BooleanColumn(
         accessor='assigned_object_id',
         accessor='assigned_object_id',
         verbose_name='Assigned'
         verbose_name='Assigned'
     )
     )
@@ -553,15 +556,15 @@ class VLANDetailTable(VLANTable):
         default_columns = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
         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(
     name = tables.LinkColumn(
         verbose_name='Interface'
         verbose_name='Interface'
     )
     )
-    untagged = tables.TemplateColumn(
-        template_code=VLAN_MEMBER_UNTAGGED,
+    tagged = tables.TemplateColumn(
+        template_code=VLAN_MEMBER_TAGGED,
         orderable=False
         orderable=False
     )
     )
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
@@ -570,9 +573,21 @@ class VLANMemberTable(BaseTable):
         verbose_name=''
         verbose_name=''
     )
     )
 
 
+
+class VLANDevicesTable(VLANMembersTable):
+    device = tables.LinkColumn()
+
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Interface
         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):
 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/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
     path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
     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>/', 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>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
     path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
     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}),
     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()
     queryset = VLAN.objects.all()
 
 
     def get(self, request, pk):
     def get(self, request, pk):
-
         vlan = get_object_or_404(self.queryset, pk=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 = {
         paginate = {
             'paginator_class': EnhancedPaginator,
             'paginator_class': EnhancedPaginator,
@@ -765,10 +784,10 @@ class VLANMembersView(ObjectView):
         }
         }
         RequestConfig(request, paginate).configure(members_table)
         RequestConfig(request, paginate).configure(members_table)
 
 
-        return render(request, 'ipam/vlan_members.html', {
+        return render(request, 'ipam/vlan_vminterfaces.html', {
             'vlan': vlan,
             'vlan': vlan,
             'members_table': members_table,
             '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',
         # 'SENTINEL_SERVICE': 'netbox',
         'PASSWORD': '',
         'PASSWORD': '',
         'DATABASE': 0,
         'DATABASE': 0,
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
         'SSL': False,
     },
     },
     'caching': {
     'caching': {
@@ -44,7 +43,6 @@ REDIS = {
         # 'SENTINEL_SERVICE': 'netbox',
         # 'SENTINEL_SERVICE': 'netbox',
         'PASSWORD': '',
         'PASSWORD': '',
         'DATABASE': 1,
         'DATABASE': 1,
-        'DEFAULT_TIMEOUT': 300,
         'SSL': False,
         'SSL': False,
     }
     }
 }
 }
@@ -232,6 +230,9 @@ RELEASE_CHECK_URL = None
 # this setting is derived from the installed location.
 # this setting is derived from the installed location.
 # REPORTS_ROOT = '/opt/netbox/netbox/reports'
 # 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
 # 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.
 # this setting is derived from the installed location.
 # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
 # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'

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

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

+ 9 - 5
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '2.9.4-dev'
+VERSION = '2.10-beta1'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 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_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
 RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600)
 RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600)
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 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('/')
 SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
 SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
 SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
 SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
 SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
@@ -132,6 +133,7 @@ if RELEASE_CHECK_URL:
 if RELEASE_CHECK_TIMEOUT < 3600:
 if RELEASE_CHECK_TIMEOUT < 3600:
     raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)")
     raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)")
 
 
+
 #
 #
 # Database
 # Database
 #
 #
@@ -201,10 +203,13 @@ TASKS_REDIS_USING_SENTINEL = all([
     len(TASKS_REDIS_SENTINELS) > 0
     len(TASKS_REDIS_SENTINELS) > 0
 ])
 ])
 TASKS_REDIS_SENTINEL_SERVICE = TASKS_REDIS.get('SENTINEL_SERVICE', 'default')
 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_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
 TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
 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)
 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
 # Caching
 if 'caching' not in REDIS:
 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_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default')
 CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
 CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
 CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0)
 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)
 CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
 
 
 
 
@@ -538,7 +542,7 @@ if TASKS_REDIS_USING_SENTINEL:
         'PASSWORD': TASKS_REDIS_PASSWORD,
         'PASSWORD': TASKS_REDIS_PASSWORD,
         'SOCKET_TIMEOUT': None,
         'SOCKET_TIMEOUT': None,
         'CONNECTION_KWARGS': {
         'CONNECTION_KWARGS': {
-            'socket_connect_timeout': TASKS_REDIS_DEFAULT_TIMEOUT
+            'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
         },
         },
     }
     }
 else:
 else:
@@ -547,8 +551,8 @@ else:
         'PORT': TASKS_REDIS_PORT,
         'PORT': TASKS_REDIS_PORT,
         'DB': TASKS_REDIS_DATABASE,
         'DB': TASKS_REDIS_DATABASE,
         'PASSWORD': TASKS_REDIS_PASSWORD,
         'PASSWORD': TASKS_REDIS_PASSWORD,
-        'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT,
         'SSL': TASKS_REDIS_SSL,
         'SSL': TASKS_REDIS_SSL,
+        'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
     }
     }
 
 
 RQ_QUEUES = {
 RQ_QUEUES = {

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

@@ -11,11 +11,8 @@
     <div class="row noprint">
     <div class="row noprint">
         <div class="col-sm-8 col-md-9">
         <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
         <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 %}
             {% if device.parent_bay %}
                 <li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
                 <li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
                 <li>{{ device.parent_bay }}</li>
                 <li>{{ device.parent_bay }}</li>

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

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

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

@@ -11,6 +11,12 @@
             <ol class="breadcrumb">
             <ol class="breadcrumb">
                 <li><a href="{% url 'dcim:rack_list' %}">Racks</a></li>
                 <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>
                 <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>
                 <li>{{ rack }}</li>
             </ol>
             </ol>
         </div>
         </div>
@@ -87,7 +93,10 @@
                     <td>Group</td>
                     <td>Group</td>
                     <td>
                     <td>
                         {% if rack.group %}
                         {% 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 %}
                         {% else %}
                             <span class="text-muted">None</span>
                             <span class="text-muted">None</span>
                         {% endif %}
                         {% endif %}

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

@@ -3,12 +3,18 @@
 {% load static %}
 {% load static %}
 
 
 {% block content %}
 {% 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">
     <button class="btn btn-default toggle-images" selected="selected">
         <span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
         <span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
     </button>
     </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>
 </div>
 <h1>{% block title %}Rack Elevations{% endblock %}</h1>
 <h1>{% block title %}Rack Elevations{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -12,7 +12,7 @@
             <ol class="breadcrumb">
             <ol class="breadcrumb">
                 <li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
                 <li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
                 {% if site.region %}
                 {% 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>
                         <li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
                     {% endfor %}
                     {% endfor %}
                     <li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
                     <li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
@@ -80,7 +80,7 @@
                     <td>Region</td>
                     <td>Region</td>
                     <td>
                     <td>
                         {% if site.region %}
                         {% 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>
                                 <a href="{{ region.get_absolute_url }}">{{ region }}</a>
                                 <i class="fa fa-angle-right"></i>
                                 <i class="fa fa-angle-right"></i>
                             {% endfor %}
                             {% endfor %}
@@ -249,7 +249,7 @@
             <table class="table table-hover panel-body">
             <table class="table table-hover panel-body">
                 {% for rg in rack_groups %}
                 {% for rg in rack_groups %}
                     <tr>
                     <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>{{ rg.rack_count }}</td>
                         <td class="text-right noprint">
                         <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">
                             <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:
         A module import error occurred during this request. Common causes include the following:
     </p>
     </p>
     <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>
     <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>
     </p>
 {% endblock %}
 {% endblock %}

+ 2 - 2
netbox/templates/home.html

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

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

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

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

@@ -52,8 +52,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a>
             <a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a>
         </li>
         </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>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <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' %}
 {% extends 'ipam/vlan.html' %}
 
 
-{% block title %}{{ block.super }} - Members{% endblock %}
-
 {% block content %}
 {% block content %}
     <div class="row">
     <div class="row">
         <div class="col-md-12">
         <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>
     </div>
     </div>
 {% endblock %}
 {% 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}'
         key = f'data-query-param-{name}'
 
 
         values = json.loads(self.attrs.get(key, '[]'))
         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])
             values.extend([str(v) for v in value])
         else:
         else:
             values.append(str(value))
             values.append(str(value))

+ 4 - 4
netbox/utilities/tables.py

@@ -114,12 +114,12 @@ class BooleanColumn(tables.Column):
     character.
     character.
     """
     """
     def render(self, value):
     def render(self, value):
-        if value is True:
+        if value:
             rendered = '<span class="text-success"><i class="fa fa-check"></i></span>'
             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>'
             rendered = '<span class="text-muted">&mdash;</span>'
+        else:
+            rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
         return mark_safe(rendered)
         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)
             response = self.client.patch(url, update_data, format='json', **self.header)
             self.assertHttpStatus(response, status.HTTP_200_OK)
             self.assertHttpStatus(response, status.HTTP_200_OK)
             instance.refresh_from_db()
             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):
         def test_bulk_update_objects(self):
             """
             """

+ 1 - 1
netbox/utilities/views.py

@@ -936,7 +936,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
 
                                 # ManyToManyFields
                                 # ManyToManyFields
                                 elif isinstance(model_field, ManyToManyField):
                                 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])
                                         getattr(obj, name).set(form.cleaned_data[name])
                                 # Normal fields
                                 # Normal fields
                                 elif form.cleaned_data[name] not in (None, ''):
                                 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):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
+        ('dcim', '0082_3569_interface_fields'),
         ('ipam', '0037_ipaddress_assignment'),
         ('ipam', '0037_ipaddress_assignment'),
         ('virtualization', '0015_vminterface'),
         ('virtualization', '0015_vminterface'),
     ]
     ]