Merge branch 'netbox-community:develop' into fix-creating-config-template-using-rest-api

This commit is contained in:
Olivier Desnoë 2023-09-22 18:11:58 +02:00 committed by GitHub
commit 5d9e8f3957
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 247 additions and 190 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.6.1 placeholder: v3.6.2
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.6.1 placeholder: v3.6.2
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -1,6 +1,6 @@
<div align="center"> <div align="center">
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" /> <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
<p>The premiere source of truth powering network automation</p> <p>The premier source of truth powering network automation</p>
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /> <img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
<p></p> <p></p>
</div> </div>

View File

@ -342,8 +342,10 @@
"100gbase-x-qsfpdd", "100gbase-x-qsfpdd",
"200gbase-x-qsfp56", "200gbase-x-qsfp56",
"200gbase-x-qsfpdd", "200gbase-x-qsfpdd",
"400gbase-x-qsfp112",
"400gbase-x-qsfpdd", "400gbase-x-qsfpdd",
"400gbase-x-osfp", "400gbase-x-osfp",
"400gbase-x-osfp-rhs",
"400gbase-x-cdfp", "400gbase-x-cdfp",
"400gbase-x-cfp8", "400gbase-x-cfp8",
"800gbase-x-qsfpdd", "800gbase-x-qsfpdd",

View File

@ -20,7 +20,7 @@ DEFAULT_DASHBOARD = [
{ {
'widget': 'extras.ObjectCountsWidget', 'widget': 'extras.ObjectCountsWidget',
'width': 4, 'width': 4,
'height': 2, 'height': 3,
'title': 'Organization', 'title': 'Organization',
'config': { 'config': {
'models': [ 'models': [
@ -32,6 +32,8 @@ DEFAULT_DASHBOARD = [
}, },
{ {
'widget': 'extras.ObjectCountsWidget', 'widget': 'extras.ObjectCountsWidget',
'width': 4,
'height': 3,
'title': 'IPAM', 'title': 'IPAM',
'color': 'blue', 'color': 'blue',
'config': { 'config': {

View File

@ -1,6 +1,6 @@
![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} ![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"}
# The Premiere Network Source of Truth # The Premier Network Source of Truth
NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure. NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure.

View File

@ -1,5 +1,8 @@
# Installation # Installation
!!! info "NetBox Cloud"
The instructions below are for installing NetBox as a standalone, self-hosted application. For a Cloud-delivered solution, check out [NetBox Cloud](https://netboxlabs.com/netbox-cloud/) by NetBox Labs.
The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
<iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> <iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

View File

@ -1,12 +1,36 @@
# NetBox v3.6 # NetBox v3.6
## v3.6.2 (FUTURE) ## v3.6.3 (FUTURE)
---
## v3.6.2 (2023-09-20)
### Enhancements
* [#13245](https://github.com/netbox-community/netbox/issues/13245) - Add interface types for QSFP112 and OSFP-RHS
* [#13563](https://github.com/netbox-community/netbox/issues/13563) - Add support for other delimiting characters when using CSV import
### Bug Fixes ### Bug Fixes
* [#11209](https://github.com/netbox-community/netbox/issues/11209) - Hide available IP/VLAN listing when sorting under a parent prefix or VLAN range
* [#11617](https://github.com/netbox-community/netbox/issues/11617) - Raise validation error on the presence of an unknown CSV header during bulk import
* [#12219](https://github.com/netbox-community/netbox/issues/12219) - Fix dashboard widget heading contrast under dark mode
* [#12685](https://github.com/netbox-community/netbox/issues/12685) - Render Markdown in custom field help text on object edit forms
* [#13653](https://github.com/netbox-community/netbox/issues/13653) - Tweak color of error text to improve legibility
* [#13701](https://github.com/netbox-community/netbox/issues/13701) - Correct display of power feed legs under device view * [#13701](https://github.com/netbox-community/netbox/issues/13701) - Correct display of power feed legs under device view
* [#13706](https://github.com/netbox-community/netbox/issues/13706) - Restore extra filters dropdown on device interfaces list * [#13706](https://github.com/netbox-community/netbox/issues/13706) - Restore extra filters dropdown on device interfaces list
* [#13721](https://github.com/netbox-community/netbox/issues/13721) - Filter VLAN choices by selected site (if any) when creating a prefix * [#13721](https://github.com/netbox-community/netbox/issues/13721) - Filter VLAN choices by selected site (if any) when creating a prefix
* [#13727](https://github.com/netbox-community/netbox/issues/13727) - Fix exception when viewing rendered config for VM without a role assigned
* [#13745](https://github.com/netbox-community/netbox/issues/13745) - Optimize counter field migrations for large databases
* [#13756](https://github.com/netbox-community/netbox/issues/13756) - Fix exception when sorting module bay list by installed module status
* [#13757](https://github.com/netbox-community/netbox/issues/13757) - Fix RecursionError exception when assigning config context to a device type
* [#13767](https://github.com/netbox-community/netbox/issues/13767) - Fix support for comments when creating a new service via web UI
* [#13782](https://github.com/netbox-community/netbox/issues/13782) - Fix tag exclusion support for contact assignments
* [#13791](https://github.com/netbox-community/netbox/issues/13791) - Preserve whitespace in values when performing bulk rename of objects via web UI
* [#13809](https://github.com/netbox-community/netbox/issues/13809) - Avoid TypeError exception when editing active configuration with statically defined `CUSTOM_VALIDATORS`
* [#13813](https://github.com/netbox-community/netbox/issues/13813) - Fix member count for newly created virtual chassis
* [#13818](https://github.com/netbox-community/netbox/issues/13818) - Restore missing tags field on L2VPN termination edit form
--- ---
@ -33,7 +57,7 @@
* [#13657](https://github.com/netbox-community/netbox/issues/13657) - Fix decoding of data file content * [#13657](https://github.com/netbox-community/netbox/issues/13657) - Fix decoding of data file content
* [#13674](https://github.com/netbox-community/netbox/issues/13674) - Fix retrieving individual report via REST API * [#13674](https://github.com/netbox-community/netbox/issues/13674) - Fix retrieving individual report via REST API
* [#13682](https://github.com/netbox-community/netbox/issues/13682) - Fix error message returned when validation of custom field default value fails * [#13682](https://github.com/netbox-community/netbox/issues/13682) - Fix error message returned when validation of custom field default value fails
* [#13684](https://github.com/netbox-community/netbox/issues/13684) - Enable modying the configuration when maintenance mode is enabled * [#13684](https://github.com/netbox-community/netbox/issues/13684) - Enable modifying the configuration when maintenance mode is enabled
--- ---

View File

@ -837,8 +837,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd' TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
TYPE_400GE_CFP2 = '400gbase-x-cfp2' TYPE_400GE_CFP2 = '400gbase-x-cfp2'
TYPE_400GE_QSFP112 = '400gbase-x-qsfp112'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd' TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp' TYPE_400GE_OSFP = '400gbase-x-osfp'
TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
TYPE_400GE_CDFP = '400gbase-x-cdfp' TYPE_400GE_CDFP = '400gbase-x-cdfp'
TYPE_400GE_CFP8 = '400gbase-x-cfp8' TYPE_400GE_CFP8 = '400gbase-x-cfp8'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd' TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
@ -989,8 +991,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'), (TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'), (TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
(TYPE_400GE_QSFP112, 'QSFP112 (400GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'), (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
(TYPE_400GE_OSFP, 'OSFP (400GE)'), (TYPE_400GE_OSFP, 'OSFP (400GE)'),
(TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
(TYPE_400GE_CDFP, 'CDFP (400GE)'), (TYPE_400GE_CDFP, 'CDFP (400GE)'),
(TYPE_400GE_CFP8, 'CPF8 (400GE)'), (TYPE_400GE_CFP8, 'CPF8 (400GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'), (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),

View File

@ -2,47 +2,22 @@ from django.db import migrations
from django.db.models import Count from django.db.models import Count
import utilities.fields import utilities.fields
from utilities.counters import update_counts
def recalculate_device_counts(apps, schema_editor): def recalculate_device_counts(apps, schema_editor):
Device = apps.get_model("dcim", "Device") Device = apps.get_model("dcim", "Device")
devices = Device.objects.annotate(
_console_port_count=Count('consoleports', distinct=True),
_console_server_port_count=Count('consoleserverports', distinct=True),
_power_port_count=Count('powerports', distinct=True),
_power_outlet_count=Count('poweroutlets', distinct=True),
_interface_count=Count('interfaces', distinct=True),
_front_port_count=Count('frontports', distinct=True),
_rear_port_count=Count('rearports', distinct=True),
_device_bay_count=Count('devicebays', distinct=True),
_module_bay_count=Count('modulebays', distinct=True),
_inventory_item_count=Count('inventoryitems', distinct=True),
)
for device in devices: update_counts(Device, 'console_port_count', 'consoleports')
device.console_port_count = device._console_port_count update_counts(Device, 'console_server_port_count', 'consoleserverports')
device.console_server_port_count = device._console_server_port_count update_counts(Device, 'power_port_count', 'powerports')
device.power_port_count = device._power_port_count update_counts(Device, 'power_outlet_count', 'poweroutlets')
device.power_outlet_count = device._power_outlet_count update_counts(Device, 'interface_count', 'interfaces')
device.interface_count = device._interface_count update_counts(Device, 'front_port_count', 'frontports')
device.front_port_count = device._front_port_count update_counts(Device, 'rear_port_count', 'rearports')
device.rear_port_count = device._rear_port_count update_counts(Device, 'device_bay_count', 'devicebays')
device.device_bay_count = device._device_bay_count update_counts(Device, 'module_bay_count', 'modulebays')
device.module_bay_count = device._module_bay_count update_counts(Device, 'inventory_item_count', 'inventoryitems')
device.inventory_item_count = device._inventory_item_count
Device.objects.bulk_update(devices, [
'console_port_count',
'console_server_port_count',
'power_port_count',
'power_outlet_count',
'interface_count',
'front_port_count',
'rear_port_count',
'device_bay_count',
'module_bay_count',
'inventory_item_count',
], batch_size=100)
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -2,47 +2,22 @@ from django.db import migrations
from django.db.models import Count from django.db.models import Count
import utilities.fields import utilities.fields
from utilities.counters import update_counts
def recalculate_devicetype_template_counts(apps, schema_editor): def recalculate_devicetype_template_counts(apps, schema_editor):
DeviceType = apps.get_model("dcim", "DeviceType") DeviceType = apps.get_model("dcim", "DeviceType")
device_types = list(DeviceType.objects.all().annotate(
_console_port_template_count=Count('consoleporttemplates', distinct=True),
_console_server_port_template_count=Count('consoleserverporttemplates', distinct=True),
_power_port_template_count=Count('powerporttemplates', distinct=True),
_power_outlet_template_count=Count('poweroutlettemplates', distinct=True),
_interface_template_count=Count('interfacetemplates', distinct=True),
_front_port_template_count=Count('frontporttemplates', distinct=True),
_rear_port_template_count=Count('rearporttemplates', distinct=True),
_device_bay_template_count=Count('devicebaytemplates', distinct=True),
_module_bay_template_count=Count('modulebaytemplates', distinct=True),
_inventory_item_template_count=Count('inventoryitemtemplates', distinct=True),
))
for devicetype in device_types: update_counts(DeviceType, 'console_port_template_count', 'consoleporttemplates')
devicetype.console_port_template_count = devicetype._console_port_template_count update_counts(DeviceType, 'console_server_port_template_count', 'consoleserverporttemplates')
devicetype.console_server_port_template_count = devicetype._console_server_port_template_count update_counts(DeviceType, 'power_port_template_count', 'powerporttemplates')
devicetype.power_port_template_count = devicetype._power_port_template_count update_counts(DeviceType, 'power_outlet_template_count', 'poweroutlettemplates')
devicetype.power_outlet_template_count = devicetype._power_outlet_template_count update_counts(DeviceType, 'interface_template_count', 'interfacetemplates')
devicetype.interface_template_count = devicetype._interface_template_count update_counts(DeviceType, 'front_port_template_count', 'frontporttemplates')
devicetype.front_port_template_count = devicetype._front_port_template_count update_counts(DeviceType, 'rear_port_template_count', 'rearporttemplates')
devicetype.rear_port_template_count = devicetype._rear_port_template_count update_counts(DeviceType, 'device_bay_template_count', 'devicebaytemplates')
devicetype.device_bay_template_count = devicetype._device_bay_template_count update_counts(DeviceType, 'module_bay_template_count', 'modulebaytemplates')
devicetype.module_bay_template_count = devicetype._module_bay_template_count update_counts(DeviceType, 'inventory_item_template_count', 'inventoryitemtemplates')
devicetype.inventory_item_template_count = devicetype._inventory_item_template_count
DeviceType.objects.bulk_update(device_types, [
'console_port_template_count',
'console_server_port_template_count',
'power_port_template_count',
'power_outlet_template_count',
'interface_template_count',
'front_port_template_count',
'rear_port_template_count',
'device_bay_template_count',
'module_bay_template_count',
'inventory_item_template_count',
])
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -2,17 +2,13 @@ from django.db import migrations
from django.db.models import Count from django.db.models import Count
import utilities.fields import utilities.fields
from utilities.counters import update_counts
def populate_virtualchassis_members(apps, schema_editor): def populate_virtualchassis_members(apps, schema_editor):
VirtualChassis = apps.get_model('dcim', 'VirtualChassis') VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
vcs = VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True)) update_counts(VirtualChassis, 'member_count', 'members')
for vc in vcs:
vc.member_count = vc._member_count
VirtualChassis.objects.bulk_update(vcs, ['member_count'], batch_size=100)
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -98,10 +98,10 @@ class Cable(PrimaryModel):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# A copy of the PK to be used by __str__ in case the object is deleted # A copy of the PK to be used by __str__ in case the object is deleted
self._pk = self.pk self._pk = self.__dict__.get('id')
# Cache the original status so we can check later if it's been changed # Cache the original status so we can check later if it's been changed
self._orig_status = self.status self._orig_status = self.__dict__.get('status')
self._terminations_modified = False self._terminations_modified = False

View File

@ -89,7 +89,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Cache the original DeviceType ID for reference under clean() # Cache the original DeviceType ID for reference under clean()
self._original_device_type = self.device_type_id self._original_device_type = self.__dict__.get('device_type_id')
def to_objectchange(self, action): def to_objectchange(self, action):
objectchange = super().to_objectchange(action) objectchange = super().to_objectchange(action)

View File

@ -86,7 +86,7 @@ class ComponentModel(NetBoxModel):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Cache the original Device ID for reference under clean() # Cache the original Device ID for reference under clean()
self._original_device = self.device_id self._original_device = self.__dict__.get('device_id')
def __str__(self): def __str__(self):
if self.label: if self.label:

View File

@ -205,11 +205,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Save a copy of u_height for validation in clean() # Save a copy of u_height for validation in clean()
self._original_u_height = self.u_height self._original_u_height = self.__dict__.get('u_height')
# Save references to the original front/rear images # Save references to the original front/rear images
self._original_front_image = self.front_image self._original_front_image = self.__dict__.get('front_image')
self._original_rear_image = self.rear_image self._original_rear_image = self.__dict__.get('rear_image')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk]) return reverse('dcim:devicetype', args=[self.pk])

View File

@ -69,7 +69,7 @@ class RenderConfigMixin(models.Model):
""" """
if self.config_template: if self.config_template:
return self.config_template return self.config_template
if self.role.config_template: if self.role and self.role.config_template:
return self.role.config_template return self.role.config_template
if self.platform and self.platform.config_template: if self.platform and self.platform.config_template:
return self.platform.config_template return self.platform.config_template

View File

@ -871,8 +871,9 @@ class ModuleBayTable(DeviceComponentTable):
url_name='dcim:modulebay_list' url_name='dcim:modulebay_list'
) )
module_status = columns.TemplateColumn( module_status = columns.TemplateColumn(
verbose_name=_('Module Status'), accessor=tables.A('installed_module__status'),
template_code=MODULEBAY_STATUS template_code=MODULEBAY_STATUS,
verbose_name=_('Module Status')
) )
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):

View File

@ -9,6 +9,7 @@ from utilities.forms.fields import DynamicModelMultipleChoiceField
__all__ = ( __all__ = (
'CustomFieldsMixin', 'CustomFieldsMixin',
'SavedFiltersMixin', 'SavedFiltersMixin',
'TagsMixin',
) )
@ -72,3 +73,19 @@ class SavedFiltersMixin(forms.Form):
'usable': True, 'usable': True,
} }
) )
class TagsMixin(forms.Form):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False,
label=_('Tags'),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit tags to those applicable to the object type
content_type = ContentType.objects.get_for_model(self._meta.model)
if content_type and hasattr(self.fields['tags'].widget, 'add_query_param'):
self.fields['tags'].widget.add_query_param('for_object_type_id', content_type.pk)

View File

@ -76,7 +76,8 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
'type': _( 'type': _(
"The type of data stored in this field. For object/multi-object fields, select the related object " "The type of data stored in this field. For object/multi-object fields, select the related object "
"type below." "type below."
) ),
'description': _("This will be displayed as help text for the form field. Markdown is supported.")
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -517,22 +518,34 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
config = get_config() config = get_config()
for param in PARAMS: for param in PARAMS:
value = getattr(config, param.name) value = getattr(config, param.name)
is_static = hasattr(settings, param.name)
if value: # Set the field's initial value, if it can be serialized. (This may not be the case e.g. for
help_text = self.fields[param.name].help_text # CUSTOM_VALIDATORS, which may reference Python objects.)
if help_text: try:
help_text += '<br />' # Line break json.dumps(value)
help_text += _('Current value: <strong>{value}</strong>').format(value=value)
if is_static:
help_text += _(' (defined statically)')
elif value == param.default:
help_text += _(' (default)')
self.fields[param.name].help_text = help_text
if type(value) in (tuple, list): if type(value) in (tuple, list):
value = ', '.join(value) self.fields[param.name].initial = ', '.join(value)
self.fields[param.name].initial = value else:
if is_static: self.fields[param.name].initial = value
except TypeError:
pass
# Check whether this parameter is statically configured (e.g. in configuration.py)
if hasattr(settings, param.name):
self.fields[param.name].disabled = True self.fields[param.name].disabled = True
self.fields[param.name].help_text = _(
'This parameter has been defined statically and cannot be modified.'
)
continue
# Set the field's help text
help_text = self.fields[param.name].help_text
if help_text:
help_text += '<br />' # Line break
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '&mdash;')
if value == param.default:
help_text += _(' (default)')
self.fields[param.name].help_text = help_text
def save(self, commit=True): def save(self, commit=True):
instance = super().save(commit=False) instance = super().save(commit=False)

View File

@ -28,6 +28,7 @@ from utilities.forms.fields import (
from utilities.forms.utils import add_blank_choice from utilities.forms.utils import add_blank_choice
from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.templatetags.builtins.filters import render_markdown
from utilities.validators import validate_regex from utilities.validators import validate_regex
__all__ = ( __all__ = (
@ -219,7 +220,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Cache instance's original name so we can check later whether it has changed # Cache instance's original name so we can check later whether it has changed
self._name = self.name self._name = self.__dict__.get('name')
@property @property
def search_type(self): def search_type(self):
@ -498,7 +499,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
field.model = self field.model = self
field.label = str(self) field.label = str(self)
if self.description: if self.description:
field.help_text = escape(self.description) field.help_text = render_markdown(self.description)
# Annotate read-only fields # Annotate read-only fields
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:

View File

@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
from dcim.models import Site from dcim.models import DeviceType, Manufacturer, Site
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from utilities.testing import ViewTestCases, TestCase from utilities.testing import ViewTestCases, TestCase
@ -434,7 +434,8 @@ class ConfigContextTestCase(
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
# Create three ConfigContexts # Create three ConfigContexts
for i in range(1, 4): for i in range(1, 4):
@ -443,7 +444,7 @@ class ConfigContextTestCase(
data={'foo': i} data={'foo': i}
) )
configcontext.save() configcontext.save()
configcontext.sites.add(site) configcontext.device_types.add(devicetype)
cls.form_data = { cls.form_data = {
'name': 'Config Context X', 'name': 'Config Context X',
@ -451,11 +452,12 @@ class ConfigContextTestCase(
'description': 'A new config context', 'description': 'A new config context',
'is_active': True, 'is_active': True,
'regions': [], 'regions': [],
'sites': [site.pk], 'sites': [],
'roles': [], 'roles': [],
'platforms': [], 'platforms': [],
'tenant_groups': [], 'tenant_groups': [],
'tenants': [], 'tenants': [],
'device_types': [devicetype.id,],
'tags': [], 'tags': [],
'data': '{"foo": 123}', 'data': '{"foo": 123}',
} }

View File

@ -731,7 +731,7 @@ class ServiceCreateForm(ServiceForm):
class Meta(ServiceForm.Meta): class Meta(ServiceForm.Meta):
fields = [ fields = [
'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
'tags', 'comments', 'tags',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -290,8 +290,8 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# 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.__dict__.get('prefix')
self._vrf_id = self.vrf_id self._vrf_id = self.__dict__.get('vrf_id')
def __str__(self): def __str__(self):
return str(self.prefix) return str(self.prefix)

View File

@ -4,10 +4,11 @@ from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
from extras.models import CustomField, Tag from extras.models import CustomField, Tag
from utilities.forms import BootstrapMixin, CSVModelForm, CheckLastUpdatedMixin from utilities.forms import CSVModelForm
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.mixins import BootstrapMixin, CheckLastUpdatedMixin
__all__ = ( __all__ = (
'NetBoxModelForm', 'NetBoxModelForm',
@ -17,7 +18,7 @@ __all__ = (
) )
class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, forms.ModelForm): class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms.ModelForm):
""" """
Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields. Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields.
@ -26,18 +27,6 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin,
the rendered form (optional). If not defined, the all fields will be rendered as a single section. the rendered form (optional). If not defined, the all fields will be rendered as a single section.
""" """
fieldsets = () fieldsets = ()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False,
label=_('Tags'),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit tags to those applicable to the object type
if (ct := self._get_content_type()) and hasattr(self.fields['tags'].widget, 'add_query_param'):
self.fields['tags'].widget.add_query_param('for_object_type_id', ct.pk)
def _get_content_type(self): def _get_content_type(self):
return ContentType.objects.get_for_model(self._meta.model) return ContentType.objects.get_for_model(self._meta.model)

View File

@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup # Environment setup
# #
VERSION = '3.6.2-dev' VERSION = '3.6.3-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()

View File

@ -17,6 +17,36 @@ class CSVImportTestCase(ModelViewTestCase):
def _get_csv_data(self, csv_data): def _get_csv_data(self, csv_data):
return '\n'.join(csv_data) return '\n'.join(csv_data)
def test_invalid_headers(self):
"""
Test that import form validation fails when an unknown CSV header is present.
"""
self.add_permissions('dcim.add_region')
csv_data = [
'name,slug,INVALIDHEADER',
'Region 1,region-1,abc',
'Region 2,region-2,def',
'Region 3,region-3,ghi',
]
data = {
'format': ImportFormatChoices.CSV,
'data': self._get_csv_data(csv_data),
'csv_delimiter': CSVDelimiterChoices.AUTO,
}
# Form validation should fail with invalid header present
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
self.assertEqual(Region.objects.count(), 0)
# Correct the CSV header name
csv_data[0] = 'name,slug,description'
data['data'] = self._get_csv_data(csv_data)
# Validation should succeed
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
self.assertEqual(Region.objects.count(), 3)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_valid_tags(self): def test_valid_tags(self):
csv_data = ( csv_data = (

Binary file not shown.

View File

@ -282,7 +282,7 @@ $btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);
$btn-close-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$btn-close-color}'><path d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/></svg>"); $btn-close-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$btn-close-color}'><path d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/></svg>");
// Code // Code
$code-color: $gray-200; $code-color: $gray-600;
$kbd-color: $white; $kbd-color: $white;
$kbd-bg: $gray-300; $kbd-bg: $gray-300;
$pre-color: null; $pre-color: null;

View File

@ -32,7 +32,7 @@
<td>{{ object.group_name|placeholder }}</td> <td>{{ object.group_name|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row"></th> <th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|markdown|placeholder }}</td> <td>{{ object.description|markdown|placeholder }}</td>
</tr> </tr>
<tr> <tr>

View File

@ -42,7 +42,7 @@
<div class="alert alert-warning d-flex align-items-center" role="alert"> <div class="alert alert-warning d-flex align-items-center" role="alert">
<i class="mdi mdi-alert"></i> <i class="mdi mdi-alert"></i>
{% blocktrans trimmed with file_path=module.full_path %} {% blocktrans trimmed with file_path=module.full_path %}
Script file at <code>{{ file_path }}</code> could not be loaded. Script file at <code class="mx-1">{{ file_path }}</code> could not be loaded.
{% endblocktrans %} {% endblocktrans %}
</div> </div>
{% else %} {% else %}

View File

@ -41,6 +41,7 @@
<div class="tab-pane {% if form.initial.vminterface %}active{% endif %}" id="vminterface" role="tabpanel" aria-labeled-by="vminterface_tab"> <div class="tab-pane {% if form.initial.vminterface %}active{% endif %}" id="vminterface" role="tabpanel" aria-labeled-by="vminterface_tab">
{% render_field form.vminterface %} {% render_field form.vminterface %}
</div> </div>
{% render_field form.tags %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,10 +1,11 @@
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from extras.forms.mixins import TagsMixin
from extras.models import Tag from extras.models import Tag
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.models import * from tenancy.models import *
from utilities.forms import BootstrapMixin from utilities.forms.mixins import BootstrapMixin
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
__all__ = ( __all__ = (
@ -121,7 +122,7 @@ class ContactForm(NetBoxModelForm):
} }
class ContactAssignmentForm(BootstrapMixin, forms.ModelForm): class ContactAssignmentForm(BootstrapMixin, TagsMixin, forms.ModelForm):
group = DynamicModelChoiceField( group = DynamicModelChoiceField(
label=_('Group'), label=_('Group'),
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
@ -141,11 +142,6 @@ class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
label=_('Role'), label=_('Role'),
queryset=ContactRole.objects.all() queryset=ContactRole.objects.all()
) )
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False,
label=_('Tags')
)
class Meta: class Meta:
model = ContactAssignment model = ContactAssignment

View File

@ -1,5 +1,5 @@
from django.apps import apps from django.apps import apps
from django.db.models import F from django.db.models import F, Count, OuterRef, Subquery
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save
from netbox.registry import registry from netbox.registry import registry
@ -23,6 +23,24 @@ def update_counter(model, pk, counter_name, value):
) )
def update_counts(model, field_name, related_query):
"""
Perform a bulk update for the given model and counter field. For example,
update_counts(Device, '_interface_count', 'interfaces')
will effectively set
Device.objects.update(_interface_count=Count('interfaces'))
"""
subquery = Subquery(
model.objects.filter(pk=OuterRef('pk')).annotate(_count=Count(related_query)).values('_count')
)
return model.objects.update(**{
field_name: subquery
})
# #
# Signal handlers # Signal handlers
# #
@ -34,12 +52,13 @@ def post_save_receiver(sender, instance, created, **kwargs):
for field_name, counter_name in get_counters_for_model(sender): for field_name, counter_name in get_counters_for_model(sender):
parent_model = sender._meta.get_field(field_name).related_model parent_model = sender._meta.get_field(field_name).related_model
new_pk = getattr(instance, field_name, None) new_pk = getattr(instance, field_name, None)
old_pk = instance.tracker.get(field_name) if field_name in instance.tracker else None has_old_field = field_name in instance.tracker
old_pk = instance.tracker.get(field_name) if has_old_field else None
# Update the counters on the old and/or new parents as needed # Update the counters on the old and/or new parents as needed
if old_pk is not None: if old_pk is not None:
update_counter(parent_model, old_pk, counter_name, -1) update_counter(parent_model, old_pk, counter_name, -1)
if new_pk is not None and (old_pk or created): if new_pk is not None and (has_old_field or created):
update_counter(parent_model, new_pk, counter_name, 1) update_counter(parent_model, new_pk, counter_name, 1)

View File

@ -129,6 +129,7 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
headers, records = parse_csv(reader) headers, records = parse_csv(reader)
# Set CSV headers for reference by the model form # Set CSV headers for reference by the model form
headers.pop('id', None)
self._csv_headers = headers self._csv_headers = headers
return records return records

View File

@ -40,8 +40,11 @@ class BulkRenameForm(BootstrapMixin, forms.Form):
""" """
An extendable form to be used for renaming objects in bulk. An extendable form to be used for renaming objects in bulk.
""" """
find = forms.CharField() find = forms.CharField(
strip=False
)
replace = forms.CharField( replace = forms.CharField(
strip=False,
required=False required=False
) )
use_regex = forms.BooleanField( use_regex = forms.BooleanField(
@ -67,22 +70,24 @@ class CSVModelForm(forms.ModelForm):
""" """
ModelForm used for the import of objects in CSV format. ModelForm used for the import of objects in CSV format.
""" """
def __init__(self, *args, headers=None, fields=None, **kwargs): def __init__(self, *args, headers=None, **kwargs):
headers = headers or {} self.headers = headers or {}
fields = fields or []
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Modify the model form to accommodate any customized to_field_name properties # Modify the model form to accommodate any customized to_field_name properties
for field, to_field in headers.items(): for field, to_field in self.headers.items():
if to_field is not None: if to_field is not None:
self.fields[field].to_field_name = to_field self.fields[field].to_field_name = to_field
# Omit any fields not specified (e.g. because the form is being used to def clean(self):
# updated rather than create objects) # Flag any invalid CSV headers
if fields: for header in self.headers:
for field in list(self.fields.keys()): if header not in self.fields:
if field not in fields: raise forms.ValidationError(
del self.fields[field] _("Unrecognized header: {name}").format(name=header)
)
return super().clean()
class FilterForm(BootstrapMixin, forms.Form): class FilterForm(BootstrapMixin, forms.Form):

View File

@ -4,6 +4,7 @@ from django.core.management.base import BaseCommand
from django.db.models import Count, OuterRef, Subquery from django.db.models import Count, OuterRef, Subquery
from netbox.registry import registry from netbox.registry import registry
from utilities.counters import update_counts
class Command(BaseCommand): class Command(BaseCommand):
@ -26,27 +27,9 @@ class Command(BaseCommand):
return models return models
def update_counts(self, model, field_name, related_query):
"""
Perform a bulk update for the given model and counter field. For example,
update_counts(Device, '_interface_count', 'interfaces')
will effectively set
Device.objects.update(_interface_count=Count('interfaces'))
"""
self.stdout.write(f'Updating {model.__name__} {field_name}...')
subquery = Subquery(
model.objects.filter(pk=OuterRef('pk')).annotate(_count=Count(related_query)).values('_count')
)
return model.objects.update(**{
field_name: subquery
})
def handle(self, *model_names, **options): def handle(self, *model_names, **options):
for model, mappings in self.collect_models().items(): for model, mappings in self.collect_models().items():
for field_name, related_query in mappings.items(): for field_name, related_query in mappings.items():
self.update_counts(model, field_name, related_query) update_counts(model, field_name, related_query)
self.stdout.write(self.style.SUCCESS('Finished.')) self.stdout.write(self.style.SUCCESS('Finished.'))

View File

@ -36,10 +36,18 @@ class CountersTest(TestCase):
self.assertEqual(device1.interface_count, 3) self.assertEqual(device1.interface_count, 3)
self.assertEqual(device2.interface_count, 3) self.assertEqual(device2.interface_count, 3)
# test saving an existing object - counter should not change
interface1.save() interface1.save()
device1.refresh_from_db() device1.refresh_from_db()
self.assertEqual(device1.interface_count, 3) self.assertEqual(device1.interface_count, 3)
# test save where tracked object FK back pointer is None
vc = VirtualChassis.objects.create(name='Virtual Chassis 1')
device1.virtual_chassis = vc
device1.save()
vc.refresh_from_db()
self.assertEqual(vc.member_count, 1)
def test_interface_count_deletion(self): def test_interface_count_deletion(self):
""" """
When a tracked object (Interface) is deleted the tracking counter should be updated. When a tracked object (Interface) is deleted the tracking counter should be updated.

View File

@ -3,6 +3,7 @@ from django.test import TestCase
from utilities.choices import ImportFormatChoices from utilities.choices import ImportFormatChoices
from utilities.forms.bulk_import import BulkImportForm from utilities.forms.bulk_import import BulkImportForm
from utilities.forms.forms import BulkRenameForm
from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
@ -364,3 +365,16 @@ class ImportFormTest(TestCase):
{'a': '1', 'b': '2', 'c': '3'}, {'a': '1', 'b': '2', 'c': '3'},
{'a': '4', 'b': '5', 'c': '6'}, {'a': '4', 'b': '5', 'c': '6'},
]) ])
class BulkRenameFormTest(TestCase):
def test_no_strip_whitespace(self):
# Tests to make sure Bulk Rename Form isn't stripping whitespaces
# See: https://github.com/netbox-community/netbox/issues/13791
form = BulkRenameForm(data={
"find": " hello ",
"replace": " world "
})
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data["find"], " hello ")
self.assertEqual(form.cleaned_data["replace"], " world ")

View File

@ -2,17 +2,13 @@ from django.db import migrations
from django.db.models import Count from django.db.models import Count
import utilities.fields import utilities.fields
from utilities.counters import update_counts
def populate_virtualmachine_counts(apps, schema_editor): def populate_virtualmachine_counts(apps, schema_editor):
VirtualMachine = apps.get_model('virtualization', 'VirtualMachine') VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
vms = VirtualMachine.objects.annotate(_interface_count=Count('interfaces', distinct=True)) update_counts(VirtualMachine, 'interface_count', 'interfaces')
for vm in vms:
vm.interface_count = vm._interface_count
VirtualMachine.objects.bulk_update(vms, ['interface_count'], batch_size=100)
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -2,7 +2,7 @@ bleach==6.0.0
Django==4.2.5 Django==4.2.5
django-cors-headers==4.2.0 django-cors-headers==4.2.0
django-debug-toolbar==4.2.0 django-debug-toolbar==4.2.0
django-filter==23.2 django-filter==23.3
django-graphiql-debug-toolbar==0.2.0 django-graphiql-debug-toolbar==0.2.0
django-mptt==0.14 django-mptt==0.14
django-pglocks==1.0.4 django-pglocks==1.0.4
@ -12,7 +12,7 @@ django-rich==1.7.0
django-rq==2.8.1 django-rq==2.8.1
django-tables2==2.6.0 django-tables2==2.6.0
django-taggit==4.0.0 django-taggit==4.0.0
django-timezone-field==6.0 django-timezone-field==6.0.1
djangorestframework==3.14.0 djangorestframework==3.14.0
drf-spectacular==0.26.4 drf-spectacular==0.26.4
drf-spectacular-sidecar==2023.9.1 drf-spectacular-sidecar==2023.9.1
@ -21,13 +21,13 @@ graphene-django==3.0.0
gunicorn==21.2.0 gunicorn==21.2.0
Jinja2==3.1.2 Jinja2==3.1.2
Markdown==3.3.7 Markdown==3.3.7
mkdocs-material==9.2.7 mkdocs-material==9.3.2
mkdocstrings[python-legacy]==0.23.0 mkdocstrings[python-legacy]==0.23.0
netaddr==0.8.0 netaddr==0.9.0
Pillow==10.0.0 Pillow==10.0.1
psycopg[binary,pool]==3.1.10 psycopg[binary,pool]==3.1.10
PyYAML==6.0.1 PyYAML==6.0.1
sentry-sdk==1.30.0 sentry-sdk==1.31.0
social-auth-app-django==5.3.0 social-auth-app-django==5.3.0
social-auth-core[openidconnect]==4.4.2 social-auth-core[openidconnect]==4.4.2
svgwrite==1.4.3 svgwrite==1.4.3