diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 332a0ad75..c26584f32 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.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
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index ff9b5e358..e6be95e49 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.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
diff --git a/base_requirements.txt b/base_requirements.txt
index 9dc85231b..672ce402c 100644
--- a/base_requirements.txt
+++ b/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
diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md
index 35e9b9a22..bf6f2f848 100644
--- a/docs/release-notes/version-3.2.md
+++ b/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
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 38221b371..12905aec9 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/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_id = DynamicModelMultipleChoiceField(
- queryset=Site.objects.all(),
- required=False,
- query_params={
- 'region_id': '$region_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'),
+ site_id = DynamicModelMultipleChoiceField(
+ queryset=Site.objects.all(),
required=False,
+ query_params={
+ 'region_id': '$region_id',
+ 'group_id': '$site_group_id',
+ },
+ label=_('Site')
+ )
+ location_id = DynamicModelMultipleChoiceField(
+ 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,
diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py
index 043af751d..fb09b9871 100644
--- a/netbox/dcim/forms/models.py
+++ b/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')),
)
diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py
index 8c9ddab19..d2c941b34 100644
--- a/netbox/dcim/forms/object_create.py
+++ b/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: [ge,xe]-0/0/[0-9]
. {module} is accepted as a substitution for
+ the module bay position.
+ """
+ )
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
required=False
diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py
index afbcd6543..606333e83 100644
--- a/netbox/dcim/forms/object_import.py
+++ b/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',
]
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index 92658d310..74252e480 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/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):
"""
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index e88af2d05..91227f1cf 100644
--- a/netbox/dcim/models/devices.py
+++ b/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)
diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py
index 5b009e42e..e40d7bd80 100644
--- a/netbox/dcim/tables/modules.py
+++ b/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',
)
diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py
index 92c4bb0aa..6696d516a 100644
--- a/netbox/dcim/tables/power.py
+++ b/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')
diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py
index 5412e2297..d83f25a5f 100644
--- a/netbox/dcim/tables/racks.py
+++ b/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')
diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py
index 8566f969b..03438a441 100644
--- a/netbox/dcim/tests/test_models.py
+++ b/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'])
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index ec3e9152e..0bdca686d 100644
--- a/netbox/dcim/views.py
+++ b/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 {}'.format(device.get_absolute_url(), escape(device))
+ msg = f'Added member {escape(device)}'
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():
diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py
index 112911f42..82575de21 100644
--- a/netbox/extras/forms/models.py
+++ b/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'}),
}
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index 6a8c1dacf..b7d77e550 100644
--- a/netbox/extras/models/customfields.py
+++ b/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):
diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py
index 8dcb53b09..946999bc2 100644
--- a/netbox/extras/tests/test_customfields.py
+++ b/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):
diff --git a/netbox/extras/tests/test_registry.py b/netbox/extras/tests/test_registry.py
index 53ba6584a..38a6b9f83 100644
--- a/netbox/extras/tests/test_registry.py
+++ b/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']
diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py
index e86abc672..d3421f22b 100644
--- a/netbox/ipam/forms/models.py
+++ b/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']:
diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py
index a3b8fb2c1..d1538953a 100644
--- a/netbox/ipam/models/ip.py
+++ b/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)
diff --git a/netbox/ipam/signals.py b/netbox/ipam/signals.py
index 3e8b86050..8555f5e67 100644
--- a/netbox/ipam/signals.py
+++ b/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)
diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py
index bec05eeff..087d0de73 100644
--- a/netbox/ipam/tables/ip.py
+++ b/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 = (
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 706670cad..9ae7cd4d7 100644
--- a/netbox/ipam/views.py
+++ b/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
diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py
index b3bfe06c0..ea2feb8de 100644
--- a/netbox/netbox/models/__init__.py
+++ b/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."
})
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 094771581..12ab44399 100644
--- a/netbox/netbox/settings.py
+++ b/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()
diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py
index 7da241566..f78b9f37c 100644
--- a/netbox/netbox/tables/columns.py
+++ b/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'{item}'
- return item
+ return f'{escape(item)}'
+ 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('')
if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
- return mark_safe(f'{value}')
+ return mark_safe(f'{escape(value)}')
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))
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
index 5bdf5cbc9..7e07c57d0 100644
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/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():
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
index 4ebfe71cc..88e078ae3 100644
--- a/netbox/netbox/views/generic/object_views.py
+++ b/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 = '{} {}'.format(msg, obj.get_absolute_url(), escape(obj))
+ msg = mark_safe(f'{msg} {escape(obj)}')
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
diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py
index c3495afdf..66ef92ab7 100644
--- a/netbox/users/api/views.py
+++ b/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")
diff --git a/netbox/users/views.py b/netbox/users/views.py
index 344f375fc..f08cac844 100644
--- a/netbox/users/views.py
+++ b/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:
diff --git a/netbox/utilities/templates/helpers/utilization_graph.html b/netbox/utilities/templates/helpers/utilization_graph.html
index e6829befc..967ac8a87 100644
--- a/netbox/utilities/templates/helpers/utilization_graph.html
+++ b/netbox/utilities/templates/helpers/utilization_graph.html
@@ -1,21 +1,15 @@
-{% if utilization == 0 %}
-