Explorar el Código

Merge pull request #9955 from netbox-community/develop

Release v3.2.8
Jeremy Stretch hace 3 años
padre
commit
f1877c0c5f
Se han modificado 37 ficheros con 288 adiciones y 244 borrados
  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:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.2.7
+      placeholder: v3.2.8
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

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

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

+ 1 - 1
base_requirements.txt

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

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

@@ -1,5 +1,33 @@
 # NetBox v3.2
 # 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)
 ## v3.2.7 (2022-07-20)
 
 
 ### Enhancements
 ### Enhancements

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

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

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

@@ -321,7 +321,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
     )
     )
 
 
     fieldsets = (
     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')),
         ('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.
     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(
     device_type = DynamicModelChoiceField(
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),
         required=False
         required=False

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

@@ -146,7 +146,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
         fields = [
         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:
     class Meta:
         model = RearPortTemplate
         model = RearPortTemplate
         fields = [
         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'
         related_name='%(class)ss'
     )
     )
     name = models.CharField(
     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(
     _name = NaturalOrderingField(
         target_field='name',
         target_field='name',
@@ -157,6 +160,14 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
             **kwargs
             **kwargs
         )
         )
 
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'label': self.label,
+            'description': self.description,
+        }
+
 
 
 class ConsoleServerPortTemplate(ModularComponentTemplateModel):
 class ConsoleServerPortTemplate(ModularComponentTemplateModel):
     """
     """
@@ -185,6 +196,14 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
             **kwargs
             **kwargs
         )
         )
 
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'label': self.label,
+            'description': self.description,
+        }
+
 
 
 class PowerPortTemplate(ModularComponentTemplateModel):
 class PowerPortTemplate(ModularComponentTemplateModel):
     """
     """
@@ -236,6 +255,16 @@ class PowerPortTemplate(ModularComponentTemplateModel):
                     'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
                     '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):
 class PowerOutletTemplate(ModularComponentTemplateModel):
     """
     """
@@ -298,6 +327,16 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
             **kwargs
             **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):
 class InterfaceTemplate(ModularComponentTemplateModel):
     """
     """
@@ -337,6 +376,15 @@ class InterfaceTemplate(ModularComponentTemplateModel):
             **kwargs
             **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):
 class FrontPortTemplate(ModularComponentTemplateModel):
     """
     """
@@ -410,6 +458,17 @@ class FrontPortTemplate(ModularComponentTemplateModel):
             **kwargs
             **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):
 class RearPortTemplate(ModularComponentTemplateModel):
     """
     """
@@ -449,6 +508,16 @@ class RearPortTemplate(ModularComponentTemplateModel):
             **kwargs
             **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):
 class ModuleBayTemplate(ComponentTemplateModel):
     """
     """
@@ -474,6 +543,14 @@ class ModuleBayTemplate(ComponentTemplateModel):
             position=self.position
             position=self.position
         )
         )
 
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'label': self.label,
+            'position': self.position,
+            'description': self.description,
+        }
+
 
 
 class DeviceBayTemplate(ComponentTemplateModel):
 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."
                 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):
 class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
     """
     """

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

@@ -1,5 +1,3 @@
-from collections import OrderedDict
-
 import yaml
 import yaml
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
@@ -161,115 +159,54 @@ class DeviceType(NetBoxModel):
         return reverse('dcim:devicetype', args=[self.pk])
         return reverse('dcim:devicetype', args=[self.pk])
 
 
     def to_yaml(self):
     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
         # Component templates
         if self.consoleporttemplates.exists():
         if self.consoleporttemplates.exists():
             data['console-ports'] = [
             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():
         if self.consoleserverporttemplates.exists():
             data['console-server-ports'] = [
             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():
         if self.powerporttemplates.exists():
             data['power-ports'] = [
             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():
         if self.poweroutlettemplates.exists():
             data['power-outlets'] = [
             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():
         if self.interfacetemplates.exists():
             data['interfaces'] = [
             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():
         if self.frontporttemplates.exists():
             data['front-ports'] = [
             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():
         if self.rearporttemplates.exists():
             data['rear-ports'] = [
             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():
         if self.modulebaytemplates.exists():
             data['module-bays'] = [
             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():
         if self.devicebaytemplates.exists():
             data['device-bays'] = [
             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)
         return yaml.dump(dict(data), sort_keys=False)
@@ -395,91 +332,41 @@ class ModuleType(NetBoxModel):
         return reverse('dcim:moduletype', args=[self.pk])
         return reverse('dcim:moduletype', args=[self.pk])
 
 
     def to_yaml(self):
     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
         # Component templates
         if self.consoleporttemplates.exists():
         if self.consoleporttemplates.exists():
             data['console-ports'] = [
             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():
         if self.consoleserverporttemplates.exists():
             data['console-server-ports'] = [
             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():
         if self.powerporttemplates.exists():
             data['power-ports'] = [
             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():
         if self.poweroutlettemplates.exists():
             data['power-outlets'] = [
             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():
         if self.interfacetemplates.exists():
             data['interfaces'] = [
             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():
         if self.frontporttemplates.exists():
             data['front-ports'] = [
             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():
         if self.rearporttemplates.exists():
             data['rear-ports'] = [
             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)
         return yaml.dump(dict(data), sort_keys=False)

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

@@ -14,6 +14,9 @@ class ModuleTypeTable(NetBoxTable):
         linkify=True,
         linkify=True,
         verbose_name='Module Type'
         verbose_name='Module Type'
     )
     )
+    manufacturer = tables.Column(
+        linkify=True
+    )
     instance_count = columns.LinkedCountColumn(
     instance_count = columns.LinkedCountColumn(
         viewname='dcim:module_list',
         viewname='dcim:module_list',
         url_params={'module_type_id': 'pk'},
         url_params={'module_type_id': 'pk'},
@@ -41,6 +44,10 @@ class ModuleTable(NetBoxTable):
     module_bay = tables.Column(
     module_bay = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    manufacturer = tables.Column(
+        accessor=tables.A('module_type__manufacturer'),
+        linkify=True
+    )
     module_type = tables.Column(
     module_type = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -52,8 +59,9 @@ class ModuleTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Module
         model = Module
         fields = (
         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 = (
         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(
     site = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    location = tables.Column(
+        linkify=True
+    )
     powerfeed_count = columns.LinkedCountColumn(
     powerfeed_count = columns.LinkedCountColumn(
         viewname='dcim:powerfeed_list',
         viewname='dcim:powerfeed_list',
         url_params={'power_panel_id': 'pk'},
         url_params={'power_panel_id': 'pk'},
@@ -35,7 +38,9 @@ class PowerPanelTable(NetBoxTable):
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = PowerPanel
         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')
         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'),
         accessor=Accessor('rack__site'),
         linkify=True
         linkify=True
     )
     )
+    location = tables.Column(
+        accessor=Accessor('rack__location'),
+        linkify=True
+    )
     rack = tables.Column(
     rack = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -123,7 +127,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = RackReservation
         model = RackReservation
         fields = (
         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',
             'actions', 'created', 'last_updated',
         )
         )
         default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')
         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)
         # Validate inventory (front face)
         rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
         rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
         self.assertEqual(rack1_inventory_front[-10]['device'], device1)
         self.assertEqual(rack1_inventory_front[-10]['device'], device1)
-        del(rack1_inventory_front[-10])
+        del rack1_inventory_front[-10]
         for u in rack1_inventory_front:
         for u in rack1_inventory_front:
             self.assertIsNone(u['device'])
             self.assertIsNone(u['device'])
 
 
         # Validate inventory (rear face)
         # Validate inventory (rear face)
         rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
         rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
         self.assertEqual(rack1_inventory_rear[-10]['device'], device1)
         self.assertEqual(rack1_inventory_rear[-10]['device'], device1)
-        del(rack1_inventory_rear[-10])
+        del rack1_inventory_rear[-10]
         for u in rack1_inventory_rear:
         for u in rack1_inventory_rear:
             self.assertIsNone(u['device'])
             self.assertIsNone(u['device'])
 
 

+ 3 - 3
netbox/dcim/views.py

@@ -2707,6 +2707,7 @@ class DeviceBulkAddModuleBayView(generic.BulkComponentCreateView):
     filterset = filtersets.DeviceFilterSet
     filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
+    patterned_fields = ('name', 'label', 'position')
 
 
 
 
 class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView):
 class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView):
@@ -3082,7 +3083,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
             if membership_form.is_valid():
             if membership_form.is_valid():
 
 
                 membership_form.save()
                 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))
                 messages.success(request, mark_safe(msg))
 
 
                 if '_addanother' in request.POST:
                 if '_addanother' in request.POST:
@@ -3127,8 +3128,7 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
         # Protect master device from being removed
         # Protect master device from being removed
         virtual_chassis = VirtualChassis.objects.filter(master=device).first()
         virtual_chassis = VirtualChassis.objects.filter(master=device).first()
         if virtual_chassis is not None:
         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())
             return redirect(device.get_absolute_url())
 
 
         if form.is_valid():
         if form.is_valid():

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

@@ -133,6 +133,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
             'http_method': StaticSelect(),
             'http_method': StaticSelect(),
             'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
             'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
             'body_template': 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()
             model = ct.model_class()
             instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
             instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
             for instance in instances:
             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)
             model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
 
 
     def rename_object_data(self, old_name, new_name):
     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):
         with self.assertRaises(ValidationError):
             site.clean()
             site.clean()
 
 
-        del(site.cf['bar'])
+        del site.cf['bar']
         site.clean()
         site.clean()
 
 
     def test_missing_required_field(self):
     def test_missing_required_field(self):

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

@@ -30,4 +30,4 @@ class RegistryTest(TestCase):
         reg['foo'] = 123
         reg['foo'] = 123
 
 
         with self.assertRaises(TypeError):
         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
         # Fields which may be populated from a ServiceTemplate are not required
         for field in ('name', 'protocol', 'ports'):
         for field in ('name', 'protocol', 'ports'):
             self.fields[field].required = False
             self.fields[field].required = False
-            del(self.fields[field].widget.attrs['required'])
+            del self.fields[field].widget.attrs['required']
 
 
     def clean(self):
     def clean(self):
         if self.cleaned_data['service_template']:
         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
         # Cache the original prefix and VRF so we can check if they have changed on post_save
         self._prefix = self.prefix
         self._prefix = self.prefix
-        self._vrf = self.vrf
+        self._vrf_id = self.vrf_id
 
 
     def __str__(self):
     def __str__(self):
         return str(self.prefix)
         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):
 def handle_prefix_saved(instance, created, **kwargs):
 
 
     # Prefix has changed (or new instance has been created)
     # 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_parents_children(instance)
         update_children_depth(instance)
         update_children_depth(instance)
 
 
         # If this is not a new prefix, clean up parent/children of previous prefix
         # If this is not a new prefix, clean up parent/children of previous prefix
         if not created:
         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_parents_children(old_prefix)
             update_children_depth(old_prefix)
             update_children_depth(old_prefix)
 
 

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

@@ -369,6 +369,11 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
         orderable=False,
         orderable=False,
         verbose_name='NAT (Inside)'
         verbose_name='NAT (Inside)'
     )
     )
+    nat_outside = tables.Column(
+        linkify=True,
+        orderable=False,
+        verbose_name='NAT (Outside)'
+    )
     assigned = columns.BooleanColumn(
     assigned = columns.BooleanColumn(
         accessor='assigned_object_id',
         accessor='assigned_object_id',
         linkify=True,
         linkify=True,
@@ -381,7 +386,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = IPAddress
         model = IPAddress
         fields = (
         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',
             'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (

+ 6 - 2
netbox/ipam/views.py

@@ -333,14 +333,18 @@ class AggregateBulkImportView(generic.BulkImportView):
 
 
 
 
 class AggregateBulkEditView(generic.BulkEditView):
 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
     filterset = filtersets.AggregateFilterSet
     table = tables.AggregateTable
     table = tables.AggregateTable
     form = forms.AggregateBulkEditForm
     form = forms.AggregateBulkEditForm
 
 
 
 
 class AggregateBulkDeleteView(generic.BulkDeleteView):
 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
     filterset = filtersets.AggregateFilterSet
     table = tables.AggregateTable
     table = tables.AggregateTable
 
 

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

@@ -89,9 +89,9 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
         super().clean()
         super().clean()
 
 
         # An MPTT model cannot be its own parent
         # 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({
             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
 # Environment setup
 #
 #
 
 
-VERSION = '3.2.7'
+VERSION = '3.2.8'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 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.db.models import DateField, DateTimeField
 from django.template import Context, Template
 from django.template import Context, Template
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.html import escape
 from django.utils.formats import date_format
 from django.utils.formats import date_format
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django_tables2.columns import library
 from django_tables2.columns import library
@@ -428,8 +429,8 @@ class CustomFieldColumn(tables.Column):
     @staticmethod
     @staticmethod
     def _likify_item(item):
     def _likify_item(item):
         if hasattr(item, 'get_absolute_url'):
         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):
     def render(self, value):
         if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True:
         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:
         if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
             return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
             return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
         if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
         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:
         if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
             return ', '.join(v for v in value)
             return ', '.join(v for v in value)
         if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
         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)
                 self._likify_item(obj) for obj in self.customfield.deserialize(value)
-            ]))
+            ))
         if value is not None:
         if value is not None:
             obj = self.customfield.deserialize(value)
             obj = self.customfield.deserialize(value)
             return mark_safe(self._likify_item(obj))
             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
     model_form = None
     filterset = None
     filterset = None
     table = None
     table = None
+    patterned_fields = ('name', 'label')
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return f'dcim.add_{self.queryset.model._meta.model_name}'
         return f'dcim.add_{self.queryset.model._meta.model_name}'
@@ -830,16 +831,16 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
 
 
                         for obj in data['pk']:
                         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 = {
                                 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_data.update(data)
                                 component_form = self.model_form(component_data)
                                 component_form = self.model_form(component_data)
                                 if component_form.is_valid():
                                 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})")
                 logger.info(f"{msg} {obj} (PK: {obj.pk})")
                 if hasattr(obj, 'get_absolute_url'):
                 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:
                 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:
                 if '_addanother' in request.POST:
                     redirect_url = request.path
                     redirect_url = request.path

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

@@ -58,6 +58,8 @@ class TokenViewSet(NetBoxModelViewSet):
         # Workaround for schema generation (drf_yasg)
         # Workaround for schema generation (drf_yasg)
         if getattr(self, 'swagger_fake_view', False):
         if getattr(self, 'swagger_fake_view', False):
             return queryset.none()
             return queryset.none()
+        if not self.request.user.is_authenticated:
+            return queryset.none()
         if self.request.user.is_superuser:
         if self.request.user.is_superuser:
             return queryset
             return queryset
         return queryset.filter(user=self.request.user)
         return queryset.filter(user=self.request.user)
@@ -74,11 +76,11 @@ class TokenProvisionView(APIView):
         serializer.is_valid()
         serializer.is_valid()
 
 
         # Authenticate the user account based on the provided credentials
         # 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:
         if user is None:
             raise AuthenticationFailed("Invalid username/password")
             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.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.decorators import method_decorator
 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.decorators.debug import sensitive_post_parameters
 from django.views.generic import View
 from django.views.generic import View
 from social_core.backends.utils import load_backends
 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
         data = request.POST if request.method == "POST" else request.GET
         redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
         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}")
             logger.debug(f"Redirecting user to {redirect_url}")
         else:
         else:
             if redirect_url:
             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>
   </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):
     if value not in ('', None):
         return value
         return value
-    placeholder = '<span class="text-muted">&mdash;</span>'
-    return mark_safe(placeholder)
+
+    return mark_safe('<span class="text-muted">&mdash;</span>')
 
 
 
 
 @register.filter()
 @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')
         long_ts = date(date_value, 'DATETIME_FORMAT')
         short_ts = date(date_value, 'SHORT_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
 @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.
     # Include any tags. Check for tags cached on the instance; fall back to using the manager.
     if is_taggable(obj):
     if is_taggable(obj):
         tags = getattr(obj, '_tags', None) or obj.tags.all()
         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
     # Append any extra data
     if extra is not None:
     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'),
         order_by=('primary_ip4', 'primary_ip6'),
         verbose_name='IP Address'
         verbose_name='IP Address'
     )
     )
+    contacts = columns.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='virtualization:virtualmachine_list'
         url_name='virtualization:virtualmachine_list'
     )
     )
@@ -55,8 +58,9 @@ class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = VirtualMachine
         model = VirtualMachine
         fields = (
         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 = (
         default_columns = (
             'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
             'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
@@ -77,9 +81,6 @@ class VMInterfaceTable(BaseInterfaceTable):
     vrf = tables.Column(
     vrf = tables.Column(
         linkify=True
         linkify=True
     )
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='virtualization:vminterface_list'
         url_name='virtualization:vminterface_list'
     )
     )
@@ -88,8 +89,7 @@ class VMInterfaceTable(BaseInterfaceTable):
         model = VMInterface
         model = VMInterface
         fields = (
         fields = (
             'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
             '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')
         default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
 
 

+ 6 - 6
requirements.txt

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