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

Merge branch 'develop' into feature

jeremystretch 3 лет назад
Родитель
Сommit
562769fb89

+ 17 - 0
docs/release-notes/version-3.2.md

@@ -2,6 +2,23 @@
 
 ## v3.2.8 (FUTURE)
 
+### Enhancements
+
+* [#9062](https://github.com/netbox-community/netbox/issues/9062) - Add/edit {module} substitution to help text for component template name
+* [#9637](https://github.com/netbox-community/netbox/issues/9637) - Add site group field to rack reservation form
+* [#9762](https://github.com/netbox-community/netbox/issues/9762) - Add `nat_outside` column to the IPAddress table
+* [#9825](https://github.com/netbox-community/netbox/issues/9825) - Add contacts column to virtual machines table
+* [#9881](https://github.com/netbox-community/netbox/issues/9881) - Increase granularity in utilization graph values
+* [#9882](https://github.com/netbox-community/netbox/issues/9882) - Add manufacturer column to modules table
+* [#9883](https://github.com/netbox-community/netbox/issues/9883) - Linkify location column in power panels table
+
+### Bug Fixes
+
+* [#9871](https://github.com/netbox-community/netbox/issues/9871) - Fix utilization graph value alignments
+* [#9884](https://github.com/netbox-community/netbox/issues/9884) - Prevent querying assigned VRF on prefix object init
+* [#9885](https://github.com/netbox-community/netbox/issues/9885) - Fix child prefix counts when editing/deleting aggregates in bulk
+* [#9891](https://github.com/netbox-community/netbox/issues/9891) - Ensure consistent ordering for tags during object serialization
+
 ---
 
 ## v3.2.7 (2022-07-20)

+ 21 - 8
netbox/dcim/forms/filtersets.py

@@ -291,7 +291,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     fieldsets = (
         (None, ('q', 'tag')),
         ('User', ('user_id',)),
-        ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id')),
+        ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
     region_id = DynamicModelMultipleChoiceField(
@@ -299,25 +299,38 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         required=False,
         label=_('Region')
     )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group')
+    )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         required=False,
         query_params={
-            'region_id': '$region_id'
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
         },
         label=_('Site')
     )
-    site_group_id = DynamicModelMultipleChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        label=_('Site group')
-    )
     location_id = DynamicModelMultipleChoiceField(
-        queryset=Location.objects.prefetch_related('site'),
+        queryset=Location.objects.all(),
         required=False,
+        query_params={
+            'site_id': '$site_id',
+        },
         label=_('Location'),
         null_option='None'
     )
+    rack_id = DynamicModelMultipleChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site_id',
+            'location_id': '$location_id',
+        },
+        label=_('Rack')
+    )
     user_id = DynamicModelMultipleChoiceField(
         queryset=User.objects.all(),
         required=False,

+ 1 - 1
netbox/dcim/forms/models.py

@@ -325,7 +325,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
     )
 
     fieldsets = (
-        ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
+        ('Reservation', ('region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
         ('Tenancy', ('tenant_group', 'tenant')),
     )
 

+ 8 - 0
netbox/dcim/forms/object_create.py

@@ -64,6 +64,14 @@ class ModularComponentTemplateCreateForm(ComponentCreateForm):
     """
     Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType.
     """
+    name_pattern = ExpandableNameField(
+        label='Name',
+        help_text="""
+                Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
+                are not supported. Example: <code>[ge,xe]-0/0/[0-9]</code>.  {module} is accepted as a substitution for
+                the module bay position.
+                """
+    )
     device_type = DynamicModelChoiceField(
         queryset=DeviceType.objects.all(),
         required=False

+ 85 - 1
netbox/dcim/models/device_component_templates.py

@@ -39,7 +39,10 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
         related_name='%(class)ss'
     )
     name = models.CharField(
-        max_length=64
+        max_length=64,
+        help_text="""
+        {module} is accepted as a substitution for the module bay position when attached to a module type.
+        """
     )
     _name = NaturalOrderingField(
         target_field='name',
@@ -157,6 +160,14 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
             **kwargs
         )
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'label': self.label,
+            'description': self.description,
+        }
+
 
 class ConsoleServerPortTemplate(ModularComponentTemplateModel):
     """
@@ -185,6 +196,14 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
             **kwargs
         )
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'label': self.label,
+            'description': self.description,
+        }
+
 
 class PowerPortTemplate(ModularComponentTemplateModel):
     """
@@ -236,6 +255,16 @@ class PowerPortTemplate(ModularComponentTemplateModel):
                     'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
                 })
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'maximum_draw': self.maximum_draw,
+            'allocated_draw': self.allocated_draw,
+            'label': self.label,
+            'description': self.description,
+        }
+
 
 class PowerOutletTemplate(ModularComponentTemplateModel):
     """
@@ -298,6 +327,16 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
             **kwargs
         )
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'power_port': self.power_port.name if self.power_port else None,
+            'feed_leg': self.feed_leg,
+            'label': self.label,
+            'description': self.description,
+        }
+
 
 class InterfaceTemplate(ModularComponentTemplateModel):
     """
@@ -351,6 +390,17 @@ class InterfaceTemplate(ModularComponentTemplateModel):
             **kwargs
         )
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'mgmt_only': self.mgmt_only,
+            'label': self.label,
+            'description': self.description,
+            'poe_mode': self.poe_mode,
+            'poe_type': self.poe_type,
+        }
+
 
 class FrontPortTemplate(ModularComponentTemplateModel):
     """
@@ -424,6 +474,16 @@ class FrontPortTemplate(ModularComponentTemplateModel):
             **kwargs
         )
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'rear_port': self.rear_port.name,
+            'rear_port_position': self.rear_port_position,
+            'label': self.label,
+            'description': self.description,
+        }
+
 
 class RearPortTemplate(ModularComponentTemplateModel):
     """
@@ -463,6 +523,15 @@ class RearPortTemplate(ModularComponentTemplateModel):
             **kwargs
         )
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'positions': self.positions,
+            'label': self.label,
+            'description': self.description,
+        }
+
 
 class ModuleBayTemplate(ComponentTemplateModel):
     """
@@ -488,6 +557,14 @@ class ModuleBayTemplate(ComponentTemplateModel):
             position=self.position
         )
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'label': self.label,
+            'position': self.position,
+            'description': self.description,
+        }
+
 
 class DeviceBayTemplate(ComponentTemplateModel):
     """
@@ -512,6 +589,13 @@ class DeviceBayTemplate(ComponentTemplateModel):
                 f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
             )
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'label': self.label,
+            'description': self.description,
+        }
+
 
 class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
     """

+ 33 - 147
netbox/dcim/models/devices.py

@@ -1,5 +1,4 @@
 import decimal
-from collections import OrderedDict
 
 import yaml
 from django.contrib.contenttypes.fields import GenericRelation
@@ -164,117 +163,54 @@ class DeviceType(NetBoxModel):
         return reverse('dcim:devicetype', args=[self.pk])
 
     def to_yaml(self):
-        data = OrderedDict((
-            ('manufacturer', self.manufacturer.name),
-            ('model', self.model),
-            ('slug', self.slug),
-            ('part_number', self.part_number),
-            ('u_height', float(self.u_height)),
-            ('is_full_depth', self.is_full_depth),
-            ('subdevice_role', self.subdevice_role),
-            ('airflow', self.airflow),
-            ('comments', self.comments),
-        ))
+        data = {
+            'manufacturer': self.manufacturer.name,
+            'model': self.model,
+            'slug': self.slug,
+            'part_number': self.part_number,
+            'u_height': float(self.u_height),
+            'is_full_depth': self.is_full_depth,
+            'subdevice_role': self.subdevice_role,
+            'airflow': self.airflow,
+            'comments': self.comments,
+        }
 
         # Component templates
         if self.consoleporttemplates.exists():
             data['console-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'label': c.label,
-                    'description': c.description,
-                }
-                for c in self.consoleporttemplates.all()
+                c.to_yaml() for c in self.consoleporttemplates.all()
             ]
         if self.consoleserverporttemplates.exists():
             data['console-server-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'label': c.label,
-                    'description': c.description,
-                }
-                for c in self.consoleserverporttemplates.all()
+                c.to_yaml() for c in self.consoleserverporttemplates.all()
             ]
         if self.powerporttemplates.exists():
             data['power-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'maximum_draw': c.maximum_draw,
-                    'allocated_draw': c.allocated_draw,
-                    'label': c.label,
-                    'description': c.description,
-                }
-                for c in self.powerporttemplates.all()
+                c.to_yaml() for c in self.powerporttemplates.all()
             ]
         if self.poweroutlettemplates.exists():
             data['power-outlets'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'power_port': c.power_port.name if c.power_port else None,
-                    'feed_leg': c.feed_leg,
-                    'label': c.label,
-                    'description': c.description,
-                }
-                for c in self.poweroutlettemplates.all()
+                c.to_yaml() for c in self.poweroutlettemplates.all()
             ]
         if self.interfacetemplates.exists():
             data['interfaces'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'mgmt_only': c.mgmt_only,
-                    'label': c.label,
-                    'description': c.description,
-                    'poe_mode': c.poe_mode,
-                    'poe_type': c.poe_type,
-                }
-                for c in self.interfacetemplates.all()
+                c.to_yaml() for c in self.interfacetemplates.all()
             ]
         if self.frontporttemplates.exists():
             data['front-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'rear_port': c.rear_port.name,
-                    'rear_port_position': c.rear_port_position,
-                    'label': c.label,
-                    'description': c.description,
-                }
-                for c in self.frontporttemplates.all()
+                c.to_yaml() for c in self.frontporttemplates.all()
             ]
         if self.rearporttemplates.exists():
             data['rear-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'positions': c.positions,
-                    'label': c.label,
-                    'description': c.description,
-                }
-                for c in self.rearporttemplates.all()
+                c.to_yaml() for c in self.rearporttemplates.all()
             ]
         if self.modulebaytemplates.exists():
             data['module-bays'] = [
-                {
-                    'name': c.name,
-                    'label': c.label,
-                    'position': c.position,
-                    'description': c.description,
-                }
-                for c in self.modulebaytemplates.all()
+                c.to_yaml() for c in self.modulebaytemplates.all()
             ]
         if self.devicebaytemplates.exists():
             data['device-bays'] = [
-                {
-                    'name': c.name,
-                    'label': c.label,
-                    'description': c.description,
-                }
-                for c in self.devicebaytemplates.all()
+                c.to_yaml() for c in self.devicebaytemplates.all()
             ]
 
         return yaml.dump(dict(data), sort_keys=False)
@@ -406,91 +342,41 @@ class ModuleType(NetBoxModel):
         return reverse('dcim:moduletype', args=[self.pk])
 
     def to_yaml(self):
-        data = OrderedDict((
-            ('manufacturer', self.manufacturer.name),
-            ('model', self.model),
-            ('part_number', self.part_number),
-            ('comments', self.comments),
-        ))
+        data = {
+            'manufacturer': self.manufacturer.name,
+            'model': self.model,
+            'part_number': self.part_number,
+            'comments': self.comments,
+        }
 
         # Component templates
         if self.consoleporttemplates.exists():
             data['console-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'label': c.label,
-                    'description': c.description,
-                }
-                for c in self.consoleporttemplates.all()
+                c.to_yaml() for c in self.consoleporttemplates.all()
             ]
         if self.consoleserverporttemplates.exists():
             data['console-server-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'label': c.label,
-                    'description': c.description,
-                }
-                for c in self.consoleserverporttemplates.all()
+                c.to_yaml() for c in self.consoleserverporttemplates.all()
             ]
         if self.powerporttemplates.exists():
             data['power-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'maximum_draw': c.maximum_draw,
-                    'allocated_draw': c.allocated_draw,
-                    'label': c.label,
-                    'description': c.description,
-                }
-                for c in self.powerporttemplates.all()
+                c.to_yaml() for c in self.powerporttemplates.all()
             ]
         if self.poweroutlettemplates.exists():
             data['power-outlets'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'power_port': c.power_port.name if c.power_port else None,
-                    'feed_leg': c.feed_leg,
-                    'label': c.label,
-                    'description': c.description,
-                }
-                for c in self.poweroutlettemplates.all()
+                c.to_yaml() for c in self.poweroutlettemplates.all()
             ]
         if self.interfacetemplates.exists():
             data['interfaces'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'mgmt_only': c.mgmt_only,
-                    'label': c.label,
-                    'description': c.description,
-                }
-                for c in self.interfacetemplates.all()
+                c.to_yaml() for c in self.interfacetemplates.all()
             ]
         if self.frontporttemplates.exists():
             data['front-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'rear_port': c.rear_port.name,
-                    'rear_port_position': c.rear_port_position,
-                    'label': c.label,
-                    'description': c.description,
-                }
-                for c in self.frontporttemplates.all()
+                c.to_yaml() for c in self.frontporttemplates.all()
             ]
         if self.rearporttemplates.exists():
             data['rear-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'positions': c.positions,
-                    'label': c.label,
-                    'description': c.description,
-                }
-                for c in self.rearporttemplates.all()
+                c.to_yaml() for c in self.rearporttemplates.all()
             ]
 
         return yaml.dump(dict(data), sort_keys=False)

+ 10 - 2
netbox/dcim/tables/modules.py

@@ -14,6 +14,9 @@ class ModuleTypeTable(NetBoxTable):
         linkify=True,
         verbose_name='Module Type'
     )
+    manufacturer = tables.Column(
+        linkify=True
+    )
     instance_count = columns.LinkedCountColumn(
         viewname='dcim:module_list',
         url_params={'module_type_id': 'pk'},
@@ -41,6 +44,10 @@ class ModuleTable(NetBoxTable):
     module_bay = tables.Column(
         linkify=True
     )
+    manufacturer = tables.Column(
+        accessor=tables.A('module_type__manufacturer'),
+        linkify=True
+    )
     module_type = tables.Column(
         linkify=True
     )
@@ -52,8 +59,9 @@ class ModuleTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = Module
         fields = (
-            'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags',
+            'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'comments',
+            'tags',
         )
         default_columns = (
-            'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag',
+            'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag',
         )

+ 6 - 1
netbox/dcim/tables/power.py

@@ -21,6 +21,9 @@ class PowerPanelTable(NetBoxTable):
     site = tables.Column(
         linkify=True
     )
+    location = tables.Column(
+        linkify=True
+    )
     powerfeed_count = columns.LinkedCountColumn(
         viewname='dcim:powerfeed_list',
         url_params={'power_panel_id': 'pk'},
@@ -35,7 +38,9 @@ class PowerPanelTable(NetBoxTable):
 
     class Meta(NetBoxTable.Meta):
         model = PowerPanel
-        fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',)
+        fields = (
+            'pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',
+        )
         default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
 
 

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

@@ -109,6 +109,10 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
         accessor=Accessor('rack__site'),
         linkify=True
     )
+    location = tables.Column(
+        accessor=Accessor('rack__location'),
+        linkify=True
+    )
     rack = tables.Column(
         linkify=True
     )
@@ -123,7 +127,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = RackReservation
         fields = (
-            'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'tenant_group', 'description', 'tags',
+            'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant', 'tenant_group', 'description', 'tags',
             'actions', 'created', 'last_updated',
         )
         default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')

+ 4 - 4
netbox/dcim/tests/test_models.py

@@ -163,8 +163,8 @@ class RackTestCase(TestCase):
         }
         self.assertEqual(rack1_inventory_front[10.0]['device'], device1)
         self.assertEqual(rack1_inventory_front[10.5]['device'], device1)
-        del(rack1_inventory_front[10.0])
-        del(rack1_inventory_front[10.5])
+        del rack1_inventory_front[10.0]
+        del rack1_inventory_front[10.5]
         for u in rack1_inventory_front.values():
             self.assertIsNone(u['device'])
 
@@ -174,8 +174,8 @@ class RackTestCase(TestCase):
         }
         self.assertEqual(rack1_inventory_rear[10.0]['device'], device1)
         self.assertEqual(rack1_inventory_rear[10.5]['device'], device1)
-        del(rack1_inventory_rear[10.0])
-        del(rack1_inventory_rear[10.5])
+        del rack1_inventory_rear[10.0]
+        del rack1_inventory_rear[10.5]
         for u in rack1_inventory_rear.values():
             self.assertIsNone(u['device'])
 

+ 1 - 0
netbox/extras/forms/models.py

@@ -136,6 +136,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
             'http_method': StaticSelect(),
             'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
             'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
+            'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
         }
 
 

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

@@ -181,7 +181,7 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
             model = ct.model_class()
             instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
             for instance in instances:
-                del(instance.custom_field_data[self.name])
+                del instance.custom_field_data[self.name]
             model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
 
     def rename_object_data(self, old_name, new_name):

+ 1 - 1
netbox/extras/tests/test_customfields.py

@@ -992,7 +992,7 @@ class CustomFieldModelTest(TestCase):
         with self.assertRaises(ValidationError):
             site.clean()
 
-        del(site.cf['bar'])
+        del site.cf['bar']
         site.clean()
 
     def test_missing_required_field(self):

+ 1 - 1
netbox/extras/tests/test_registry.py

@@ -30,4 +30,4 @@ class RegistryTest(TestCase):
         reg['foo'] = 123
 
         with self.assertRaises(TypeError):
-            del(reg['foo'])
+            del reg['foo']

+ 1 - 1
netbox/ipam/forms/models.py

@@ -851,7 +851,7 @@ class ServiceCreateForm(ServiceForm):
         # Fields which may be populated from a ServiceTemplate are not required
         for field in ('name', 'protocol', 'ports'):
             self.fields[field].required = False
-            del(self.fields[field].widget.attrs['required'])
+            del self.fields[field].widget.attrs['required']
 
     def clean(self):
         if self.cleaned_data['service_template']:

+ 1 - 1
netbox/ipam/models/ip.py

@@ -373,7 +373,7 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel):
 
         # Cache the original prefix and VRF so we can check if they have changed on post_save
         self._prefix = self.prefix
-        self._vrf = self.vrf
+        self._vrf_id = self.vrf_id
 
     def __str__(self):
         return str(self.prefix)

+ 2 - 2
netbox/ipam/signals.py

@@ -30,14 +30,14 @@ def update_children_depth(prefix):
 def handle_prefix_saved(instance, created, **kwargs):
 
     # Prefix has changed (or new instance has been created)
-    if created or instance.vrf != instance._vrf or instance.prefix != instance._prefix:
+    if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix:
 
         update_parents_children(instance)
         update_children_depth(instance)
 
         # If this is not a new prefix, clean up parent/children of previous prefix
         if not created:
-            old_prefix = Prefix(vrf=instance._vrf, prefix=instance._prefix)
+            old_prefix = Prefix(vrf_id=instance._vrf_id, prefix=instance._prefix)
             update_parents_children(old_prefix)
             update_children_depth(old_prefix)
 

+ 6 - 1
netbox/ipam/tables/ip.py

@@ -369,6 +369,11 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
         orderable=False,
         verbose_name='NAT (Inside)'
     )
+    nat_outside = tables.Column(
+        linkify=True,
+        orderable=False,
+        verbose_name='NAT (Outside)'
+    )
     assigned = columns.BooleanColumn(
         accessor='assigned_object_id',
         linkify=True,
@@ -381,7 +386,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = IPAddress
         fields = (
-            'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'assigned', 'dns_name', 'description',
+            'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'nat_outside', 'assigned', 'dns_name', 'description',
             'tags', 'created', 'last_updated',
         )
         default_columns = (

+ 6 - 2
netbox/ipam/views.py

@@ -334,14 +334,18 @@ class AggregateBulkImportView(generic.BulkImportView):
 
 
 class AggregateBulkEditView(generic.BulkEditView):
-    queryset = Aggregate.objects.all()
+    queryset = Aggregate.objects.annotate(
+        child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
+    )
     filterset = filtersets.AggregateFilterSet
     table = tables.AggregateTable
     form = forms.AggregateBulkEditForm
 
 
 class AggregateBulkDeleteView(generic.BulkDeleteView):
-    queryset = Aggregate.objects.all()
+    queryset = Aggregate.objects.annotate(
+        child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
+    )
     filterset = filtersets.AggregateFilterSet
     table = tables.AggregateTable
 

+ 14 - 20
netbox/utilities/templates/helpers/utilization_graph.html

@@ -1,21 +1,15 @@
-{% if utilization == 0 %}
-  <div class="progress align-items-center justify-content-center">
-    <span class="w-100 text-center">{{ utilization }}%</span>
+<div class="progress">
+  <div
+    role="progressbar"
+    aria-valuemin="0"
+    aria-valuemax="100"
+    aria-valuenow="{{ utilization }}"
+    class="progress-bar {{ bar_class }}"
+    style="width: {{ utilization }}%;"
+  >
+    {% if utilization >= 35 %}{{ utilization|floatformat:1 }}%{% endif %}
   </div>
-{% else %}
-  <div class="progress">
-    <div
-      role="progressbar"
-      aria-valuemin="0"
-      aria-valuemax="100"
-      aria-valuenow="{{ utilization }}"
-      class="progress-bar {{ bar_class }}"
-      style="width: {{ utilization }}%;"
-    >
-      {% if utilization >= 25 %}{{ utilization|floatformat:0 }}%{% endif %}
-    </div>
-    {% if utilization < 25 %}
-      <span class="ps-1">{{ utilization|floatformat:0 }}%</span>
-    {% endif %}
-  </div>
-{% endif %}
+  {% if utilization < 35 %}
+    <span class="ps-1">{{ utilization|floatformat:1 }}%</span>
+  {% endif %}
+</div>

+ 1 - 1
netbox/utilities/utils.py

@@ -149,7 +149,7 @@ def serialize_object(obj, extra=None):
     # Include any tags. Check for tags cached on the instance; fall back to using the manager.
     if is_taggable(obj):
         tags = getattr(obj, '_tags', None) or obj.tags.all()
-        data['tags'] = [tag.name for tag in tags]
+        data['tags'] = sorted([tag.name for tag in tags])
 
     # Append any extra data
     if extra is not None:

+ 6 - 7
netbox/virtualization/tables/virtualmachines.py

@@ -54,6 +54,9 @@ class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable):
         order_by=('primary_ip4', 'primary_ip6'),
         verbose_name='IP Address'
     )
+    contacts = columns.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
         url_name='virtualization:virtualmachine_list'
     )
@@ -62,8 +65,8 @@ class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable):
         model = VirtualMachine
         fields = (
             'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform',
-            'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created',
-            'last_updated',
+            'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'contacts', 'tags',
+            'created', 'last_updated',
         )
         default_columns = (
             'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
@@ -84,9 +87,6 @@ class VMInterfaceTable(BaseInterfaceTable):
     vrf = tables.Column(
         linkify=True
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='virtualization:vminterface_list'
     )
@@ -95,8 +95,7 @@ class VMInterfaceTable(BaseInterfaceTable):
         model = VMInterface
         fields = (
             'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
-            'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'contacts', 'created',
-            'last_updated',
+            'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
         )
         default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')