diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index ec7d667e6..8664768ee 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.6.1
+ placeholder: v3.6.2
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index dc27ebd26..8e3af527a 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.6.1
+ placeholder: v3.6.2
validations:
required: true
- type: dropdown
diff --git a/README.md b/README.md
index 54b3e727e..6e50e5687 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-
The premiere source of truth powering network automation
+
The premier source of truth powering network automation
diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json
index 9a6e2417a..5e8507798 100644
--- a/contrib/generated_schema.json
+++ b/contrib/generated_schema.json
@@ -342,8 +342,10 @@
"100gbase-x-qsfpdd",
"200gbase-x-qsfp56",
"200gbase-x-qsfpdd",
+ "400gbase-x-qsfp112",
"400gbase-x-qsfpdd",
"400gbase-x-osfp",
+ "400gbase-x-osfp-rhs",
"400gbase-x-cdfp",
"400gbase-x-cfp8",
"800gbase-x-qsfpdd",
diff --git a/docs/configuration/default-values.md b/docs/configuration/default-values.md
index e76930208..d90e6eafc 100644
--- a/docs/configuration/default-values.md
+++ b/docs/configuration/default-values.md
@@ -20,7 +20,7 @@ DEFAULT_DASHBOARD = [
{
'widget': 'extras.ObjectCountsWidget',
'width': 4,
- 'height': 2,
+ 'height': 3,
'title': 'Organization',
'config': {
'models': [
@@ -32,6 +32,8 @@ DEFAULT_DASHBOARD = [
},
{
'widget': 'extras.ObjectCountsWidget',
+ 'width': 4,
+ 'height': 3,
'title': 'IPAM',
'color': 'blue',
'config': {
diff --git a/docs/index.md b/docs/index.md
index 6a53403d6..05cd79f23 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,6 +1,6 @@
{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.
diff --git a/docs/installation/index.md b/docs/installation/index.md
index da50fa5fa..5affdf247 100644
--- a/docs/installation/index.md
+++ b/docs/installation/index.md
@@ -1,5 +1,8 @@
# 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.
VIDEO
diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md
index a019cbe13..4339682c2 100644
--- a/docs/release-notes/version-3.6.md
+++ b/docs/release-notes/version-3.6.md
@@ -1,12 +1,36 @@
# 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
+* [#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
* [#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
+* [#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
* [#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
-* [#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
---
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index 1bcf61b20..e1d4a330a 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -837,8 +837,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
TYPE_400GE_CFP2 = '400gbase-x-cfp2'
+ TYPE_400GE_QSFP112 = '400gbase-x-qsfp112'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp'
+ TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
TYPE_400GE_CDFP = '400gbase-x-cdfp'
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
@@ -989,8 +991,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
+ (TYPE_400GE_QSFP112, 'QSFP112 (400GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
+ (TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
diff --git a/netbox/dcim/migrations/0176_device_component_counters.py b/netbox/dcim/migrations/0176_device_component_counters.py
index a911d7fd7..60857ecb9 100644
--- a/netbox/dcim/migrations/0176_device_component_counters.py
+++ b/netbox/dcim/migrations/0176_device_component_counters.py
@@ -2,47 +2,22 @@ from django.db import migrations
from django.db.models import Count
import utilities.fields
+from utilities.counters import update_counts
def recalculate_device_counts(apps, schema_editor):
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:
- device.console_port_count = device._console_port_count
- device.console_server_port_count = device._console_server_port_count
- device.power_port_count = device._power_port_count
- device.power_outlet_count = device._power_outlet_count
- device.interface_count = device._interface_count
- device.front_port_count = device._front_port_count
- device.rear_port_count = device._rear_port_count
- device.device_bay_count = device._device_bay_count
- device.module_bay_count = device._module_bay_count
- 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)
+ update_counts(Device, 'console_port_count', 'consoleports')
+ update_counts(Device, 'console_server_port_count', 'consoleserverports')
+ update_counts(Device, 'power_port_count', 'powerports')
+ update_counts(Device, 'power_outlet_count', 'poweroutlets')
+ update_counts(Device, 'interface_count', 'interfaces')
+ update_counts(Device, 'front_port_count', 'frontports')
+ update_counts(Device, 'rear_port_count', 'rearports')
+ update_counts(Device, 'device_bay_count', 'devicebays')
+ update_counts(Device, 'module_bay_count', 'modulebays')
+ update_counts(Device, 'inventory_item_count', 'inventoryitems')
class Migration(migrations.Migration):
diff --git a/netbox/dcim/migrations/0177_devicetype_component_counters.py b/netbox/dcim/migrations/0177_devicetype_component_counters.py
index 66d1460d9..b452ce2d9 100644
--- a/netbox/dcim/migrations/0177_devicetype_component_counters.py
+++ b/netbox/dcim/migrations/0177_devicetype_component_counters.py
@@ -2,47 +2,22 @@ from django.db import migrations
from django.db.models import Count
import utilities.fields
+from utilities.counters import update_counts
def recalculate_devicetype_template_counts(apps, schema_editor):
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:
- devicetype.console_port_template_count = devicetype._console_port_template_count
- devicetype.console_server_port_template_count = devicetype._console_server_port_template_count
- devicetype.power_port_template_count = devicetype._power_port_template_count
- devicetype.power_outlet_template_count = devicetype._power_outlet_template_count
- devicetype.interface_template_count = devicetype._interface_template_count
- devicetype.front_port_template_count = devicetype._front_port_template_count
- devicetype.rear_port_template_count = devicetype._rear_port_template_count
- devicetype.device_bay_template_count = devicetype._device_bay_template_count
- devicetype.module_bay_template_count = devicetype._module_bay_template_count
- 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',
- ])
+ update_counts(DeviceType, 'console_port_template_count', 'consoleporttemplates')
+ update_counts(DeviceType, 'console_server_port_template_count', 'consoleserverporttemplates')
+ update_counts(DeviceType, 'power_port_template_count', 'powerporttemplates')
+ update_counts(DeviceType, 'power_outlet_template_count', 'poweroutlettemplates')
+ update_counts(DeviceType, 'interface_template_count', 'interfacetemplates')
+ update_counts(DeviceType, 'front_port_template_count', 'frontporttemplates')
+ update_counts(DeviceType, 'rear_port_template_count', 'rearporttemplates')
+ update_counts(DeviceType, 'device_bay_template_count', 'devicebaytemplates')
+ update_counts(DeviceType, 'module_bay_template_count', 'modulebaytemplates')
+ update_counts(DeviceType, 'inventory_item_template_count', 'inventoryitemtemplates')
class Migration(migrations.Migration):
diff --git a/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py b/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py
index 7d07a4d9d..99b304b66 100644
--- a/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py
+++ b/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py
@@ -2,17 +2,13 @@ from django.db import migrations
from django.db.models import Count
import utilities.fields
+from utilities.counters import update_counts
def populate_virtualchassis_members(apps, schema_editor):
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
- vcs = VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True))
-
- for vc in vcs:
- vc.member_count = vc._member_count
-
- VirtualChassis.objects.bulk_update(vcs, ['member_count'], batch_size=100)
+ update_counts(VirtualChassis, 'member_count', 'members')
class Migration(migrations.Migration):
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index de7ba0eb6..ba9e11d46 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -98,10 +98,10 @@ class Cable(PrimaryModel):
super().__init__(*args, **kwargs)
# 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
- self._orig_status = self.status
+ self._orig_status = self.__dict__.get('status')
self._terminations_modified = False
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index f58d2bbca..86b6d85fe 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -89,7 +89,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
super().__init__(*args, **kwargs)
# 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):
objectchange = super().to_objectchange(action)
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index f42ae5895..639f8aadb 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -86,7 +86,7 @@ class ComponentModel(NetBoxModel):
super().__init__(*args, **kwargs)
# 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):
if self.label:
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 857251caf..9cca724ce 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -205,11 +205,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
super().__init__(*args, **kwargs)
# 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
- self._original_front_image = self.front_image
- self._original_rear_image = self.rear_image
+ self._original_front_image = self.__dict__.get('front_image')
+ self._original_rear_image = self.__dict__.get('rear_image')
def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk])
diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py
index 95f6d41fe..9be8dc0a3 100644
--- a/netbox/dcim/models/mixins.py
+++ b/netbox/dcim/models/mixins.py
@@ -69,7 +69,7 @@ class RenderConfigMixin(models.Model):
"""
if 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
if self.platform and self.platform.config_template:
return self.platform.config_template
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 68c24ca14..34dbcbf30 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -871,8 +871,9 @@ class ModuleBayTable(DeviceComponentTable):
url_name='dcim:modulebay_list'
)
module_status = columns.TemplateColumn(
- verbose_name=_('Module Status'),
- template_code=MODULEBAY_STATUS
+ accessor=tables.A('installed_module__status'),
+ template_code=MODULEBAY_STATUS,
+ verbose_name=_('Module Status')
)
class Meta(DeviceComponentTable.Meta):
diff --git a/netbox/extras/forms/mixins.py b/netbox/extras/forms/mixins.py
index be45f5211..5366dcc28 100644
--- a/netbox/extras/forms/mixins.py
+++ b/netbox/extras/forms/mixins.py
@@ -9,6 +9,7 @@ from utilities.forms.fields import DynamicModelMultipleChoiceField
__all__ = (
'CustomFieldsMixin',
'SavedFiltersMixin',
+ 'TagsMixin',
)
@@ -72,3 +73,19 @@ class SavedFiltersMixin(forms.Form):
'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)
diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py
index c8afeb090..83a346420 100644
--- a/netbox/extras/forms/model_forms.py
+++ b/netbox/extras/forms/model_forms.py
@@ -76,7 +76,8 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
'type': _(
"The type of data stored in this field. For object/multi-object fields, select the related object "
"type below."
- )
+ ),
+ 'description': _("This will be displayed as help text for the form field. Markdown is supported.")
}
def __init__(self, *args, **kwargs):
@@ -517,22 +518,34 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
config = get_config()
for param in PARAMS:
value = getattr(config, param.name)
- is_static = hasattr(settings, param.name)
- if value:
- help_text = self.fields[param.name].help_text
- if help_text:
- help_text += ' ' # Line break
- help_text += _('Current value: {value} ').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
+
+ # Set the field's initial value, if it can be serialized. (This may not be the case e.g. for
+ # CUSTOM_VALIDATORS, which may reference Python objects.)
+ try:
+ json.dumps(value)
if type(value) in (tuple, list):
- value = ', '.join(value)
- self.fields[param.name].initial = value
- if is_static:
+ self.fields[param.name].initial = ', '.join(value)
+ else:
+ 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].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 += ' ' # Line break
+ help_text += _('Current value: {value} ').format(value=value or '—')
+ if value == param.default:
+ help_text += _(' (default)')
+ self.fields[param.name].help_text = help_text
def save(self, commit=True):
instance = super().save(commit=False)
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index 0c4a0c615..e6f339e5a 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -28,6 +28,7 @@ from utilities.forms.fields import (
from utilities.forms.utils import add_blank_choice
from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
from utilities.querysets import RestrictedQuerySet
+from utilities.templatetags.builtins.filters import render_markdown
from utilities.validators import validate_regex
__all__ = (
@@ -219,7 +220,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
super().__init__(*args, **kwargs)
# 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
def search_type(self):
@@ -498,7 +499,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
field.model = self
field.label = str(self)
if self.description:
- field.help_text = escape(self.description)
+ field.help_text = render_markdown(self.description)
# Annotate read-only fields
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py
index 01ef9a2a6..296ed9f4d 100644
--- a/netbox/extras/tests/test_views.py
+++ b/netbox/extras/tests/test_views.py
@@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
-from dcim.models import Site
+from dcim.models import DeviceType, Manufacturer, Site
from extras.choices import *
from extras.models import *
from utilities.testing import ViewTestCases, TestCase
@@ -434,7 +434,8 @@ class ConfigContextTestCase(
@classmethod
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
for i in range(1, 4):
@@ -443,7 +444,7 @@ class ConfigContextTestCase(
data={'foo': i}
)
configcontext.save()
- configcontext.sites.add(site)
+ configcontext.device_types.add(devicetype)
cls.form_data = {
'name': 'Config Context X',
@@ -451,11 +452,12 @@ class ConfigContextTestCase(
'description': 'A new config context',
'is_active': True,
'regions': [],
- 'sites': [site.pk],
+ 'sites': [],
'roles': [],
'platforms': [],
'tenant_groups': [],
'tenants': [],
+ 'device_types': [devicetype.id,],
'tags': [],
'data': '{"foo": 123}',
}
diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py
index cc147cec4..e965bf7b1 100644
--- a/netbox/ipam/forms/model_forms.py
+++ b/netbox/ipam/forms/model_forms.py
@@ -731,7 +731,7 @@ class ServiceCreateForm(ServiceForm):
class Meta(ServiceForm.Meta):
fields = [
'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
- 'tags',
+ 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py
index 2456fa021..00c08b3bc 100644
--- a/netbox/ipam/models/ip.py
+++ b/netbox/ipam/models/ip.py
@@ -290,8 +290,8 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
super().__init__(*args, **kwargs)
# Cache the original prefix and VRF so we can check if they have changed on post_save
- self._prefix = self.prefix
- self._vrf_id = self.vrf_id
+ self._prefix = self.__dict__.get('prefix')
+ self._vrf_id = self.__dict__.get('vrf_id')
def __str__(self):
return str(self.prefix)
diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py
index c5dac90f7..43d0850f0 100644
--- a/netbox/netbox/forms/base.py
+++ b/netbox/netbox/forms/base.py
@@ -4,10 +4,11 @@ from django.db.models import Q
from django.utils.translation import gettext_lazy as _
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 utilities.forms import BootstrapMixin, CSVModelForm, CheckLastUpdatedMixin
+from utilities.forms import CSVModelForm
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.mixins import BootstrapMixin, CheckLastUpdatedMixin
__all__ = (
'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.
@@ -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.
"""
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):
return ContentType.objects.get_for_model(self._meta.model)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index c22281e9e..53700073e 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
-VERSION = '3.6.2-dev'
+VERSION = '3.6.3-dev'
# Hostname
HOSTNAME = platform.node()
diff --git a/netbox/netbox/tests/test_import.py b/netbox/netbox/tests/test_import.py
index 6594409f2..bd07886e8 100644
--- a/netbox/netbox/tests/test_import.py
+++ b/netbox/netbox/tests/test_import.py
@@ -17,6 +17,36 @@ class CSVImportTestCase(ModelViewTestCase):
def _get_csv_data(self, 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=['*'])
def test_valid_tags(self):
csv_data = (
diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css
index 2aa24b72c..2d7142bc6 100644
Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ
diff --git a/netbox/project-static/styles/theme-dark.scss b/netbox/project-static/styles/theme-dark.scss
index 4bbe5cea5..2d04b44e3 100644
--- a/netbox/project-static/styles/theme-dark.scss
+++ b/netbox/project-static/styles/theme-dark.scss
@@ -282,7 +282,7 @@ $btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);
$btn-close-bg: url("data:image/svg+xml, ");
// Code
-$code-color: $gray-200;
+$code-color: $gray-600;
$kbd-color: $white;
$kbd-bg: $gray-300;
$pre-color: null;
diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html
index 85824e61b..dd5cce7bd 100644
--- a/netbox/templates/extras/customfield.html
+++ b/netbox/templates/extras/customfield.html
@@ -32,7 +32,7 @@
{{ object.group_name|placeholder }}
-
+ {% trans "Description" %}
{{ object.description|markdown|placeholder }}
diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html
index fc45bebc7..167e3073c 100644
--- a/netbox/templates/extras/script_list.html
+++ b/netbox/templates/extras/script_list.html
@@ -42,7 +42,7 @@
{% blocktrans trimmed with file_path=module.full_path %}
- Script file at {{ file_path }}
could not be loaded.
+ Script file at {{ file_path }}
could not be loaded.
{% endblocktrans %}
{% else %}
diff --git a/netbox/templates/ipam/l2vpntermination_edit.html b/netbox/templates/ipam/l2vpntermination_edit.html
index 3db0d0102..0df2c883e 100644
--- a/netbox/templates/ipam/l2vpntermination_edit.html
+++ b/netbox/templates/ipam/l2vpntermination_edit.html
@@ -41,6 +41,7 @@
{% render_field form.vminterface %}
+ {% render_field form.tags %}
diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py
index 72c030d84..5b1051c68 100644
--- a/netbox/tenancy/forms/model_forms.py
+++ b/netbox/tenancy/forms/model_forms.py
@@ -1,10 +1,11 @@
from django import forms
from django.utils.translation import gettext_lazy as _
+from extras.forms.mixins import TagsMixin
from extras.models import Tag
from netbox.forms import NetBoxModelForm
from tenancy.models import *
-from utilities.forms import BootstrapMixin
+from utilities.forms.mixins import BootstrapMixin
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
__all__ = (
@@ -121,7 +122,7 @@ class ContactForm(NetBoxModelForm):
}
-class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
+class ContactAssignmentForm(BootstrapMixin, TagsMixin, forms.ModelForm):
group = DynamicModelChoiceField(
label=_('Group'),
queryset=ContactGroup.objects.all(),
@@ -141,11 +142,6 @@ class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
label=_('Role'),
queryset=ContactRole.objects.all()
)
- tags = DynamicModelMultipleChoiceField(
- queryset=Tag.objects.all(),
- required=False,
- label=_('Tags')
- )
class Meta:
model = ContactAssignment
diff --git a/netbox/utilities/counters.py b/netbox/utilities/counters.py
index 6c1418dff..6c597b943 100644
--- a/netbox/utilities/counters.py
+++ b/netbox/utilities/counters.py
@@ -1,5 +1,5 @@
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 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
#
@@ -34,12 +52,13 @@ def post_save_receiver(sender, instance, created, **kwargs):
for field_name, counter_name in get_counters_for_model(sender):
parent_model = sender._meta.get_field(field_name).related_model
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
if old_pk is not None:
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)
diff --git a/netbox/utilities/forms/bulk_import.py b/netbox/utilities/forms/bulk_import.py
index 63cec2ba2..57362d3dd 100644
--- a/netbox/utilities/forms/bulk_import.py
+++ b/netbox/utilities/forms/bulk_import.py
@@ -129,6 +129,7 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
headers, records = parse_csv(reader)
# Set CSV headers for reference by the model form
+ headers.pop('id', None)
self._csv_headers = headers
return records
diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py
index 9f84e100f..54c9e41cb 100644
--- a/netbox/utilities/forms/forms.py
+++ b/netbox/utilities/forms/forms.py
@@ -40,8 +40,11 @@ class BulkRenameForm(BootstrapMixin, forms.Form):
"""
An extendable form to be used for renaming objects in bulk.
"""
- find = forms.CharField()
+ find = forms.CharField(
+ strip=False
+ )
replace = forms.CharField(
+ strip=False,
required=False
)
use_regex = forms.BooleanField(
@@ -67,22 +70,24 @@ class CSVModelForm(forms.ModelForm):
"""
ModelForm used for the import of objects in CSV format.
"""
- def __init__(self, *args, headers=None, fields=None, **kwargs):
- headers = headers or {}
- fields = fields or []
+ def __init__(self, *args, headers=None, **kwargs):
+ self.headers = headers or {}
super().__init__(*args, **kwargs)
# 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:
self.fields[field].to_field_name = to_field
- # Omit any fields not specified (e.g. because the form is being used to
- # updated rather than create objects)
- if fields:
- for field in list(self.fields.keys()):
- if field not in fields:
- del self.fields[field]
+ def clean(self):
+ # Flag any invalid CSV headers
+ for header in self.headers:
+ if header not in self.fields:
+ raise forms.ValidationError(
+ _("Unrecognized header: {name}").format(name=header)
+ )
+
+ return super().clean()
class FilterForm(BootstrapMixin, forms.Form):
diff --git a/netbox/utilities/management/commands/calculate_cached_counts.py b/netbox/utilities/management/commands/calculate_cached_counts.py
index 62354797c..f7810604f 100644
--- a/netbox/utilities/management/commands/calculate_cached_counts.py
+++ b/netbox/utilities/management/commands/calculate_cached_counts.py
@@ -4,6 +4,7 @@ from django.core.management.base import BaseCommand
from django.db.models import Count, OuterRef, Subquery
from netbox.registry import registry
+from utilities.counters import update_counts
class Command(BaseCommand):
@@ -26,27 +27,9 @@ class Command(BaseCommand):
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):
for model, mappings in self.collect_models().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.'))
diff --git a/netbox/utilities/tests/test_counters.py b/netbox/utilities/tests/test_counters.py
index 0c61c0890..cf8850c52 100644
--- a/netbox/utilities/tests/test_counters.py
+++ b/netbox/utilities/tests/test_counters.py
@@ -36,10 +36,18 @@ class CountersTest(TestCase):
self.assertEqual(device1.interface_count, 3)
self.assertEqual(device2.interface_count, 3)
+ # test saving an existing object - counter should not change
interface1.save()
device1.refresh_from_db()
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):
"""
When a tracked object (Interface) is deleted the tracking counter should be updated.
diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py
index 8fd001ba8..d014d4bbd 100644
--- a/netbox/utilities/tests/test_forms.py
+++ b/netbox/utilities/tests/test_forms.py
@@ -3,6 +3,7 @@ from django.test import TestCase
from utilities.choices import ImportFormatChoices
from utilities.forms.bulk_import import BulkImportForm
+from utilities.forms.forms import BulkRenameForm
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': '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 ")
diff --git a/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py b/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py
index 725b73573..abed09d7e 100644
--- a/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py
+++ b/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py
@@ -2,17 +2,13 @@ from django.db import migrations
from django.db.models import Count
import utilities.fields
+from utilities.counters import update_counts
def populate_virtualmachine_counts(apps, schema_editor):
VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
- vms = VirtualMachine.objects.annotate(_interface_count=Count('interfaces', distinct=True))
-
- for vm in vms:
- vm.interface_count = vm._interface_count
-
- VirtualMachine.objects.bulk_update(vms, ['interface_count'], batch_size=100)
+ update_counts(VirtualMachine, 'interface_count', 'interfaces')
class Migration(migrations.Migration):
diff --git a/requirements.txt b/requirements.txt
index 54f1334ed..931fc2c24 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,7 +2,7 @@ bleach==6.0.0
Django==4.2.5
django-cors-headers==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-mptt==0.14
django-pglocks==1.0.4
@@ -12,7 +12,7 @@ django-rich==1.7.0
django-rq==2.8.1
django-tables2==2.6.0
django-taggit==4.0.0
-django-timezone-field==6.0
+django-timezone-field==6.0.1
djangorestframework==3.14.0
drf-spectacular==0.26.4
drf-spectacular-sidecar==2023.9.1
@@ -21,13 +21,13 @@ graphene-django==3.0.0
gunicorn==21.2.0
Jinja2==3.1.2
Markdown==3.3.7
-mkdocs-material==9.2.7
+mkdocs-material==9.3.2
mkdocstrings[python-legacy]==0.23.0
-netaddr==0.8.0
-Pillow==10.0.0
+netaddr==0.9.0
+Pillow==10.0.1
psycopg[binary,pool]==3.1.10
PyYAML==6.0.1
-sentry-sdk==1.30.0
+sentry-sdk==1.31.0
social-auth-app-django==5.3.0
social-auth-core[openidconnect]==4.4.2
svgwrite==1.4.3