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

Merge pull request #5581 from netbox-community/develop

Release v2.10.3
Jeremy Stretch 5 лет назад
Родитель
Сommit
5a32b9599a
39 измененных файлов с 304 добавлено и 132 удалено
  1. 3 3
      docs/development/getting-started.md
  2. 1 4
      docs/development/release-checklist.md
  3. 1 1
      docs/installation/3-netbox.md
  4. 23 0
      docs/release-notes/version-2.10.md
  5. 2 2
      docs/rest-api/filtering.md
  6. 1 0
      netbox/circuits/api/views.py
  7. 11 0
      netbox/dcim/api/views.py
  8. 26 38
      netbox/dcim/forms.py
  9. 11 0
      netbox/dcim/models/device_component_templates.py
  10. 7 1
      netbox/dcim/models/device_components.py
  11. 5 5
      netbox/dcim/models/devices.py
  12. 1 16
      netbox/dcim/models/racks.py
  13. 43 1
      netbox/dcim/signals.py
  14. 2 1
      netbox/dcim/tables/racks.py
  15. 2 1
      netbox/dcim/tables/sites.py
  16. 4 7
      netbox/dcim/tables/template_code.py
  17. 16 2
      netbox/dcim/tests/test_forms.py
  18. 64 0
      netbox/dcim/tests/test_models.py
  19. 4 5
      netbox/extras/api/views.py
  20. 1 1
      netbox/extras/migrations/0051_migrate_customfields.py
  21. 6 1
      netbox/extras/models/customfields.py
  22. 5 1
      netbox/extras/models/models.py
  23. 2 2
      netbox/extras/querysets.py
  24. 12 2
      netbox/ipam/api/serializers.py
  25. 1 1
      netbox/ipam/tables.py
  26. 1 1
      netbox/ipam/views.py
  27. 24 16
      netbox/netbox/api/views.py
  28. 1 1
      netbox/netbox/settings.py
  29. 2 2
      netbox/netbox/views/generic.py
  30. 3 3
      netbox/project-static/css/base.css
  31. 1 1
      netbox/templates/circuits/provider.html
  32. 4 3
      netbox/templates/dcim/device/lldp_neighbors.html
  33. 1 1
      netbox/templates/dcim/powerfeed.html
  34. 1 1
      netbox/templates/dcim/rack_edit.html
  35. 6 8
      netbox/tenancy/tables.py
  36. 2 0
      netbox/users/admin.py
  37. 2 0
      netbox/utilities/forms/forms.py
  38. 1 0
      netbox/virtualization/api/views.py
  39. 1 0
      netbox/virtualization/models.py

+ 3 - 3
docs/development/getting-started.md

@@ -27,13 +27,13 @@ base_requirements.txt  contrib          docs         mkdocs.yml  NOTICE     requ
 CHANGELOG.md           CONTRIBUTING.md  LICENSE.txt  netbox      README.md  scripts
 ```
 
-The NetBox project utilizes three long-term branches:
+The NetBox project utilizes three persistent git branches to track work:
 
 * `master` - Serves as a snapshot of the current stable release
 * `develop` - All development on the upcoming stable release occurs here
-* `develop-x.y` - Tracks work on an upcoming major release
+* `feature` - Tracks work on an upcoming major release
 
-Typically, you'll base pull requests off of the `develop` branch, or off of `develop-x.y` if you're working on a new major release. **Never** base pull requests off of the master branch, which receives merged only from the `develop` branch.
+Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch, which receives merged only from the `develop` branch.
 
 ### Enable Pre-Commit Hooks
 

+ 1 - 4
docs/development/release-checklist.md

@@ -52,10 +52,7 @@ Close the release milestone on GitHub after ensuring there are no remaining open
 
 ### Merge the Release Branch
 
-Submit a pull request to merge the release branch `develop-x.y` into the `develop` branch in preparation for its releases.
-
-!!! warning
-    No further releases for the current major version can be published once this pull request is merged.
+Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release.
 
 ---
 

+ 1 - 1
docs/installation/3-netbox.md

@@ -83,7 +83,7 @@ Checking connectivity... done.
 ```
 
 !!! note
-    Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `develop-x.y` branch (if present) tracks progress on the next major release. 
+    Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `feature` branch tracks progress on the next major release. 
 
 ## Create the NetBox System User
 

+ 23 - 0
docs/release-notes/version-2.10.md

@@ -1,5 +1,28 @@
 # NetBox v2.10
 
+## v2.10.3 (2021-01-05)
+
+### Bug Fixes
+
+* [#5049](https://github.com/netbox-community/netbox/issues/5049) - Add check for LLDP neighbor chassis name to lldp_neighbors
+* [#5301](https://github.com/netbox-community/netbox/issues/5301) - Fix misleading error when racking a device with invalid parameters
+* [#5311](https://github.com/netbox-community/netbox/issues/5311) - Update child objects when a rack group is moved to a new site
+* [#5518](https://github.com/netbox-community/netbox/issues/5518) - Fix persistent vertical scrollbar
+* [#5533](https://github.com/netbox-community/netbox/issues/5533) - Fix bulk editing of objects with required custom fields
+* [#5540](https://github.com/netbox-community/netbox/issues/5540) - Fix exception when viewing a provider with one or more tags assigned
+* [#5543](https://github.com/netbox-community/netbox/issues/5543) - Fix rendering of config contexts with cluster assignment for devices
+* [#5546](https://github.com/netbox-community/netbox/issues/5546) - Add custom field bulk edit support for cables, power panels, rack reservations, and virtual chassis
+* [#5547](https://github.com/netbox-community/netbox/issues/5547) - Add custom field bulk import support for cables, power panels, rack reservations, and virtual chassis
+* [#5551](https://github.com/netbox-community/netbox/issues/5551) - Restore missing import button on services list
+* [#5557](https://github.com/netbox-community/netbox/issues/5557) - Fix VRF route target assignment via REST API
+* [#5558](https://github.com/netbox-community/netbox/issues/5558) - Fix regex validation support for custom URL fields
+* [#5563](https://github.com/netbox-community/netbox/issues/5563) - Fix power feed cable trace link
+* [#5564](https://github.com/netbox-community/netbox/issues/5564) - Raise validation error if a power port template's `allocated_draw` exceeds its `maximum_draw`
+* [#5569](https://github.com/netbox-community/netbox/issues/5569) - Ensure consistent labeling of interface `mgmt_only` field
+* [#5573](https://github.com/netbox-community/netbox/issues/5573) - Report inconsistent values when migrating custom field data
+
+---
+
 ## v2.10.2 (2020-12-21)
 
 ### Enhancements

+ 2 - 2
docs/rest-api/filtering.md

@@ -78,8 +78,8 @@ String based (char) fields (Name, Address, etc) support these lookup expressions
 - `nisw` - negated case insensitive starts with
 - `iew` - case insensitive ends with
 - `niew` - negated case insensitive ends with
-- `ie` - case sensitive exact match
-- `nie` - negated case sensitive exact match
+- `ie` - case insensitive exact match
+- `nie` - negated case insensitive exact match
 
 ### Foreign Keys & Other Fields
 

+ 1 - 0
netbox/circuits/api/views.py

@@ -65,3 +65,4 @@ class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
     )
     serializer_class = serializers.CircuitTerminationSerializer
     filterset_class = filters.CircuitTerminationFilterSet
+    brief_prefetch_fields = ['circuit']

+ 11 - 0
netbox/dcim/api/views.py

@@ -258,6 +258,7 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
     )
     serializer_class = serializers.DeviceTypeSerializer
     filterset_class = filters.DeviceTypeFilterSet
+    brief_prefetch_fields = ['manufacturer']
 
 
 #
@@ -493,6 +494,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
     queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
     serializer_class = serializers.ConsolePortSerializer
     filterset_class = filters.ConsolePortFilterSet
+    brief_prefetch_fields = ['device']
 
 
 class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
@@ -501,18 +503,21 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
     )
     serializer_class = serializers.ConsoleServerPortSerializer
     filterset_class = filters.ConsoleServerPortFilterSet
+    brief_prefetch_fields = ['device']
 
 
 class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
     queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
     serializer_class = serializers.PowerPortSerializer
     filterset_class = filters.PowerPortFilterSet
+    brief_prefetch_fields = ['device']
 
 
 class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
     queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
     serializer_class = serializers.PowerOutletSerializer
     filterset_class = filters.PowerOutletFilterSet
+    brief_prefetch_fields = ['device']
 
 
 class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
@@ -521,30 +526,35 @@ class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
     )
     serializer_class = serializers.InterfaceSerializer
     filterset_class = filters.InterfaceFilterSet
+    brief_prefetch_fields = ['device']
 
 
 class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
     queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
     serializer_class = serializers.FrontPortSerializer
     filterset_class = filters.FrontPortFilterSet
+    brief_prefetch_fields = ['device']
 
 
 class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
     queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
     serializer_class = serializers.RearPortSerializer
     filterset_class = filters.RearPortFilterSet
+    brief_prefetch_fields = ['device']
 
 
 class DeviceBayViewSet(ModelViewSet):
     queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags')
     serializer_class = serializers.DeviceBaySerializer
     filterset_class = filters.DeviceBayFilterSet
+    brief_prefetch_fields = ['device']
 
 
 class InventoryItemViewSet(ModelViewSet):
     queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags')
     serializer_class = serializers.InventoryItemSerializer
     filterset_class = filters.InventoryItemFilterSet
+    brief_prefetch_fields = ['device']
 
 
 #
@@ -600,6 +610,7 @@ class VirtualChassisViewSet(ModelViewSet):
     )
     serializer_class = serializers.VirtualChassisSerializer
     filterset_class = filters.VirtualChassisFilterSet
+    brief_prefetch_fields = ['master']
 
 
 #

+ 26 - 38
netbox/dcim/forms.py

@@ -134,6 +134,7 @@ class ComponentForm(BootstrapMixin, forms.Form):
     )
 
     def clean(self):
+        super().clean()
 
         # Validate that the number of components being created from both the name_pattern and label_pattern are equal
         if self.cleaned_data['label_pattern']:
@@ -783,7 +784,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         ]
 
 
-class RackReservationCSVForm(CSVModelForm):
+class RackReservationCSVForm(CustomFieldModelCSVForm):
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         to_field_name='name',
@@ -833,7 +834,7 @@ class RackReservationCSVForm(CSVModelForm):
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
-class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=RackReservation.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -1438,6 +1439,7 @@ class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
         self.fields['rear_port_set'].choices = choices
 
     def clean(self):
+        super().clean()
 
         # Validate that the number of ports being created equals the number of selected (rear port, position) tuples
         front_port_count = len(self.cleaned_data['name_pattern'])
@@ -1781,9 +1783,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             'group_id': '$rack_group',
         }
     )
-    position = forms.TypedChoiceField(
+    position = forms.IntegerField(
         required=False,
-        empty_value=None,
         help_text="The lowest-numbered unit occupied by the device",
         widget=APISelect(
             api_url='/api/dcim/racks/{{rack}}/elevation/',
@@ -1856,6 +1857,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
                                   "config context",
         }
         widgets = {
+            'face': StaticSelect2(),
             'status': StaticSelect2(),
             'primary_ip4': StaticSelect2(),
             'primary_ip6': StaticSelect2(),
@@ -1902,6 +1904,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
                 Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
             )
 
+            # Disable rack assignment if this is a child device installed in a parent device
+            if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
+                self.fields['site'].disabled = True
+                self.fields['rack'].disabled = True
+                self.initial['site'] = self.instance.parent_bay.device.site_id
+                self.initial['rack'] = self.instance.parent_bay.device.rack_id
+
         else:
 
             # An object that doesn't exist yet can't have any IPs assigned to it
@@ -1911,31 +1920,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             self.fields['primary_ip6'].widget.attrs['readonly'] = True
 
         # Rack position
-        pk = self.instance.pk if self.instance.pk else None
-        try:
-            if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
-                position_choices = Rack.objects.get(pk=self.data['rack']) \
-                    .get_rack_units(face=self.data.get('face'), exclude=pk)
-            elif self.initial.get('rack') and str(self.initial.get('face')):
-                position_choices = Rack.objects.get(pk=self.initial['rack']) \
-                    .get_rack_units(face=self.initial.get('face'), exclude=pk)
-            else:
-                position_choices = []
-        except Rack.DoesNotExist:
-            position_choices = []
-        self.fields['position'].choices = [('', '---------')] + [
-            (p['id'], {
-                'label': p['name'],
-                'disabled': bool(p['device'] and p['id'] != self.initial.get('position')),
-            }) for p in position_choices
-        ]
-
-        # Disable rack assignment if this is a child device installed in a parent device
-        if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
-            self.fields['site'].disabled = True
-            self.fields['rack'].disabled = True
-            self.initial['site'] = self.instance.parent_bay.device.site_id
-            self.initial['rack'] = self.instance.parent_bay.device.rack_id
+        position = self.data.get('position') or self.initial.get('position')
+        if position:
+            self.fields['position'].widget.choices = [(position, f'U{position}')]
 
 
 class BaseDeviceCSVForm(CustomFieldModelCSVForm):
@@ -2944,6 +2931,7 @@ class InterfaceBulkEditForm(
             self.fields['lag'].widget.attrs['disabled'] = True
 
     def clean(self):
+        super().clean()
 
         # Untagged interfaces cannot be assigned tagged VLANs
         if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
@@ -3092,6 +3080,7 @@ class FrontPortCreateForm(ComponentCreateForm):
         self.fields['rear_port_set'].choices = choices
 
     def clean(self):
+        super().clean()
 
         # Validate that the number of ports being created equals the number of selected (rear port, position) tuples
         front_port_count = len(self.cleaned_data['name_pattern'])
@@ -3786,7 +3775,7 @@ class CableForm(BootstrapMixin, CustomFieldModelForm):
         }
 
 
-class CableCSVForm(CSVModelForm):
+class CableCSVForm(CustomFieldModelCSVForm):
     # Termination A
     side_a_device = CSVModelChoiceField(
         queryset=Device.objects.all(),
@@ -3881,7 +3870,7 @@ class CableCSVForm(CSVModelForm):
         return length_unit if length_unit is not None else ''
 
 
-class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Cable.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -3924,6 +3913,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         ]
 
     def clean(self):
+        super().clean()
 
         # Validate length/unit
         length = self.cleaned_data.get('length')
@@ -4267,7 +4257,7 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
         return device
 
 
-class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=VirtualChassis.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -4281,7 +4271,7 @@ class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm
         nullable_fields = ['domain']
 
 
-class VirtualChassisCSVForm(CSVModelForm):
+class VirtualChassisCSVForm(CustomFieldModelCSVForm):
     master = CSVModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name',
@@ -4368,7 +4358,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
         ]
 
 
-class PowerPanelCSVForm(CSVModelForm):
+class PowerPanelCSVForm(CustomFieldModelCSVForm):
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         to_field_name='name',
@@ -4394,7 +4384,7 @@ class PowerPanelCSVForm(CSVModelForm):
             self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params)
 
 
-class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=PowerPanel.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -4422,9 +4412,7 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     )
 
     class Meta:
-        nullable_fields = (
-            'rack_group',
-        )
+        nullable_fields = ['rack_group']
 
 
 class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):

+ 11 - 0
netbox/dcim/models/device_component_templates.py

@@ -164,6 +164,15 @@ class PowerPortTemplate(ComponentTemplateModel):
             allocated_draw=self.allocated_draw
         )
 
+    def clean(self):
+        super().clean()
+
+        if self.maximum_draw is not None and self.allocated_draw is not None:
+            if self.allocated_draw > self.maximum_draw:
+                raise ValidationError({
+                    'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
+                })
+
 
 class PowerOutletTemplate(ComponentTemplateModel):
     """
@@ -193,6 +202,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
         unique_together = ('device_type', 'name')
 
     def clean(self):
+        super().clean()
 
         # Validate power port assignment
         if self.power_port and self.power_port.device_type != self.device_type:
@@ -278,6 +288,7 @@ class FrontPortTemplate(ComponentTemplateModel):
         )
 
     def clean(self):
+        super().clean()
 
         # Validate rear port assignment
         if self.rear_port.device_type != self.device_type:

+ 7 - 1
netbox/dcim/models/device_components.py

@@ -316,6 +316,7 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
         )
 
     def clean(self):
+        super().clean()
 
         if self.maximum_draw is not None and self.allocated_draw is not None:
             if self.allocated_draw > self.maximum_draw:
@@ -425,6 +426,7 @@ class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
         )
 
     def clean(self):
+        super().clean()
 
         # Validate power port assignment
         if self.power_port and self.power_port.device != self.device:
@@ -503,7 +505,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
     )
     mgmt_only = models.BooleanField(
         default=False,
-        verbose_name='OOB Management',
+        verbose_name='Management only',
         help_text='This interface is used only for out-of-band management'
     )
     untagged_vlan = models.ForeignKey(
@@ -555,6 +557,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
         )
 
     def clean(self):
+        super().clean()
 
         # Virtual interfaces cannot be connected
         if self.type in NONCONNECTABLE_IFACE_TYPES and (
@@ -668,6 +671,7 @@ class FrontPort(CableTermination, ComponentModel):
         )
 
     def clean(self):
+        super().clean()
 
         # Validate rear port assignment
         if self.rear_port.device != self.device:
@@ -711,6 +715,7 @@ class RearPort(CableTermination, ComponentModel):
         return reverse('dcim:rearport', kwargs={'pk': self.pk})
 
     def clean(self):
+        super().clean()
 
         # Check that positions count is greater than or equal to the number of associated FrontPorts
         frontport_count = self.frontports.count()
@@ -768,6 +773,7 @@ class DeviceBay(ComponentModel):
         )
 
     def clean(self):
+        super().clean()
 
         # Validate that the parent Device can have DeviceBays
         if not self.device.device_type.is_parent_device:

+ 5 - 5
netbox/dcim/models/devices.py

@@ -640,7 +640,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         # Validate site/rack combination
         if self.rack and self.site != self.rack.site:
             raise ValidationError({
-                'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site),
+                'rack': f"Rack {self.rack} does not belong to site {self.site}.",
             })
 
         if self.rack is None:
@@ -650,7 +650,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
                 })
             if self.position:
                 raise ValidationError({
-                    'face': "Cannot select a rack position without assigning a rack.",
+                    'position': "Cannot select a rack position without assigning a rack.",
                 })
 
         # Validate position/face combination
@@ -662,7 +662,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         # Prevent 0U devices from being assigned to a specific position
         if self.position and self.device_type.u_height == 0:
             raise ValidationError({
-                'position': "A U0 device type ({}) cannot be assigned to a rack position.".format(self.device_type)
+                'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
             })
 
         if self.rack:
@@ -688,8 +688,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
                 )
                 if self.position and self.position not in available_units:
                     raise ValidationError({
-                        'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) "
-                                    "{} ({}U).".format(self.position, self.device_type, self.device_type.u_height)
+                        'position': f"U{self.position} is already occupied or does not have sufficient space to "
+                                    f"accommodate this device type: {self.device_type} ({self.device_type.u_height}U)"
                     })
 
             except DeviceType.DoesNotExist:

+ 1 - 16
netbox/dcim/models/racks.py

@@ -109,6 +109,7 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
         )
 
     def clean(self):
+        super().clean()
 
         # Parent RackGroup (if any) must belong to the same Site
         if self.parent and self.parent.site != self.site:
@@ -326,22 +327,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
                         'group': "Rack group must be from the same site, {}.".format(self.site)
                     })
 
-    def save(self, *args, **kwargs):
-
-        # Record the original site assignment for this rack.
-        _site_id = None
-        if self.pk:
-            _site_id = Rack.objects.get(pk=self.pk).site_id
-
-        super().save(*args, **kwargs)
-
-        # Update racked devices if the assigned Site has been changed.
-        if _site_id is not None and self.site_id != _site_id:
-            devices = Device.objects.filter(rack=self)
-            for device in devices:
-                device.site = self.site
-                device.save()
-
     def to_csv(self):
         return (
             self.site.name,

+ 43 - 1
netbox/dcim/signals.py

@@ -7,7 +7,7 @@ from django.db import transaction
 from django.dispatch import receiver
 
 from .choices import CableStatusChoices
-from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis
+from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, RackGroup, VirtualChassis
 
 
 def create_cablepath(node):
@@ -36,6 +36,43 @@ def rebuild_paths(obj):
             create_cablepath(cp.origin)
 
 
+#
+# Site/rack/device assignment
+#
+
+@receiver(post_save, sender=RackGroup)
+def handle_rackgroup_site_change(instance, created, **kwargs):
+    """
+    Update child RackGroups and Racks if Site assignment has changed. We intentionally recurse through each child
+    object instead of calling update() on the QuerySet to ensure the proper change records get created for each.
+    """
+    if not created:
+        for rackgroup in instance.get_children():
+            rackgroup.site = instance.site
+            rackgroup.save()
+        for rack in Rack.objects.filter(group=instance).exclude(site=instance.site):
+            rack.site = instance.site
+            rack.save()
+        for powerpanel in PowerPanel.objects.filter(rack_group=instance).exclude(site=instance.site):
+            powerpanel.site = instance.site
+            powerpanel.save()
+
+
+@receiver(post_save, sender=Rack)
+def handle_rack_site_change(instance, created, **kwargs):
+    """
+    Update child Devices if Site assignment has changed.
+    """
+    if not created:
+        for device in Device.objects.filter(rack=instance).exclude(site=instance.site):
+            device.site = instance.site
+            device.save()
+
+
+#
+# Virtual chassis
+#
+
 @receiver(post_save, sender=VirtualChassis)
 def assign_virtualchassis_master(instance, created, **kwargs):
     """
@@ -60,6 +97,11 @@ def clear_virtualchassis_members(instance, **kwargs):
         device.save()
 
 
+#
+# Cables
+#
+
+
 @receiver(post_save, sender=Cable)
 def update_connected_endpoints(instance, created, raw=False, **kwargs):
     """

+ 2 - 1
netbox/dcim/tables/racks.py

@@ -26,7 +26,8 @@ class RackGroupTable(BaseTable):
     pk = ToggleColumn()
     name = tables.TemplateColumn(
         template_code=MPTT_LINK,
-        orderable=False
+        orderable=False,
+        attrs={'td': {'class': 'text-nowrap'}}
     )
     site = tables.LinkColumn(
         viewname='dcim:site',

+ 2 - 1
netbox/dcim/tables/sites.py

@@ -19,7 +19,8 @@ class RegionTable(BaseTable):
     pk = ToggleColumn()
     name = tables.TemplateColumn(
         template_code=MPTT_LINK,
-        orderable=False
+        orderable=False,
+        attrs={'td': {'class': 'text-nowrap'}}
     )
     site_count = tables.Column(
         verbose_name='Sites'

+ 4 - 7
netbox/dcim/tables/template_code.py

@@ -57,13 +57,10 @@ INTERFACE_TAGGED_VLANS = """
 """
 
 MPTT_LINK = """
-{% if record.get_children %}
-    <span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="mdi mdi-chevron-right"></i>
-{% else %}
-    <span style="padding-left: {{ record.get_ancestors|length }}9px">
-{% endif %}
-    <a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
-</span>
+{% for i in record.get_ancestors %}
+    <i class="mdi mdi-circle-small"></i>
+{% endfor %}
+<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
 """
 
 POWERFEED_CABLE = """

+ 16 - 2
netbox/dcim/tests/test_forms.py

@@ -82,7 +82,7 @@ class DeviceTestCase(TestCase):
         self.assertTrue(form.is_valid())
         self.assertTrue(form.save())
 
-    def test_non_racked_device_with_face_position(self):
+    def test_non_racked_device_with_face(self):
         form = DeviceForm(data={
             'name': 'New Device',
             'device_role': DeviceRole.objects.first().pk,
@@ -92,12 +92,26 @@ class DeviceTestCase(TestCase):
             'site': Site.objects.first().pk,
             'rack': None,
             'face': DeviceFaceChoices.FACE_REAR,
-            'position': 10,
             'platform': None,
             'status': DeviceStatusChoices.STATUS_ACTIVE,
         })
         self.assertFalse(form.is_valid())
         self.assertIn('face', form.errors)
+
+    def test_non_racked_device_with_position(self):
+        form = DeviceForm(data={
+            'name': 'New Device',
+            'device_role': DeviceRole.objects.first().pk,
+            'tenant': None,
+            'manufacturer': Manufacturer.objects.first().pk,
+            'device_type': DeviceType.objects.first().pk,
+            'site': Site.objects.first().pk,
+            'rack': None,
+            'position': 10,
+            'platform': None,
+            'status': DeviceStatusChoices.STATUS_ACTIVE,
+        })
+        self.assertFalse(form.is_valid())
         self.assertIn('position', form.errors)
 
 

+ 64 - 0
netbox/dcim/tests/test_models.py

@@ -7,6 +7,42 @@ from dcim.models import *
 from tenancy.models import Tenant
 
 
+class RackGroupTestCase(TestCase):
+
+    def test_change_rackgroup_site(self):
+        """
+        Check that all child RackGroups and Racks get updated when a RackGroup is moved to a new Site. Topology:
+        Site A
+          - RackGroup A1
+            - RackGroup A2
+              - Rack 2
+            - Rack 1
+        """
+        site_a = Site.objects.create(name='Site A', slug='site-a')
+        site_b = Site.objects.create(name='Site B', slug='site-b')
+
+        rackgroup_a1 = RackGroup(site=site_a, name='RackGroup A1', slug='rackgroup-a1')
+        rackgroup_a1.save()
+        rackgroup_a2 = RackGroup(site=site_a, parent=rackgroup_a1, name='RackGroup A2', slug='rackgroup-a2')
+        rackgroup_a2.save()
+
+        rack1 = Rack.objects.create(site=site_a, group=rackgroup_a1, name='Rack 1')
+        rack2 = Rack.objects.create(site=site_a, group=rackgroup_a2, name='Rack 2')
+
+        powerpanel1 = PowerPanel.objects.create(site=site_a, rack_group=rackgroup_a1, name='Power Panel 1')
+
+        # Move RackGroup A1 to Site B
+        rackgroup_a1.site = site_b
+        rackgroup_a1.save()
+
+        # Check that all objects within RackGroup A1 now belong to Site B
+        self.assertEqual(RackGroup.objects.get(pk=rackgroup_a1.pk).site, site_b)
+        self.assertEqual(RackGroup.objects.get(pk=rackgroup_a2.pk).site, site_b)
+        self.assertEqual(Rack.objects.get(pk=rack1.pk).site, site_b)
+        self.assertEqual(Rack.objects.get(pk=rack2.pk).site, site_b)
+        self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b)
+
+
 class RackTestCase(TestCase):
 
     def setUp(self):
@@ -154,6 +190,34 @@ class RackTestCase(TestCase):
         )
         self.assertTrue(pdu)
 
+    def test_change_rack_site(self):
+        """
+        Check that child Devices get updated when a Rack is moved to a new Site.
+        """
+        site_a = Site.objects.create(name='Site A', slug='site-a')
+        site_b = Site.objects.create(name='Site B', slug='site-b')
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+        )
+        device_role = DeviceRole.objects.create(
+            name='Device Role 1', slug='device-role-1', color='ff0000'
+        )
+
+        # Create Rack1 in Site A
+        rack1 = Rack.objects.create(site=site_a, name='Rack 1')
+
+        # Create Device1 in Rack1
+        device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role)
+
+        # Move Rack1 to Site B
+        rack1.site = site_b
+        rack1.save()
+
+        # Check that Device1 is now assigned to Site B
+        self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b)
+
 
 class DeviceTestCase(TestCase):
 

+ 4 - 5
netbox/extras/api/views.py

@@ -39,7 +39,6 @@ class ConfigContextQuerySetMixin:
     Provides a get_queryset() method which deals with adding the config context
     data annotation or not.
     """
-
     def get_queryset(self):
         """
         Build the proper queryset based on the request context
@@ -49,11 +48,11 @@ class ConfigContextQuerySetMixin:
 
         Else, return the queryset annotated with config context data
         """
-
+        queryset = super().get_queryset()
         request = self.get_serializer_context()['request']
-        if request.query_params.get('brief') or 'config_context' in request.query_params.get('exclude', []):
-            return self.queryset
-        return self.queryset.annotate_config_context_data()
+        if self.brief or 'config_context' in request.query_params.get('exclude', []):
+            return queryset
+        return queryset.annotate_config_context_data()
 
 
 #

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

@@ -67,7 +67,7 @@ def migrate_customfieldvalues(apps, schema_editor):
         cf_data = model.objects.filter(pk=cfv.obj_id).values('custom_field_data').first()
         try:
             cf_data['custom_field_data'][cfv.field.name] = deserialize_value(cfv.field, cfv.serialized_value)
-        except ValueError as e:
+        except Exception as e:
             print(f'{cfv.field.name} ({cfv.field.type}): {cfv.serialized_value} ({cfv.pk})')
             raise e
         model.objects.filter(pk=cfv.obj_id).update(**cf_data)

+ 6 - 1
netbox/extras/models/customfields.py

@@ -47,6 +47,8 @@ class CustomFieldModel(models.Model):
         ])
 
     def clean(self):
+        super().clean()
+
         custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)}
 
         # Validate all field values
@@ -172,6 +174,8 @@ class CustomField(models.Model):
                 obj.save()
 
     def clean(self):
+        super().clean()
+
         # Validate the field's default value (if any)
         if self.default is not None:
             try:
@@ -192,7 +196,8 @@ class CustomField(models.Model):
             })
 
         # Regex validation can be set only for text fields
-        if self.validation_regex and self.type != CustomFieldTypeChoices.TYPE_TEXT:
+        regex_types = (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_URL)
+        if self.validation_regex and self.type not in regex_types:
             raise ValidationError({
                 'validation_regex': "Regular expression validation is supported only for text and URL fields"
             })

+ 5 - 1
netbox/extras/models/models.py

@@ -117,11 +117,15 @@ class Webhook(models.Model):
         return self.name
 
     def clean(self):
+        super().clean()
+
+        # At least one action type must be selected
         if not self.type_create and not self.type_delete and not self.type_update:
             raise ValidationError(
                 "You must select at least one type: create, update, and/or delete."
             )
 
+        # CA file path requires SSL verification enabled
         if not self.ssl_verification and self.ca_file_path:
             raise ValidationError({
                 'ca_file_path': 'Do not specify a CA certificate file if SSL verification is disabled.'
@@ -436,6 +440,7 @@ class ConfigContext(ChangeLoggedModel):
         return reverse('extras:configcontext', kwargs={'pk': self.pk})
 
     def clean(self):
+        super().clean()
 
         # Verify that JSON data is provided as an object
         if type(self.data) is not dict:
@@ -482,7 +487,6 @@ class ConfigContextModel(models.Model):
         return data
 
     def clean(self):
-
         super().clean()
 
         # Verify that JSON data is provided as an object

+ 2 - 2
netbox/extras/querysets.py

@@ -89,6 +89,8 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
         }
         base_query = Q(
             Q(platforms=OuterRef('platform')) | Q(platforms=None),
+            Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None),
+            Q(clusters=OuterRef('cluster')) | Q(clusters=None),
             Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=None),
             Q(tenants=OuterRef('tenant')) | Q(tenants=None),
             Q(
@@ -111,8 +113,6 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
 
         elif self.model._meta.model_name == 'virtualmachine':
             base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
-            base_query.add((Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None)), Q.AND)
-            base_query.add((Q(clusters=OuterRef('cluster')) | Q(clusters=None)), Q.AND)
             base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND)
             region_field = 'cluster__site__region'
 

+ 12 - 2
netbox/ipam/api/serializers.py

@@ -25,8 +25,18 @@ from .nested_serializers import *
 class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
     tenant = NestedTenantSerializer(required=False, allow_null=True)
-    import_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True)
-    export_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True)
+    import_targets = SerializedPKRelatedField(
+        queryset=RouteTarget.objects.all(),
+        serializer=NestedRouteTargetSerializer,
+        required=False,
+        many=True
+    )
+    export_targets = SerializedPKRelatedField(
+        queryset=RouteTarget.objects.all(),
+        serializer=NestedRouteTargetSerializer,
+        required=False,
+        many=True
+    )
     ipaddress_count = serializers.IntegerField(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)
 

+ 1 - 1
netbox/ipam/tables.py

@@ -270,7 +270,7 @@ class PrefixTable(BaseTable):
     pk = ToggleColumn()
     prefix = tables.TemplateColumn(
         template_code=PREFIX_LINK,
-        attrs={'th': {'style': 'padding-left: 17px'}}
+        attrs={'td': {'class': 'text-nowrap'}}
     )
     status = ChoiceFieldColumn(
         default=AVAILABLE_LABEL

+ 1 - 1
netbox/ipam/views.py

@@ -804,7 +804,7 @@ class ServiceListView(generic.ObjectListView):
     filterset = filters.ServiceFilterSet
     filterset_form = forms.ServiceFilterForm
     table = tables.ServiceTable
-    action_buttons = ('export',)
+    action_buttons = ('import', 'export')
 
 
 class ServiceView(generic.ObjectView):

+ 24 - 16
netbox/netbox/api/views.py

@@ -9,11 +9,11 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import transaction
 from django.db.models import ProtectedError
 from django_rq.queues import get_connection
-from rest_framework import mixins, status
+from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
 from rest_framework.views import APIView
-from rest_framework.viewsets import GenericViewSet
+from rest_framework.viewsets import ModelViewSet as ModelViewSet_
 from rq.worker import Worker
 
 from netbox.api import BulkOperationSerializer
@@ -120,17 +120,13 @@ class BulkDestroyModelMixin:
 # Viewsets
 #
 
-class ModelViewSet(mixins.CreateModelMixin,
-                   mixins.RetrieveModelMixin,
-                   mixins.UpdateModelMixin,
-                   mixins.DestroyModelMixin,
-                   mixins.ListModelMixin,
-                   BulkUpdateModelMixin,
-                   BulkDestroyModelMixin,
-                   GenericViewSet):
+class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
     """
-    Accept either a single object or a list of objects to create.
+    Extend DRF's ModelViewSet to support bulk update and delete functions.
     """
+    brief = False
+    brief_prefetch_fields = []
+
     def get_serializer(self, *args, **kwargs):
 
         # If a list of objects has been provided, initialize the serializer with many=True
@@ -142,22 +138,34 @@ class ModelViewSet(mixins.CreateModelMixin,
     def get_serializer_class(self):
         logger = logging.getLogger('netbox.api.views.ModelViewSet')
 
-        # If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one
-        # exists
-        request = self.get_serializer_context()['request']
-        if request.query_params.get('brief'):
+        # If using 'brief' mode, find and return the nested serializer for this model, if one exists
+        if self.brief:
             logger.debug("Request is for 'brief' format; initializing nested serializer")
             try:
                 serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
                 logger.debug(f"Using serializer {serializer}")
                 return serializer
             except SerializerNotFound:
-                pass
+                logger.debug(f"Nested serializer for {self.queryset.model} not found!")
 
         # Fall back to the hard-coded serializer class
         logger.debug(f"Using serializer {self.serializer_class}")
         return self.serializer_class
 
+    def get_queryset(self):
+        # If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
+        if self.brief:
+            return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
+
+        return super().get_queryset()
+
+    def initialize_request(self, request, *args, **kwargs):
+        # Check if brief=True has been passed
+        if request.method == 'GET' and request.GET.get('brief'):
+            self.brief = True
+
+        return super().initialize_request(request, *args, **kwargs)
+
     def initial(self, request, *args, **kwargs):
         super().initial(request, *args, **kwargs)
 

+ 1 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '2.10.2'
+VERSION = '2.10.3'
 
 # Hostname
 HOSTNAME = platform.node()

+ 2 - 2
netbox/netbox/views/generic.py

@@ -798,8 +798,8 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                             # Update custom fields
                             for name in custom_fields:
                                 if name in form.nullable_fields and name in nullified_fields:
-                                    obj.custom_field_data.pop(name, None)
-                                else:
+                                    obj.custom_field_data[name] = None
+                                elif form.cleaned_data.get(name) not in (None, ''):
                                     obj.custom_field_data[name] = form.cleaned_data[name]
 
                             obj.full_clean()

+ 3 - 3
netbox/project-static/css/base.css

@@ -14,21 +14,21 @@ body {
 .wrapper {
     min-height: 100%;
     height: auto !important;
-    margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */
+    margin: 0 auto -48px; /* the bottom margin is the negative value of the footer's height */
     padding-bottom: 30px;
 }
 .navbar-brand {
     padding: 12px 15px 8px;
 }
 .footer, .push {
-    height: 60px; /* .push must be the same height as .footer */
+    height: 48px; /* .push must be the same height as .footer */
 }
 .footer {
     background-color: #f5f5f5;
     border-top: 1px solid #d0d0d0;
 }
 footer p {
-    margin: 20px 0;
+    margin: 12px 0;
 }
 #navbar_search {
     padding: 0 8px;

+ 1 - 1
netbox/templates/circuits/provider.html

@@ -100,7 +100,7 @@
             </table>
         </div>
         {% include 'inc/custom_fields_panel.html' %}
-        {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:object_list' %}
+        {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
         <div class="panel panel-default">
             <div class="panel-heading">
                 <strong>Comments</strong>

+ 4 - 3
netbox/templates/dcim/device/lldp_neighbors.html

@@ -23,7 +23,7 @@
                     <tr id="{{ iface.name }}">
                         <td>{{ iface }}</td>
                         {% if iface.connected_endpoint.device %}
-                            <td class="configured_device" data="{{ iface.connected_endpoint.device }}">
+                            <td class="configured_device" data="{{ iface.connected_endpoint.device }}" data-chassis="{{ iface.connected_endpoint.device.virtual_chassis.name }}">
                                 <a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">{{ iface.connected_endpoint.device }}</a>
                             </td>
                             <td class="configured_interface" data="{{ iface.connected_endpoint }}">
@@ -61,6 +61,7 @@ $(document).ready(function() {
 
                 // Glean configured hostnames/interfaces from the DOM
                 var configured_device = row.children('td.configured_device').attr('data');
+                var configured_chassis = row.children('td.configured_device').attr('data-chassis');
                 var configured_interface = row.children('td.configured_interface').attr('data');
                 var configured_interface_short = null;
                 if (configured_interface) {
@@ -81,9 +82,9 @@ $(document).ready(function() {
                 // Apply colors to rows
                 if (!configured_device && lldp_device) {
                     row.addClass('info');
-                } else if (configured_device == lldp_device && configured_interface == lldp_interface) {
+                } else if ((configured_device == lldp_device || configured_chassis == lldp_device) && configured_interface == lldp_interface) {
                     row.addClass('success');
-                } else if (configured_device == lldp_device && configured_interface_short == lldp_interface) {
+                } else if ((configured_device == lldp_device || configured_chassis == lldp_device) && configured_interface_short == lldp_interface) {
                     row.addClass('success');
                 } else {
                     row.addClass('danger');

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

@@ -165,7 +165,7 @@
                         <td>Cable</td>
                         <td>
                             <a href="{{ object.cable.get_absolute_url }}">{{ object.cable }}</a>
-                            <a href="{% url 'dcim:consoleport_trace' pk=object.pk %}" class="btn btn-primary btn-xs" title="Trace">
+                            <a href="{% url 'dcim:powerfeed_trace' pk=object.pk %}" class="btn btn-primary btn-xs" title="Trace">
                                 <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
                             </a>
                         </td>

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

@@ -7,9 +7,9 @@
         <div class="panel-body">
             {% render_field form.region %}
             {% render_field form.site %}
+            {% render_field form.group %}
             {% render_field form.name %}
             {% render_field form.facility_id %}
-            {% render_field form.group %}
             {% render_field form.status %}
             {% render_field form.role %}
             {% render_field form.serial %}

+ 6 - 8
netbox/tenancy/tables.py

@@ -4,13 +4,10 @@ from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, TagCol
 from .models import Tenant, TenantGroup
 
 MPTT_LINK = """
-{% if record.get_children %}
-    <span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="mdi mdi-chevron-right"></i>
-{% else %}
-    <span style="padding-left: {{ record.get_ancestors|length }}9px">
-{% endif %}
-    <a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
-</span>
+{% for i in record.get_ancestors %}
+    <i class="mdi mdi-circle-small"></i>
+{% endfor %}
+<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
 """
 
 COL_TENANT = """
@@ -30,7 +27,8 @@ class TenantGroupTable(BaseTable):
     pk = ToggleColumn()
     name = tables.TemplateColumn(
         template_code=MPTT_LINK,
-        orderable=False
+        orderable=False,
+        attrs={'td': {'class': 'text-nowrap'}}
     )
     tenant_count = LinkedCountColumn(
         viewname='tenancy:tenant_list',

+ 2 - 0
netbox/users/admin.py

@@ -169,6 +169,8 @@ class ObjectPermissionForm(forms.ModelForm):
                     self.instance.actions.remove(action)
 
     def clean(self):
+        super().clean()
+
         object_types = self.cleaned_data.get('object_types')
         constraints = self.cleaned_data.get('constraints')
 

+ 2 - 0
netbox/utilities/forms/forms.py

@@ -82,6 +82,7 @@ class BulkRenameForm(forms.Form):
     )
 
     def clean(self):
+        super().clean()
 
         # Validate regular expression in "find" field
         if self.cleaned_data['use_regex']:
@@ -124,6 +125,7 @@ class ImportForm(BootstrapMixin, forms.Form):
     )
 
     def clean(self):
+        super().clean()
 
         data = self.cleaned_data['data']
         format = self.cleaned_data['format']

+ 1 - 0
netbox/virtualization/api/views.py

@@ -84,3 +84,4 @@ class VMInterfaceViewSet(ModelViewSet):
     )
     serializer_class = serializers.VMInterfaceSerializer
     filterset_class = filters.VMInterfaceFilterSet
+    brief_prefetch_fields = ['virtual_machine']

+ 1 - 0
netbox/virtualization/models.py

@@ -444,6 +444,7 @@ class VMInterface(BaseInterface):
         )
 
     def clean(self):
+        super().clean()
 
         # Validate untagged VLAN
         if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: