diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 5e936c5ec..974527bd3 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -10,16 +10,25 @@ body:
installation. If you're having trouble with installation or just looking for
assistance with using NetBox, please visit our
[discussion forum](https://github.com/netbox-community/netbox/discussions) instead.
+ - type: dropdown
+ attributes:
+ label: Deployment Type
+ description: How are you running NetBox?
+ options:
+ - Self-hosted
+ - NetBox Cloud
+ validations:
+ required: true
- type: input
attributes:
- label: NetBox version
+ label: NetBox Version
description: What version of NetBox are you currently running?
- placeholder: v3.6.6
+ placeholder: v3.6.7
validations:
required: true
- type: dropdown
attributes:
- label: Python version
+ label: Python Version
description: What version of Python are you currently running?
options:
- "3.8"
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 34103e616..9fb14742a 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.6
+ placeholder: v3.6.7
validations:
required: true
- type: dropdown
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 301fac079..471846427 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -36,6 +36,8 @@ NetBox users are welcome to participate in either role, on stage or in the crowd
## :bug: Reporting Bugs
+:warning: Bug reports are used to call attention to some unintended or unexpected behavior in NetBox, such as when an error occurs or when the result of taking some action is inconsistent with the documentation. **Bug reports may not be used to suggest new functionality**; please see "feature requests" below if that is your goal.
+
* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases) of NetBox. If you're running an older version, it's likely that the bug has already been fixed.
* Next, search our [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the bug you've found has already been reported. If you come across a bug report that seems to match, please click "add a reaction" in the top right corner of the issue and add a thumbs up (:thumbsup:). This will help draw more attention to it. Any comments you can add to provide additional information or context would also be much appreciated.
diff --git a/docs/features/synchronized-data.md b/docs/features/synchronized-data.md
index f266519b6..a070d0ce1 100644
--- a/docs/features/synchronized-data.md
+++ b/docs/features/synchronized-data.md
@@ -10,7 +10,6 @@ To enable remote data synchronization, the NetBox administrator first designates
(Local disk paths are considered "remote" in this context as they exist outside NetBox's database. These paths could also be mapped to external network shares.)
-
!!! info
Data backends which connect to external sources typically require the installation of one or more supporting Python libraries. The Git backend requires the [`dulwich`](https://www.dulwich.io/) package, and the S3 backend requires the [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) package. These must be installed within NetBox's environment to enable these backends.
@@ -23,3 +22,6 @@ The following NetBox models can be associated with replicated data files:
* Export templates
Once a data has been designated for a local instance, its data will be replaced with the content of the replicated file. When the replicated file is updated in the future (via synchronization jobs), the local instance will be flagged as having out-of-date data. A user can then synchronize these objects individually or in bulk to effect the update. This two-stage process ensures that automated synchronization tasks do not immediately affect production data.
+
+!!! note "Permissions"
+ A user must be assigned the `core.sync_datasource` permission in order to synchronize local files from a remote data source.
diff --git a/docs/integrations/synchronized-data.md b/docs/integrations/synchronized-data.md
index 805cbe15b..d72501fd5 100644
--- a/docs/integrations/synchronized-data.md
+++ b/docs/integrations/synchronized-data.md
@@ -2,6 +2,9 @@
Some NetBox models support automatic synchronization of certain attributes from remote [data sources](../models/core/datasource.md), such as a git repository hosted on GitHub or GitLab. Data from the authoritative remote source is synchronized locally in NetBox as [data files](../models/core/datafile.md).
+!!! note "Permissions"
+ A user must be assigned the `core.sync_datasource` permission in order to synchronize local files from a remote data source. This is accomplished by creating a permission for the "Core > Data Source" object type with the `sync` action, and assigning it to the desired user and/or group.
+
The following features support the use of synchronized data:
* [Configuration templates](../features/configuration-rendering.md)
diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md
index 10e93be1e..fc2328897 100644
--- a/docs/release-notes/version-3.6.md
+++ b/docs/release-notes/version-3.6.md
@@ -1,6 +1,33 @@
# NetBox v3.6
-## v3.6.7 (FUTURE)
+## v3.6.8 (FUTURE)
+
+---
+
+## v3.6.7 (2023-12-15)
+
+### Enhancements
+
+* [#12751](https://github.com/netbox-community/netbox/issues/12751) - Designate fields to expand by default for object selector widget
+* [#14148](https://github.com/netbox-community/netbox/issues/14148) - Add tags column to L2VPN terminations column
+* [#14390](https://github.com/netbox-community/netbox/issues/14390) - Add `classes` parameter to `copy_content` template tag
+* [#14467](https://github.com/netbox-community/netbox/issues/14467) - Change custom field choice delimiter from comma to colon
+
+### Bug Fixes
+
+* [#13983](https://github.com/netbox-community/netbox/issues/13983) - Fix bulk import support for custom field choices
+* [#14081](https://github.com/netbox-community/netbox/issues/14081) - Ensure accuracy of parent object counters when deleting related objects
+* [#14249](https://github.com/netbox-community/netbox/issues/14249) - Fix server error when authenticating via IP-restricted API tokens using IPv6
+* [#14392](https://github.com/netbox-community/netbox/issues/14392) - Fix bulk operations for plugin models under admin UI
+* [#14397](https://github.com/netbox-community/netbox/issues/14397) - Fix exception on non-JSON request to `/available-ips/` API endpoints
+* [#14401](https://github.com/netbox-community/netbox/issues/14401) - Rack `starting_unit` cannot be zero
+* [#14432](https://github.com/netbox-community/netbox/issues/14432) - Populate custom field default values for components when creating a device
+* [#14448](https://github.com/netbox-community/netbox/issues/14448) - Fix exception when creating a power feed with rack and panel in different sites
+* [#14505](https://github.com/netbox-community/netbox/issues/14505) - Fix the assignment of tags to L2VPN terminations
+* [#14512](https://github.com/netbox-community/netbox/issues/14512) - Remove unneeded annotations from queries when using REST API brief mode
+* [#14515](https://github.com/netbox-community/netbox/issues/14515) - Ensure user config is created automatically for all user accounts
+* [#14522](https://github.com/netbox-community/netbox/issues/14522) - Fix filtering contact assignments by group
+* [#14533](https://github.com/netbox-community/netbox/issues/14533) - Fix quick search under VLAN group VLANs list
---
diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py
index a82ec1726..1e1abd068 100644
--- a/netbox/circuits/forms/filtersets.py
+++ b/netbox/circuits/forms/filtersets.py
@@ -119,6 +119,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
+ selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id')
type_id = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
required=False,
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 1c8713a28..95c441381 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -165,6 +165,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
+ selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
status = forms.MultipleChoiceField(
label=_('Status'),
choices=SiteStatusChoices,
@@ -248,6 +249,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
(_('Weight'), ('weight', 'max_weight', 'weight_unit')),
)
+ selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -420,6 +422,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
)),
(_('Weight'), ('weight', 'weight_unit')),
)
+ selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -544,6 +547,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
)),
(_('Weight'), ('weight', 'weight_unit')),
)
+ selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -620,6 +624,7 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
class PlatformFilterForm(NetBoxModelFilterSetForm):
model = Platform
+ selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -654,6 +659,7 @@ class DeviceFilterForm(
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
))
)
+ selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -997,6 +1003,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
+ selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -1228,6 +1235,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
+ selector_fields = ('filter_id', 'q', 'device_id')
vdc_id = DynamicModelMultipleChoiceField(
queryset=VirtualDeviceContext.objects.all(),
required=False,
diff --git a/netbox/dcim/migrations/0174_rack_starting_unit.py b/netbox/dcim/migrations/0174_rack_starting_unit.py
index e32738660..2d2b5f826 100644
--- a/netbox/dcim/migrations/0174_rack_starting_unit.py
+++ b/netbox/dcim/migrations/0174_rack_starting_unit.py
@@ -1,5 +1,6 @@
# Generated by Django 4.1.9 on 2023-05-31 15:47
+import django.core.validators
from django.db import migrations, models
@@ -12,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='rack',
name='starting_unit',
- field=models.PositiveSmallIntegerField(default=1),
+ field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)]),
),
]
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 8ed8336cd..4b9689a22 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -16,7 +16,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
-from extras.models import ConfigContextModel
+from extras.models import ConfigContextModel, CustomField
from extras.querysets import ConfigContextModelQuerySet
from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel
@@ -994,11 +994,17 @@ class Device(
bulk_create: If True, bulk_create() will be called to create all components in a single query
(default). Otherwise, save() will be called on each instance individually.
"""
+ components = [obj.instantiate(device=self) for obj in queryset]
+ if not components:
+ return
+
+ # Set default values for any applicable custom fields
+ model = queryset.model.component_model
+ if cf_defaults := CustomField.objects.get_defaults_for_model(model):
+ for component in components:
+ component.custom_field_data = cf_defaults
+
if bulk_create:
- components = [obj.instantiate(device=self) for obj in queryset]
- if not components:
- return
- model = components[0]._meta.model
model.objects.bulk_create(components)
# Manually send the post_save signal for each of the newly created components
for component in components:
@@ -1011,8 +1017,7 @@ class Device(
update_fields=None
)
else:
- for obj in queryset:
- component = obj.instantiate(device=self)
+ for component in components:
component.save()
def save(self, *args, **kwargs):
diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py
index a852ea5cd..62578d6c4 100644
--- a/netbox/dcim/models/power.py
+++ b/netbox/dcim/models/power.py
@@ -175,7 +175,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
# Rack must belong to same Site as PowerPanel
if self.rack and self.rack.site != self.power_panel.site:
raise ValidationError(_(
- "Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites"
+ "Rack {rack} ({rack_site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites."
).format(
rack=self.rack,
rack_site=self.rack.site,
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index 0d4b844f9..3cb4e0225 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -141,6 +141,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
starting_unit = models.PositiveSmallIntegerField(
default=RACK_STARTING_UNIT_DEFAULT,
verbose_name=_('starting unit'),
+ validators=[MinValueValidator(1),],
help_text=_('Starting unit for rack')
)
desc_units = models.BooleanField(
diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py
index 741a615d4..d56bf0741 100644
--- a/netbox/dcim/tests/test_models.py
+++ b/netbox/dcim/tests/test_models.py
@@ -1,9 +1,11 @@
+from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.test import TestCase
from circuits.models import *
from dcim.choices import *
from dcim.models import *
+from extras.models import CustomField
from tenancy.models import Tenant
from utilities.utils import drange
@@ -289,6 +291,23 @@ class DeviceTestCase(TestCase):
)
DeviceRole.objects.bulk_create(roles)
+ # Create a CustomField with a default value & assign it to all component models
+ cf1 = CustomField.objects.create(name='cf1', default='foo')
+ cf1.content_types.set(
+ ContentType.objects.filter(app_label='dcim', model__in=[
+ 'consoleport',
+ 'consoleserverport',
+ 'powerport',
+ 'poweroutlet',
+ 'interface',
+ 'rearport',
+ 'frontport',
+ 'modulebay',
+ 'devicebay',
+ 'inventoryitem',
+ ])
+ )
+
# Create DeviceType components
ConsolePortTemplate(
device_type=device_type,
@@ -300,18 +319,18 @@ class DeviceTestCase(TestCase):
name='Console Server Port 1'
).save()
- ppt = PowerPortTemplate(
+ powerport = PowerPortTemplate(
device_type=device_type,
name='Power Port 1',
maximum_draw=1000,
allocated_draw=500
)
- ppt.save()
+ powerport.save()
PowerOutletTemplate(
device_type=device_type,
name='Power Outlet 1',
- power_port=ppt,
+ power_port=powerport,
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
).save()
@@ -322,19 +341,19 @@ class DeviceTestCase(TestCase):
mgmt_only=True
).save()
- rpt = RearPortTemplate(
+ rearport = RearPortTemplate(
device_type=device_type,
name='Rear Port 1',
type=PortTypeChoices.TYPE_8P8C,
positions=8
)
- rpt.save()
+ rearport.save()
FrontPortTemplate(
device_type=device_type,
name='Front Port 1',
type=PortTypeChoices.TYPE_8P8C,
- rear_port=rpt,
+ rear_port=rearport,
rear_port_position=2
).save()
@@ -348,73 +367,93 @@ class DeviceTestCase(TestCase):
name='Device Bay 1'
).save()
+ InventoryItemTemplate(
+ device_type=device_type,
+ name='Inventory Item 1'
+ ).save()
+
def test_device_creation(self):
"""
Ensure that all Device components are copied automatically from the DeviceType.
"""
- d = Device(
+ device = Device(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
role=DeviceRole.objects.first(),
name='Test Device 1'
)
- d.save()
+ device.save()
- ConsolePort.objects.get(
- device=d,
+ consoleport = ConsolePort.objects.get(
+ device=device,
name='Console Port 1'
)
+ self.assertEqual(consoleport.cf['cf1'], 'foo')
- ConsoleServerPort.objects.get(
- device=d,
+ consoleserverport = ConsoleServerPort.objects.get(
+ device=device,
name='Console Server Port 1'
)
+ self.assertEqual(consoleserverport.cf['cf1'], 'foo')
- pp = PowerPort.objects.get(
- device=d,
+ powerport = PowerPort.objects.get(
+ device=device,
name='Power Port 1',
maximum_draw=1000,
allocated_draw=500
)
+ self.assertEqual(powerport.cf['cf1'], 'foo')
- PowerOutlet.objects.get(
- device=d,
+ poweroutlet = PowerOutlet.objects.get(
+ device=device,
name='Power Outlet 1',
- power_port=pp,
+ power_port=powerport,
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
)
+ self.assertEqual(poweroutlet.cf['cf1'], 'foo')
- Interface.objects.get(
- device=d,
+ interface = Interface.objects.get(
+ device=device,
name='Interface 1',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
mgmt_only=True
)
+ self.assertEqual(interface.cf['cf1'], 'foo')
- rp = RearPort.objects.get(
- device=d,
+ rearport = RearPort.objects.get(
+ device=device,
name='Rear Port 1',
type=PortTypeChoices.TYPE_8P8C,
positions=8
)
+ self.assertEqual(rearport.cf['cf1'], 'foo')
- FrontPort.objects.get(
- device=d,
+ frontport = FrontPort.objects.get(
+ device=device,
name='Front Port 1',
type=PortTypeChoices.TYPE_8P8C,
- rear_port=rp,
+ rear_port=rearport,
rear_port_position=2
)
+ self.assertEqual(frontport.cf['cf1'], 'foo')
- ModuleBay.objects.get(
- device=d,
+ modulebay = ModuleBay.objects.get(
+ device=device,
name='Module Bay 1'
)
+ self.assertEqual(modulebay.cf['cf1'], 'foo')
- DeviceBay.objects.get(
- device=d,
+ devicebay = DeviceBay.objects.get(
+ device=device,
name='Device Bay 1'
)
+ self.assertEqual(devicebay.cf['cf1'], 'foo')
+
+ inventoryitem = InventoryItem.objects.get(
+ device=device,
+ name='Inventory Item 1'
+ )
+ self.assertEqual(inventoryitem.cf['cf1'], 'foo')
def test_multiple_unnamed_devices(self):
diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py
index 243d8fa4c..f8d3ffb7f 100644
--- a/netbox/extras/forms/bulk_import.py
+++ b/netbox/extras/forms/bulk_import.py
@@ -1,3 +1,5 @@
+import re
+
from django import forms
from django.contrib.postgres.forms import SimpleArrayField
from django.core.exceptions import ObjectDoesNotExist
@@ -82,7 +84,10 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
extra_choices = SimpleArrayField(
base_field=forms.CharField(),
required=False,
- help_text=_('Comma-separated list of field choices')
+ help_text=_(
+ 'Quoted string of comma-separated field choices with optional labels separated by colon: '
+ '"choice1:First Choice,choice2:Second Choice"'
+ )
)
class Meta:
@@ -91,6 +96,19 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
'name', 'description', 'extra_choices', 'order_alphabetically',
)
+ def clean_extra_choices(self):
+ if isinstance(self.cleaned_data['extra_choices'], list):
+ data = []
+ for line in self.cleaned_data['extra_choices']:
+ try:
+ value, label = re.split(r'(?choice1,First Choice')
+ 'colon. Example:'
+ ) + ' choice1:First Choice
')
)
class Meta:
model = CustomFieldChoiceSet
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically')
+ def __init__(self, *args, initial=None, **kwargs):
+ super().__init__(*args, initial=initial, **kwargs)
+
+ # Escape colons in extra_choices
+ if 'extra_choices' in self.initial and self.initial['extra_choices']:
+ choices = []
+ for choice in self.initial['extra_choices']:
+ choice = (choice[0].replace(':', '\\:'), choice[1].replace(':', '\\:'))
+ choices.append(choice)
+
+ self.initial['extra_choices'] = choices
+
def clean_extra_choices(self):
data = []
for line in self.cleaned_data['extra_choices'].splitlines():
try:
- value, label = line.split(',', maxsplit=1)
+ value, label = re.split(r'(?
+{% if widget.name != '_selected_action' %}{% endif %}
{% include "django/forms/widgets/input.html" %}
diff --git a/netbox/templates/htmx/object_selector.html b/netbox/templates/htmx/object_selector.html
index 0febb1069..280102ada 100644
--- a/netbox/templates/htmx/object_selector.html
+++ b/netbox/templates/htmx/object_selector.html
@@ -10,18 +10,18 @@