소스 검색

Merge pull request #9955 from netbox-community/develop

Release v3.2.8
Jeremy Stretch 3 년 전
부모
커밋
f1877c0c5f
37개의 변경된 파일288개의 추가작업 그리고 244개의 파일을 삭제
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 1 1
      base_requirements.txt
  4. 28 0
      docs/release-notes/version-3.2.md
  5. 21 8
      netbox/dcim/forms/filtersets.py
  6. 1 1
      netbox/dcim/forms/models.py
  7. 8 0
      netbox/dcim/forms/object_create.py
  8. 2 2
      netbox/dcim/forms/object_import.py
  9. 85 1
      netbox/dcim/models/device_component_templates.py
  10. 33 146
      netbox/dcim/models/devices.py
  11. 10 2
      netbox/dcim/tables/modules.py
  12. 6 1
      netbox/dcim/tables/power.py
  13. 5 1
      netbox/dcim/tables/racks.py
  14. 2 2
      netbox/dcim/tests/test_models.py
  15. 3 3
      netbox/dcim/views.py
  16. 1 0
      netbox/extras/forms/models.py
  17. 1 1
      netbox/extras/models/customfields.py
  18. 1 1
      netbox/extras/tests/test_customfields.py
  19. 1 1
      netbox/extras/tests/test_registry.py
  20. 1 1
      netbox/ipam/forms/models.py
  21. 1 1
      netbox/ipam/models/ip.py
  22. 2 2
      netbox/ipam/signals.py
  23. 6 1
      netbox/ipam/tables/ip.py
  24. 6 2
      netbox/ipam/views.py
  25. 2 2
      netbox/netbox/models/__init__.py
  26. 1 1
      netbox/netbox/settings.py
  27. 6 5
      netbox/netbox/tables/columns.py
  28. 9 8
      netbox/netbox/views/generic/bulk_views.py
  29. 3 3
      netbox/netbox/views/generic/object_views.py
  30. 7 5
      netbox/users/api/views.py
  31. 2 1
      netbox/users/views.py
  32. 14 20
      netbox/utilities/templates/helpers/utilization_graph.html
  33. 2 2
      netbox/utilities/templatetags/builtins/filters.py
  34. 1 3
      netbox/utilities/templatetags/helpers.py
  35. 1 1
      netbox/utilities/utils.py
  36. 7 7
      netbox/virtualization/tables/virtualmachines.py
  37. 6 6
      requirements.txt

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.2.7
+      placeholder: v3.2.8
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.2.7
+      placeholder: v3.2.8
     validations:
       required: true
   - type: dropdown

+ 1 - 1
base_requirements.txt

@@ -4,7 +4,7 @@ bleach
 
 # The Python web framework on which NetBox is built
 # https://github.com/django/django
-Django
+Django<4.1
 
 # Django middleware which permits cross-domain API requests
 # https://github.com/OttoYiu/django-cors-headers

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

@@ -1,5 +1,33 @@
 # NetBox v3.2
 
+## v3.2.8 (2022-08-08)
+
+### 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
+* [#9906](https://github.com/netbox-community/netbox/issues/9906) - Include `color` attribute in front & rear port YAML import/export
+
+### Bug Fixes
+
+* [#9827](https://github.com/netbox-community/netbox/issues/9827) - Fix assignment of module bay position during bulk creation
+* [#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
+* [#9919](https://github.com/netbox-community/netbox/issues/9919) - Fix potential XSS avenue via linked objects in tables
+* [#9948](https://github.com/netbox-community/netbox/issues/9948) - Fix TypeError exception when requesting API tokens list as non-authenticated user
+* [#9949](https://github.com/netbox-community/netbox/issues/9949) - Fix KeyError exception resulting from invalid API token provisioning request
+* [#9950](https://github.com/netbox-community/netbox/issues/9950) - Prevent redirection to arbitrary URLs via `next` parameter on login URL
+* [#9952](https://github.com/netbox-community/netbox/issues/9952) - Prevent InvalidMove when attempting to assign a nested child object as parent
+
+---
+
 ## v3.2.7 (2022-07-20)
 
 ### Enhancements

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

@@ -287,7 +287,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(
@@ -295,25 +295,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

@@ -321,7 +321,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

+ 2 - 2
netbox/dcim/forms/object_import.py

@@ -146,7 +146,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
         model = FrontPortTemplate
         fields = [
-            'device_type', 'module_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description',
+            'device_type', 'module_type', 'name', 'type', 'color', 'rear_port', 'rear_port_position', 'label', 'description',
         ]
 
 
@@ -158,7 +158,7 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
         model = RearPortTemplate
         fields = [
-            'device_type', 'module_type', 'name', 'type', 'positions', 'label', 'description',
+            'device_type', 'module_type', 'name', 'type', 'color', 'positions', 'label', 'description',
         ]
 
 

+ 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):
     """
@@ -337,6 +376,15 @@ 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,
+        }
+
 
 class FrontPortTemplate(ModularComponentTemplateModel):
     """
@@ -410,6 +458,17 @@ class FrontPortTemplate(ModularComponentTemplateModel):
             **kwargs
         )
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'color': self.color,
+            'rear_port': self.rear_port.name,
+            'rear_port_position': self.rear_port_position,
+            'label': self.label,
+            'description': self.description,
+        }
+
 
 class RearPortTemplate(ModularComponentTemplateModel):
     """
@@ -449,6 +508,16 @@ class RearPortTemplate(ModularComponentTemplateModel):
             **kwargs
         )
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'color': self.color,
+            'positions': self.positions,
+            'label': self.label,
+            'description': self.description,
+        }
+
 
 class ModuleBayTemplate(ComponentTemplateModel):
     """
@@ -474,6 +543,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):
     """
@@ -498,6 +575,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 - 146
netbox/dcim/models/devices.py

@@ -1,5 +1,3 @@
-from collections import OrderedDict
-
 import yaml
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
@@ -161,115 +159,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', 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': 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,
-                }
-                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)
@@ -395,91 +332,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')

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

@@ -194,14 +194,14 @@ class RackTestCase(TestCase):
         # Validate inventory (front face)
         rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
         self.assertEqual(rack1_inventory_front[-10]['device'], device1)
-        del(rack1_inventory_front[-10])
+        del rack1_inventory_front[-10]
         for u in rack1_inventory_front:
             self.assertIsNone(u['device'])
 
         # Validate inventory (rear face)
         rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
         self.assertEqual(rack1_inventory_rear[-10]['device'], device1)
-        del(rack1_inventory_rear[-10])
+        del rack1_inventory_rear[-10]
         for u in rack1_inventory_rear:
             self.assertIsNone(u['device'])
 

+ 3 - 3
netbox/dcim/views.py

@@ -2707,6 +2707,7 @@ class DeviceBulkAddModuleBayView(generic.BulkComponentCreateView):
     filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
+    patterned_fields = ('name', 'label', 'position')
 
 
 class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView):
@@ -3082,7 +3083,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
             if membership_form.is_valid():
 
                 membership_form.save()
-                msg = 'Added member <a href="{}">{}</a>'.format(device.get_absolute_url(), escape(device))
+                msg = f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
                 messages.success(request, mark_safe(msg))
 
                 if '_addanother' in request.POST:
@@ -3127,8 +3128,7 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
         # Protect master device from being removed
         virtual_chassis = VirtualChassis.objects.filter(master=device).first()
         if virtual_chassis is not None:
-            msg = 'Unable to remove master device {} from the virtual chassis.'.format(escape(device))
-            messages.error(request, mark_safe(msg))
+            messages.error(request, f'Unable to remove master device {device} from the virtual chassis.')
             return redirect(device.get_absolute_url())
 
         if form.is_valid():

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

@@ -133,6 +133,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

@@ -169,7 +169,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

@@ -848,7 +848,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

@@ -333,14 +333,18 @@ class AggregateBulkImportView(generic.BulkImportView):
 
 
 class AggregateBulkEditView(generic.BulkEditView):
-    queryset = Aggregate.objects.prefetch_related('rir')
+    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.prefetch_related('rir')
+    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
 

+ 2 - 2
netbox/netbox/models/__init__.py

@@ -89,9 +89,9 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
         super().clean()
 
         # An MPTT model cannot be its own parent
-        if self.pk and self.parent_id == self.pk:
+        if self.pk and self.parent and self.parent in self.get_descendants(include_self=True):
             raise ValidationError({
-                "parent": "Cannot assign self as parent."
+                "parent": f"Cannot assign self or child {self._meta.verbose_name} as parent."
             })
 
 

+ 1 - 1
netbox/netbox/settings.py

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
 # Environment setup
 #
 
-VERSION = '3.2.7'
+VERSION = '3.2.8'
 
 # Hostname
 HOSTNAME = platform.node()

+ 6 - 5
netbox/netbox/tables/columns.py

@@ -7,6 +7,7 @@ from django.contrib.auth.models import AnonymousUser
 from django.db.models import DateField, DateTimeField
 from django.template import Context, Template
 from django.urls import reverse
+from django.utils.html import escape
 from django.utils.formats import date_format
 from django.utils.safestring import mark_safe
 from django_tables2.columns import library
@@ -428,8 +429,8 @@ class CustomFieldColumn(tables.Column):
     @staticmethod
     def _likify_item(item):
         if hasattr(item, 'get_absolute_url'):
-            return f'<a href="{item.get_absolute_url()}">{item}</a>'
-        return item
+            return f'<a href="{item.get_absolute_url()}">{escape(item)}</a>'
+        return escape(item)
 
     def render(self, value):
         if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True:
@@ -437,13 +438,13 @@ class CustomFieldColumn(tables.Column):
         if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
             return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
         if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
-            return mark_safe(f'<a href="{value}">{value}</a>')
+            return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
         if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
             return ', '.join(v for v in value)
         if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
-            return mark_safe(', '.join([
+            return mark_safe(', '.join(
                 self._likify_item(obj) for obj in self.customfield.deserialize(value)
-            ]))
+            ))
         if value is not None:
             obj = self.customfield.deserialize(value)
             return mark_safe(self._likify_item(obj))

+ 9 - 8
netbox/netbox/views/generic/bulk_views.py

@@ -795,6 +795,7 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
     model_form = None
     filterset = None
     table = None
+    patterned_fields = ('name', 'label')
 
     def get_required_permission(self):
         return f'dcim.add_{self.queryset.model._meta.model_name}'
@@ -830,16 +831,16 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
 
                         for obj in data['pk']:
 
-                            names = data['name_pattern']
-                            labels = data['label_pattern'] if 'label_pattern' in data else None
-                            for i, name in enumerate(names):
-                                label = labels[i] if labels else None
-
+                            pattern_count = len(data[f'{self.patterned_fields[0]}_pattern'])
+                            for i in range(pattern_count):
                                 component_data = {
-                                    self.parent_field: obj.pk,
-                                    'name': name,
-                                    'label': label
+                                    self.parent_field: obj.pk
                                 }
+
+                                for field_name in self.patterned_fields:
+                                    if data.get(f'{field_name}_pattern'):
+                                        component_data[field_name] = data[f'{field_name}_pattern'][i]
+
                                 component_data.update(data)
                                 component_form = self.model_form(component_data)
                                 if component_form.is_valid():

+ 3 - 3
netbox/netbox/views/generic/object_views.py

@@ -386,10 +386,10 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
                 )
                 logger.info(f"{msg} {obj} (PK: {obj.pk})")
                 if hasattr(obj, 'get_absolute_url'):
-                    msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
+                    msg = mark_safe(f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>')
                 else:
-                    msg = '{} {}'.format(msg, escape(obj))
-                messages.success(request, mark_safe(msg))
+                    msg = f'{msg} {obj}'
+                messages.success(request, msg)
 
                 if '_addanother' in request.POST:
                     redirect_url = request.path

+ 7 - 5
netbox/users/api/views.py

@@ -58,6 +58,8 @@ class TokenViewSet(NetBoxModelViewSet):
         # Workaround for schema generation (drf_yasg)
         if getattr(self, 'swagger_fake_view', False):
             return queryset.none()
+        if not self.request.user.is_authenticated:
+            return queryset.none()
         if self.request.user.is_superuser:
             return queryset
         return queryset.filter(user=self.request.user)
@@ -74,11 +76,11 @@ class TokenProvisionView(APIView):
         serializer.is_valid()
 
         # Authenticate the user account based on the provided credentials
-        user = authenticate(
-            request=request,
-            username=serializer.data['username'],
-            password=serializer.data['password']
-        )
+        username = serializer.data.get('username')
+        password = serializer.data.get('password')
+        if not username or not password:
+            raise AuthenticationFailed("Username and password must be provided to provision a token.")
+        user = authenticate(request=request, username=username, password=password)
         if user is None:
             raise AuthenticationFailed("Invalid username/password")
 

+ 2 - 1
netbox/users/views.py

@@ -10,6 +10,7 @@ from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.utils.decorators import method_decorator
+from django.utils.http import url_has_allowed_host_and_scheme
 from django.views.decorators.debug import sensitive_post_parameters
 from django.views.generic import View
 from social_core.backends.utils import load_backends
@@ -91,7 +92,7 @@ class LoginView(View):
         data = request.POST if request.method == "POST" else request.GET
         redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
 
-        if redirect_url and redirect_url.startswith('/'):
+        if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
             logger.debug(f"Redirecting user to {redirect_url}")
         else:
             if redirect_url:

+ 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>

+ 2 - 2
netbox/utilities/templatetags/builtins/filters.py

@@ -86,8 +86,8 @@ def placeholder(value):
     """
     if value not in ('', None):
         return value
-    placeholder = '<span class="text-muted">&mdash;</span>'
-    return mark_safe(placeholder)
+
+    return mark_safe('<span class="text-muted">&mdash;</span>')
 
 
 @register.filter()

+ 1 - 3
netbox/utilities/templatetags/helpers.py

@@ -109,9 +109,7 @@ def annotated_date(date_value):
         long_ts = date(date_value, 'DATETIME_FORMAT')
         short_ts = date(date_value, 'SHORT_DATETIME_FORMAT')
 
-    span = f'<span title="{long_ts}">{short_ts}</span>'
-
-    return mark_safe(span)
+    return mark_safe(f'<span title="{long_ts}">{short_ts}</span>')
 
 
 @register.simple_tag

+ 1 - 1
netbox/utilities/utils.py

@@ -148,7 +148,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:

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

@@ -48,6 +48,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'
     )
@@ -55,8 +58,9 @@ class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = VirtualMachine
         fields = (
-            'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'tenant_group', 'platform', 'vcpus', 'memory', 'disk',
-            'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
+            'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'tenant_group', 'platform', 'vcpus', 'memory',
+            'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'contacts', 'tags', 'created',
+            'last_updated',
         )
         default_columns = (
             'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
@@ -77,9 +81,6 @@ class VMInterfaceTable(BaseInterfaceTable):
     vrf = tables.Column(
         linkify=True
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='virtualization:vminterface_list'
     )
@@ -88,8 +89,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')
 

+ 6 - 6
requirements.txt

@@ -1,5 +1,5 @@
 bleach==5.0.1
-Django==4.0.6
+Django==4.0.7
 django-cors-headers==3.13.0
 django-debug-toolbar==3.5.0
 django-filter==22.1
@@ -13,22 +13,22 @@ django-tables2==2.4.1
 django-taggit==2.1.0
 django-timezone-field==5.0
 djangorestframework==3.13.1
-drf-yasg[validation]==1.20.0
+drf-yasg[validation]==1.21.3
 graphene-django==2.15.0
 gunicorn==20.1.0
 Jinja2==3.1.2
-Markdown==3.3.7
-markdown-include==0.6.0
+Markdown==3.4.1
+markdown-include==0.7.0
 mkdocs-material==8.3.9
 mkdocstrings[python-legacy]==0.19.0
 netaddr==0.8.0
 Pillow==9.2.0
 psycopg2-binary==2.9.3
 PyYAML==6.0
-sentry-sdk==1.7.0
+sentry-sdk==1.9.2
 social-auth-app-django==5.0.0
 social-auth-core==4.3.0
-svgwrite==1.4.2
+svgwrite==1.4.3
 tablib==3.2.1
 tzdata==2022.1