Merge pull request #9955 from netbox-community/develop

Release v3.2.8
This commit is contained in:
Jeremy Stretch 2022-08-08 15:32:38 -04:00 committed by GitHub
commit f1877c0c5f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 291 additions and 247 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id'
},
label=_('Site')
)
site_group_id = DynamicModelMultipleChoiceField( site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
required=False, required=False,
label=_('Site group') label=_('Site group')
) )
location_id = DynamicModelMultipleChoiceField( site_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.prefetch_related('site'), queryset=Site.objects.all(),
required=False, 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'), 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,

View File

@ -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')),
) )

View File

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

View File

@ -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',
] ]

View File

@ -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):
""" """

View File

@ -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(( data = {
('manufacturer', self.manufacturer.name), 'manufacturer': self.manufacturer.name,
('model', self.model), 'model': self.model,
('slug', self.slug), 'slug': self.slug,
('part_number', self.part_number), 'part_number': self.part_number,
('u_height', self.u_height), 'u_height': self.u_height,
('is_full_depth', self.is_full_depth), 'is_full_depth': self.is_full_depth,
('subdevice_role', self.subdevice_role), 'subdevice_role': self.subdevice_role,
('airflow', self.airflow), 'airflow': self.airflow,
('comments', self.comments), 'comments': self.comments,
)) }
# Component templates # Component templates
if self.consoleporttemplates.exists(): if self.consoleporttemplates.exists():
data['console-ports'] = [ data['console-ports'] = [
{ c.to_yaml() for c in self.consoleporttemplates.all()
'name': c.name,
'type': c.type,
'label': c.label,
'description': c.description,
}
for c in self.consoleporttemplates.all()
] ]
if self.consoleserverporttemplates.exists(): if self.consoleserverporttemplates.exists():
data['console-server-ports'] = [ data['console-server-ports'] = [
{ c.to_yaml() for c in self.consoleserverporttemplates.all()
'name': c.name,
'type': c.type,
'label': c.label,
'description': c.description,
}
for c in self.consoleserverporttemplates.all()
] ]
if self.powerporttemplates.exists(): if self.powerporttemplates.exists():
data['power-ports'] = [ data['power-ports'] = [
{ c.to_yaml() for c in self.powerporttemplates.all()
'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()
] ]
if self.poweroutlettemplates.exists(): if self.poweroutlettemplates.exists():
data['power-outlets'] = [ data['power-outlets'] = [
{ c.to_yaml() for c in self.poweroutlettemplates.all()
'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()
] ]
if self.interfacetemplates.exists(): if self.interfacetemplates.exists():
data['interfaces'] = [ data['interfaces'] = [
{ c.to_yaml() for c in self.interfacetemplates.all()
'name': c.name,
'type': c.type,
'mgmt_only': c.mgmt_only,
'label': c.label,
'description': c.description,
}
for c in self.interfacetemplates.all()
] ]
if self.frontporttemplates.exists(): if self.frontporttemplates.exists():
data['front-ports'] = [ data['front-ports'] = [
{ c.to_yaml() for c in self.frontporttemplates.all()
'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()
] ]
if self.rearporttemplates.exists(): if self.rearporttemplates.exists():
data['rear-ports'] = [ data['rear-ports'] = [
{ c.to_yaml() for c in self.rearporttemplates.all()
'name': c.name,
'type': c.type,
'positions': c.positions,
'label': c.label,
'description': c.description,
}
for c in self.rearporttemplates.all()
] ]
if self.modulebaytemplates.exists(): if self.modulebaytemplates.exists():
data['module-bays'] = [ data['module-bays'] = [
{ c.to_yaml() for c in self.modulebaytemplates.all()
'name': c.name,
'label': c.label,
'position': c.position,
'description': c.description,
}
for c in self.modulebaytemplates.all()
] ]
if self.devicebaytemplates.exists(): if self.devicebaytemplates.exists():
data['device-bays'] = [ data['device-bays'] = [
{ c.to_yaml() for c in self.devicebaytemplates.all()
'name': c.name,
'label': c.label,
'description': c.description,
}
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(( data = {
('manufacturer', self.manufacturer.name), 'manufacturer': self.manufacturer.name,
('model', self.model), 'model': self.model,
('part_number', self.part_number), 'part_number': self.part_number,
('comments', self.comments), 'comments': self.comments,
)) }
# Component templates # Component templates
if self.consoleporttemplates.exists(): if self.consoleporttemplates.exists():
data['console-ports'] = [ data['console-ports'] = [
{ c.to_yaml() for c in self.consoleporttemplates.all()
'name': c.name,
'type': c.type,
'label': c.label,
'description': c.description,
}
for c in self.consoleporttemplates.all()
] ]
if self.consoleserverporttemplates.exists(): if self.consoleserverporttemplates.exists():
data['console-server-ports'] = [ data['console-server-ports'] = [
{ c.to_yaml() for c in self.consoleserverporttemplates.all()
'name': c.name,
'type': c.type,
'label': c.label,
'description': c.description,
}
for c in self.consoleserverporttemplates.all()
] ]
if self.powerporttemplates.exists(): if self.powerporttemplates.exists():
data['power-ports'] = [ data['power-ports'] = [
{ c.to_yaml() for c in self.powerporttemplates.all()
'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()
] ]
if self.poweroutlettemplates.exists(): if self.poweroutlettemplates.exists():
data['power-outlets'] = [ data['power-outlets'] = [
{ c.to_yaml() for c in self.poweroutlettemplates.all()
'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()
] ]
if self.interfacetemplates.exists(): if self.interfacetemplates.exists():
data['interfaces'] = [ data['interfaces'] = [
{ c.to_yaml() for c in self.interfacetemplates.all()
'name': c.name,
'type': c.type,
'mgmt_only': c.mgmt_only,
'label': c.label,
'description': c.description,
}
for c in self.interfacetemplates.all()
] ]
if self.frontporttemplates.exists(): if self.frontporttemplates.exists():
data['front-ports'] = [ data['front-ports'] = [
{ c.to_yaml() for c in self.frontporttemplates.all()
'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()
] ]
if self.rearporttemplates.exists(): if self.rearporttemplates.exists():
data['rear-ports'] = [ data['rear-ports'] = [
{ c.to_yaml() for c in self.rearporttemplates.all()
'name': c.name,
'type': c.type,
'positions': c.positions,
'label': c.label,
'description': c.description,
}
for c in self.rearporttemplates.all()
] ]
return yaml.dump(dict(data), sort_keys=False) return yaml.dump(dict(data), sort_keys=False)

View File

@ -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',
) )

View File

@ -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')

View File

@ -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')

View File

@ -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'])

View File

@ -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, f'Unable to remove master device {device} from the virtual chassis.')
messages.error(request, mark_safe(msg))
return redirect(device.get_absolute_url()) return redirect(device.get_absolute_url())
if form.is_valid(): if form.is_valid():

View File

@ -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'}),
} }

View File

@ -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):

View File

@ -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):

View File

@ -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']

View File

@ -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']:

View File

@ -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)

View File

@ -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)

View File

@ -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 = (

View File

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

View File

@ -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."
}) })

View File

@ -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()

View File

@ -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 f'<a href="{item.get_absolute_url()}">{escape(item)}</a>'
return item 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))

View File

@ -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'] pattern_count = len(data[f'{self.patterned_fields[0]}_pattern'])
labels = data['label_pattern'] if 'label_pattern' in data else None for i in range(pattern_count):
for i, name in enumerate(names):
label = labels[i] if labels else None
component_data = { component_data = {
self.parent_field: obj.pk, self.parent_field: obj.pk
'name': name,
'label': label
} }
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():

View File

@ -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)) msg = f'{msg} {obj}'
messages.success(request, mark_safe(msg)) messages.success(request, msg)
if '_addanother' in request.POST: if '_addanother' in request.POST:
redirect_url = request.path redirect_url = request.path

View File

@ -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( username = serializer.data.get('username')
request=request, password = serializer.data.get('password')
username=serializer.data['username'], if not username or not password:
password=serializer.data['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")

View File

@ -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:

View File

@ -1,8 +1,3 @@
{% if utilization == 0 %}
<div class="progress align-items-center justify-content-center">
<span class="w-100 text-center">{{ utilization }}%</span>
</div>
{% else %}
<div class="progress"> <div class="progress">
<div <div
role="progressbar" role="progressbar"
@ -12,10 +7,9 @@
class="progress-bar {{ bar_class }}" class="progress-bar {{ bar_class }}"
style="width: {{ utilization }}%;" style="width: {{ utilization }}%;"
> >
{% if utilization >= 25 %}{{ utilization|floatformat:0 }}%{% endif %} {% if utilization >= 35 %}{{ utilization|floatformat:1 }}%{% endif %}
</div> </div>
{% if utilization < 25 %} {% if utilization < 35 %}
<span class="ps-1">{{ utilization|floatformat:0 }}%</span> <span class="ps-1">{{ utilization|floatformat:1 }}%</span>
{% endif %} {% endif %}
</div> </div>
{% endif %}

View File

@ -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()

View File

@ -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(f'<span title="{long_ts}">{short_ts}</span>')
return mark_safe(span)
@register.simple_tag @register.simple_tag

View File

@ -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:

View File

@ -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', 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'tenant_group', 'platform', 'vcpus', 'memory',
'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', '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', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
'last_updated',
) )
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')

View File

@ -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==3.4.1
markdown-include==0.6.0 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