diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 80810f2ba..9ed740fff 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.4.3
+ placeholder: v3.4.4
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 975fc025a..8e4ab54a5 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.4.3
+ placeholder: v3.4.4
validations:
required: true
- type: dropdown
diff --git a/README.md b/README.md
index f44ce725f..053aa8461 100644
--- a/README.md
+++ b/README.md
@@ -1,107 +1,73 @@
+
+ [](https://netboxlabs.com)
+
[](https://try.digitalocean.com/developer-cloud)
- [](https://metal.equinix.com/)
-
- [](https://ns1.com/)
+ [](https://ns1.com)
- [](https://sentry.io/)
+ [](https://sentry.io)
- [](https://stellar.tech/)
+ [](https://metal.equinix.com)
-### Discussion
-
-* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
-* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
-
-### Installation
-
-Please see [the documentation](https://docs.netbox.dev/) for
-instructions on installing NetBox. To upgrade NetBox, please download the
-[latest release](https://github.com/netbox-community/netbox/releases) and
-run `upgrade.sh`.
-
-### Providing Feedback
-
-The best platform for general feedback, assistance, and other discussion is our
-[GitHub discussions](https://github.com/netbox-community/netbox/discussions).
-To report a bug or request a specific feature, please open a GitHub issue using
-the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).
-
-If you are interested in contributing to the development of NetBox, please read
-our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
-
-### Screenshots
+## Screenshots
")
@@ -110,8 +76,3 @@ our [contributing guide](CONTRIBUTING.md) prior to beginning any work.


-
-### Related projects
-
-Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions)
-for a list of relevant community projects.
diff --git a/SECURITY.md b/SECURITY.md
index b389dd2b3..c434b6110 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -24,7 +24,7 @@ If you believe you've uncovered a security vulnerability and wish to report it c
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
-If you believe that you've found a vulnerability which meets all of these conditions, please email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
+If you believe that you've found a vulnerability which meets all of these conditions, please [submit a draft security advisory](https://github.com/netbox-community/netbox/security/advisories/new) on GitHub, or email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
### Bug Bounties
diff --git a/base_requirements.txt b/base_requirements.txt
index 3e4811ece..7292c676b 100644
--- a/base_requirements.txt
+++ b/base_requirements.txt
@@ -1,6 +1,6 @@
# HTML sanitizer
# https://github.com/mozilla/bleach
-bleach
+bleach<6.0
# The Python web framework on which NetBox is built
# https://github.com/django/django
diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md
index 456bcf472..af1e9b5b6 100644
--- a/docs/customization/custom-scripts.md
+++ b/docs/customization/custom-scripts.md
@@ -142,6 +142,19 @@ obj.full_clean()
obj.save()
```
+## Error handling
+
+Sometimes things go wrong and a script will run into an `Exception`. If that happens and an uncaught exception is raised by the custom script, the execution is aborted and a full stack trace is reported.
+
+Although this is helpful for debugging, in some situations it might be required to cleanly abort the execution of a custom script (e.g. because of invalid input data) and thereby make sure no changes are performed on the database. In this case the script can throw an `AbortScript` exception, which will prevent the stack trace from being reported, but still terminating the script's execution and reporting a given error message.
+
+```python
+from utilities.exceptions import AbortScript
+
+if some_error:
+ raise AbortScript("Some meaningful error message")
+```
+
## Variable Reference
### Default Options
diff --git a/docs/index.md b/docs/index.md
index d61465443..6a53403d6 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -52,4 +52,4 @@ NetBox is built on the enormously popular [Django](http://www.djangoproject.com/
* Try out our [public demo](https://demo.netbox.dev/) if you want to jump right in
* The [installation guide](./installation/index.md) will help you get your own deployment up and running
* Or try the community [Docker image](https://github.com/netbox-community/netbox-docker) for a low-touch approach
-* [NetBox Cloud](https://www.getnetbox.io/) is a hosted solution offered by NS1
+* [NetBox Cloud](https://netboxlabs.com/netbox-cloud) is a managed solution offered by [NetBox Labs](https://netboxlabs.com/)
diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md
index 68a582e7f..26a2bf917 100644
--- a/docs/installation/3-netbox.md
+++ b/docs/installation/3-netbox.md
@@ -272,7 +272,10 @@ See the [housekeeping documentation](../administration/housekeeping.md) for furt
## Test the Application
-At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance:
+At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance locally.
+
+!!! tip
+ Check that the Python virtual environment is still active before attempting to run the server.
```no-highlight
python3 manage.py runserver 0.0.0.0:8000 --insecure
diff --git a/docs/installation/4-gunicorn.md b/docs/installation/4-gunicorn.md
index 21d1f1211..1183a9123 100644
--- a/docs/installation/4-gunicorn.md
+++ b/docs/installation/4-gunicorn.md
@@ -14,7 +14,10 @@ While the provided configuration should suffice for most initial installations,
## systemd Setup
-We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd daemon:
+We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd daemon.
+
+!!! warning "Check user & group assignment"
+ The stock service configuration files packaged with NetBox assume that the service will run with the `netbox` user and group names. If these differ on your installation, be sure to update the service files accordingly.
```no-highlight
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/
diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md
index 63402c747..5f4a8a0dc 100644
--- a/docs/plugins/development/navigation.md
+++ b/docs/plugins/development/navigation.md
@@ -51,7 +51,7 @@ menu_items = (item1, item2, item3)
Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below.
-```python filename="navigation.py"
+```python title="navigation.py"
from extras.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices
diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md
index ffa144d34..1581ce681 100644
--- a/docs/release-notes/version-3.4.md
+++ b/docs/release-notes/version-3.4.md
@@ -1,5 +1,30 @@
# NetBox v3.4
+## v3.4.4 (2023-02-02)
+
+### Enhancements
+
+* [#10762](https://github.com/netbox-community/netbox/issues/10762) - Permit selection custom fields to have only one choice
+* [#11152](https://github.com/netbox-community/netbox/issues/11152) - Introduce AbortScript exception to elegantly abort scripts
+* [#11554](https://github.com/netbox-community/netbox/issues/11554) - Add module types count to manufacturers list
+* [#11585](https://github.com/netbox-community/netbox/issues/11585) - Add IP address filters for services
+* [#11598](https://github.com/netbox-community/netbox/issues/11598) - Add buttons to easily switch between rack list and elevations views
+
+### Bug Fixes
+
+* [#11267](https://github.com/netbox-community/netbox/issues/11267) - Avoid catching ImportErrors when loading plugin resources
+* [#11487](https://github.com/netbox-community/netbox/issues/11487) - Remove "set null" option from non-writable custom fields during bulk edit
+* [#11491](https://github.com/netbox-community/netbox/issues/11491) - Show edit/delete buttons in user tokens table
+* [#11528](https://github.com/netbox-community/netbox/issues/11528) - Permit import of devices using uploaded file
+* [#11555](https://github.com/netbox-community/netbox/issues/11555) - Avoid inadvertent interpretation of search query as regular expression under global search (previously [#11516](https://github.com/netbox-community/netbox/issues/11516))
+* [#11562](https://github.com/netbox-community/netbox/issues/11562) - Correct ordering of virtual chassis interfaces with duplicate names
+* [#11574](https://github.com/netbox-community/netbox/issues/11574) - Fix exception when attempting to schedule reports/scripts
+* [#11620](https://github.com/netbox-community/netbox/issues/11620) - Correct available filter choices for interface PoE type
+* [#11635](https://github.com/netbox-community/netbox/issues/11635) - Pre-populate assigned VRF when following "first available IP" link from prefix view
+* [#11650](https://github.com/netbox-community/netbox/issues/11650) - Display error message when attempting to create device component with duplicate name
+
+---
+
## v3.4.3 (2023-01-20)
### Enhancements
@@ -30,8 +55,9 @@
* [#11483](https://github.com/netbox-community/netbox/issues/11483) - Apply configured formatting to custom date fields
* [#11488](https://github.com/netbox-community/netbox/issues/11488) - Add missing `description` fields to several REST API serializers
* [#11497](https://github.com/netbox-community/netbox/issues/11497) - Enforce `run_script` permission when executing scripts via REST API
-* [#11516](https://github.com/netbox-community/netbox/issues/11516) - Prevent text highlight utility from interpreting match as regex
+* ~[#11516](https://github.com/netbox-community/netbox/issues/11516) - Prevent text highlight utility from interpreting match as regex~
* [#11522](https://github.com/netbox-community/netbox/issues/11522) - Correct tag links under contact & tenant list views
+* [#11537](https://github.com/netbox-community/netbox/issues/11537) - Remove obsolete "Connection" column from power feeds table
* [#11544](https://github.com/netbox-community/netbox/issues/11544) - Catch ValidationError exception when filtering by invalid MAC address
---
diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py
index bdbaf9f18..3f016899e 100644
--- a/netbox/dcim/forms/bulk_import.py
+++ b/netbox/dcim/forms/bulk_import.py
@@ -18,7 +18,6 @@ from .common import ModuleCommonForm
__all__ = (
'CableImportForm',
- 'ChildDeviceImportForm',
'ConsolePortImportForm',
'ConsoleServerPortImportForm',
'DeviceBayImportForm',
@@ -413,6 +412,18 @@ class DeviceImportForm(BaseDeviceImportForm):
required=False,
help_text=_('Mounted rack face')
)
+ parent = CSVModelChoiceField(
+ queryset=Device.objects.all(),
+ to_field_name='name',
+ required=False,
+ help_text=_('Parent device (for child devices)')
+ )
+ device_bay = CSVModelChoiceField(
+ queryset=DeviceBay.objects.all(),
+ to_field_name='name',
+ required=False,
+ help_text=_('Device bay in which this device is installed (for child devices)')
+ )
airflow = CSVChoiceField(
choices=DeviceAirflowChoices,
required=False,
@@ -422,8 +433,8 @@ class DeviceImportForm(BaseDeviceImportForm):
class Meta(BaseDeviceImportForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
- 'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority',
- 'cluster', 'description', 'comments', 'tags',
+ 'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis',
+ 'vc_position', 'vc_priority', 'cluster', 'description', 'comments', 'tags',
]
def __init__(self, data=None, *args, **kwargs):
@@ -434,6 +445,7 @@ class DeviceImportForm(BaseDeviceImportForm):
# Limit location queryset by assigned site
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
+ self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
# Limit rack queryset by assigned site and group
params = {
@@ -442,6 +454,23 @@ class DeviceImportForm(BaseDeviceImportForm):
}
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
+ # Limit device bay queryset by parent device
+ if parent := data.get('parent'):
+ params = {f"device__{self.fields['parent'].to_field_name}": parent}
+ self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
+
+ def clean(self):
+ super().clean()
+
+ # Inherit site and rack from parent device
+ if parent := self.cleaned_data.get('parent'):
+ self.instance.site = parent.site
+ self.instance.rack = parent.rack
+
+ # Set parent_bay reverse relationship
+ if device_bay := self.cleaned_data.get('device_bay'):
+ self.instance.parent_bay = device_bay
+
class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
device = CSVModelChoiceField(
@@ -495,48 +524,6 @@ class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
return self.cleaned_data['replicate_components']
-class ChildDeviceImportForm(BaseDeviceImportForm):
- parent = CSVModelChoiceField(
- queryset=Device.objects.all(),
- to_field_name='name',
- help_text=_('Parent device')
- )
- device_bay = CSVModelChoiceField(
- queryset=DeviceBay.objects.all(),
- to_field_name='name',
- help_text=_('Device bay in which this device is installed')
- )
-
- class Meta(BaseDeviceImportForm.Meta):
- fields = [
- 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
- 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', 'tags'
- ]
-
- def __init__(self, data=None, *args, **kwargs):
- super().__init__(data, *args, **kwargs)
-
- if data:
-
- # Limit device bay queryset by parent device
- params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')}
- self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
-
- def clean(self):
- super().clean()
-
- # Set parent_bay reverse relationship
- device_bay = self.cleaned_data.get('device_bay')
- if device_bay:
- self.instance.parent_bay = device_bay
-
- # Inherit site and rack from parent device
- parent = self.cleaned_data.get('parent')
- if parent:
- self.instance.site = parent.site
- self.instance.rack = parent.rack
-
-
#
# Device components
#
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index c00e83672..4dd2f73eb 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -1170,7 +1170,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
label='PoE mode'
)
poe_type = MultipleChoiceField(
- choices=InterfacePoEModeChoices,
+ choices=InterfacePoETypeChoices,
required=False,
label='PoE type'
)
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 7a2ea50ba..730309156 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -580,7 +580,6 @@ class DeviceInterfaceTable(InterfaceTable):
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
'untagged_vlan', 'tagged_vlans', 'actions',
)
- order_by = ('name',)
default_columns = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
'cable', 'connection',
diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py
index 42d9c7879..c452c3efb 100644
--- a/netbox/dcim/tables/devicetypes.py
+++ b/netbox/dcim/tables/devicetypes.py
@@ -34,10 +34,19 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
url_params={'manufacturer_id': 'pk'},
verbose_name='Device Types'
)
- inventoryitem_count = tables.Column(
+ moduletype_count = columns.LinkedCountColumn(
+ viewname='dcim:moduletype_list',
+ url_params={'manufacturer_id': 'pk'},
+ verbose_name='Module Types'
+ )
+ inventoryitem_count = columns.LinkedCountColumn(
+ viewname='dcim:inventoryitem_list',
+ url_params={'manufacturer_id': 'pk'},
verbose_name='Inventory Items'
)
- platform_count = tables.Column(
+ platform_count = columns.LinkedCountColumn(
+ viewname='dcim:platform_list',
+ url_params={'manufacturer_id': 'pk'},
verbose_name='Platforms'
)
slug = tables.Column()
@@ -48,11 +57,12 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = models.Manufacturer
fields = (
- 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
- 'tags', 'contacts', 'actions', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
+ 'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
)
default_columns = (
- 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
+ 'pk', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
+ 'description', 'slug',
)
diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py
index 6772f96ad..c71a0aff1 100644
--- a/netbox/dcim/urls.py
+++ b/netbox/dcim/urls.py
@@ -177,7 +177,6 @@ urlpatterns = [
path('devices/', views.DeviceListView.as_view(), name='device_list'),
path('devices/add/', views.DeviceEditView.as_view(), name='device_add'),
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
- path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
path('devices/rename/', views.DeviceBulkRenameView.as_view(), name='device_bulk_rename'),
path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 46d12937b..9b49e799c 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -642,6 +642,7 @@ class RackListView(generic.ObjectListView):
filterset = filtersets.RackFilterSet
filterset_form = forms.RackFilterForm
table = tables.RackTable
+ template_name = 'dcim/rack_list.html'
class RackElevationListView(generic.ObjectListView):
@@ -842,6 +843,7 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
class ManufacturerListView(generic.ObjectListView):
queryset = Manufacturer.objects.annotate(
devicetype_count=count_related(DeviceType, 'manufacturer'),
+ moduletype_count=count_related(ModuleType, 'manufacturer'),
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer')
)
@@ -2090,22 +2092,15 @@ class DeviceBulkImportView(generic.BulkImportView):
queryset = Device.objects.all()
model_form = forms.DeviceImportForm
table = tables.DeviceImportTable
- template_name = 'dcim/device_import.html'
-
-
-class ChildDeviceBulkImportView(generic.BulkImportView):
- queryset = Device.objects.all()
- model_form = forms.ChildDeviceImportForm
- table = tables.DeviceImportTable
- template_name = 'dcim/device_import_child.html'
def save_object(self, object_form, request):
obj = object_form.save()
- # Save the reverse relation to the parent device bay
- device_bay = obj.parent_bay
- device_bay.installed_device = obj
- device_bay.save()
+ # For child devices, save the reverse relation to the parent device bay
+ if getattr(obj, 'parent_bay', None):
+ device_bay = obj.parent_bay
+ device_bay.installed_device = obj
+ device_bay.save()
return obj
diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py
index 79dc8c869..8216c5413 100644
--- a/netbox/extras/forms/scripts.py
+++ b/netbox/extras/forms/scripts.py
@@ -45,12 +45,16 @@ class ScriptForm(BootstrapMixin, forms.Form):
self.fields['_interval'] = interval
self.fields['_commit'] = commit
- def clean__schedule_at(self):
+ def clean(self):
scheduled_time = self.cleaned_data['_schedule_at']
- if scheduled_time and scheduled_time < timezone.now():
+ if scheduled_time and scheduled_time < local_now():
raise forms.ValidationError(_('Scheduled time must be in the future.'))
- return scheduled_time
+ # When interval is used without schedule at, raise an exception
+ if self.cleaned_data['_interval'] and not scheduled_time:
+ raise forms.ValidationError(_('Scheduled time must be set when recurs is used.'))
+
+ return self.cleaned_data
@property
def requires_input(self):
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index 14b033bcd..4842c0654 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -273,10 +273,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
'choices': "Choices may be set only for custom selection fields."
})
- # A selection field must have at least two choices defined
- if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.choices and len(self.choices) < 2:
+ # Selection fields must have at least one choice defined
+ if self.type in (
+ CustomFieldTypeChoices.TYPE_SELECT,
+ CustomFieldTypeChoices.TYPE_MULTISELECT
+ ) and not self.choices:
raise ValidationError({
- 'choices': "Selection fields must specify at least two choices."
+ 'choices': "Selection fields must specify at least one choice."
})
# A selection field's default (if any) must be present in its available choices
diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py
index 7694a1fbe..b56113ca1 100644
--- a/netbox/extras/plugins/__init__.py
+++ b/netbox/extras/plugins/__init__.py
@@ -1,4 +1,5 @@
import collections
+from importlib import import_module
from django.apps import AppConfig
from django.conf import settings
@@ -21,6 +22,15 @@ registry['plugins'] = {
'template_extensions': collections.defaultdict(list),
}
+DEFAULT_RESOURCE_PATHS = {
+ 'search_indexes': 'search.indexes',
+ 'graphql_schema': 'graphql.schema',
+ 'menu': 'navigation.menu',
+ 'menu_items': 'navigation.menu_items',
+ 'template_extensions': 'template_content.template_extensions',
+ 'user_preferences': 'preferences.preferences',
+}
+
#
# Plugin AppConfig class
@@ -58,58 +68,53 @@ class PluginConfig(AppConfig):
# Django apps to append to INSTALLED_APPS when plugin requires them.
django_apps = []
- # Default integration paths. Plugin authors can override these to customize the paths to
- # integrated components.
- search_indexes = 'search.indexes'
- graphql_schema = 'graphql.schema'
- menu = 'navigation.menu'
- menu_items = 'navigation.menu_items'
- template_extensions = 'template_content.template_extensions'
- user_preferences = 'preferences.preferences'
+ # Optional plugin resources
+ search_indexes = None
+ graphql_schema = None
+ menu = None
+ menu_items = None
+ template_extensions = None
+ user_preferences = None
+
+ def _load_resource(self, name):
+ # Import from the configured path, if defined.
+ if getattr(self, name):
+ return import_string(f"{self.__module__}.{self.name}")
+
+ # Fall back to the resource's default path. Return None if the module has not been provided.
+ default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'
+ default_module, resource_name = default_path.rsplit('.', 1)
+ try:
+ module = import_module(default_module)
+ return getattr(module, resource_name, None)
+ except ModuleNotFoundError:
+ pass
def ready(self):
plugin_name = self.name.rsplit('.', 1)[-1]
# Register search extensions (if defined)
- try:
- search_indexes = import_string(f"{self.__module__}.{self.search_indexes}")
- for idx in search_indexes:
- register_search(idx)
- except ImportError:
- pass
+ search_indexes = self._load_resource('search_indexes') or []
+ for idx in search_indexes:
+ register_search(idx)
# Register template content (if defined)
- try:
- template_extensions = import_string(f"{self.__module__}.{self.template_extensions}")
+ if template_extensions := self._load_resource('template_extensions'):
register_template_extensions(template_extensions)
- except ImportError:
- pass
# Register navigation menu and/or menu items (if defined)
- try:
- menu = import_string(f"{self.__module__}.{self.menu}")
+ if menu := self._load_resource('menu'):
register_menu(menu)
- except ImportError:
- pass
- try:
- menu_items = import_string(f"{self.__module__}.{self.menu_items}")
+ if menu_items := self._load_resource('menu_items'):
register_menu_items(self.verbose_name, menu_items)
- except ImportError:
- pass
# Register GraphQL schema (if defined)
- try:
- graphql_schema = import_string(f"{self.__module__}.{self.graphql_schema}")
+ if graphql_schema := self._load_resource('graphql_schema'):
register_graphql_schema(graphql_schema)
- except ImportError:
- pass
# Register user preferences (if defined)
- try:
- user_preferences = import_string(f"{self.__module__}.{self.user_preferences}")
+ if user_preferences := self._load_resource('user_preferences'):
register_user_preferences(plugin_name, user_preferences)
- except ImportError:
- pass
@classmethod
def validate(cls, user_config, netbox_version):
diff --git a/netbox/extras/plugins/urls.py b/netbox/extras/plugins/urls.py
index b4360dc9e..2f237f56a 100644
--- a/netbox/extras/plugins/urls.py
+++ b/netbox/extras/plugins/urls.py
@@ -1,9 +1,11 @@
+from importlib import import_module
+
from django.apps import apps
from django.conf import settings
from django.conf.urls import include
from django.contrib.admin.views.decorators import staff_member_required
from django.urls import path
-from django.utils.module_loading import import_string
+from django.utils.module_loading import import_string, module_has_submodule
from . import views
@@ -19,24 +21,21 @@ plugin_admin_patterns = [
# Register base/API URL patterns for each plugin
for plugin_path in settings.PLUGINS:
+ plugin = import_module(plugin_path)
plugin_name = plugin_path.split('.')[-1]
app = apps.get_app_config(plugin_name)
base_url = getattr(app, 'base_url') or app.label
# Check if the plugin specifies any base URLs
- try:
+ if module_has_submodule(plugin, 'urls'):
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
plugin_patterns.append(
path(f"{base_url}/", include((urlpatterns, app.label)))
)
- except ImportError:
- pass
# Check if the plugin specifies any API URLs
- try:
+ if module_has_submodule(plugin, 'api.urls'):
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
plugin_api_patterns.append(
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
)
- except ImportError:
- pass
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index 998d727a4..77c96de56 100644
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -21,7 +21,7 @@ from extras.models import JobResult
from extras.signals import clear_webhooks
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
-from utilities.exceptions import AbortTransaction
+from utilities.exceptions import AbortScript, AbortTransaction
from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .context_managers import change_logging
from .forms import ScriptForm
@@ -470,6 +470,14 @@ def run_script(data, request, commit=True, *args, **kwargs):
except AbortTransaction:
script.log_info("Database changes have been reverted automatically.")
clear_webhooks.send(request)
+ except AbortScript as e:
+ script.log_failure(
+ f"Script aborted with error: {e}"
+ )
+ script.log_info("Database changes have been reverted due to error.")
+ logger.error(f"Script aborted with error: {e}")
+ job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
+ clear_webhooks.send(request)
except Exception as e:
stacktrace = traceback.format_exc()
script.log_failure(
diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py
index 29e725507..81a607eec 100644
--- a/netbox/extras/tests/test_api.py
+++ b/netbox/extras/tests/test_api.py
@@ -101,6 +101,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
'content_types': ['dcim.site'],
'name': 'cf6',
'type': 'select',
+ 'choices': ['A', 'B', 'C']
},
]
bulk_update_data = {
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index c30064ff1..d069eed27 100644
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -923,6 +923,18 @@ class ServiceFilterSet(NetBoxModelFilterSet):
to_field_name='name',
label=_('Virtual machine (name)'),
)
+ ipaddress_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='ipaddresses',
+ queryset=IPAddress.objects.all(),
+ label=_('IP address (ID)'),
+ )
+ ipaddress = django_filters.ModelMultipleChoiceFilter(
+ field_name='ipaddresses__address',
+ queryset=IPAddress.objects.all(),
+ to_field_name='address',
+ label=_('IP address'),
+ )
+
port = NumericArrayFilter(
field_name='ports',
lookup_expr='contains'
diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py
index a2b06080a..711009a7e 100644
--- a/netbox/ipam/tests/test_filtersets.py
+++ b/netbox/ipam/tests/test_filtersets.py
@@ -1420,6 +1420,19 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Device.objects.bulk_create(devices)
+ interface = Interface.objects.create(
+ device=devices[0],
+ name='eth0',
+ type=InterfaceTypeChoices.TYPE_VIRTUAL
+ )
+ interface_ct = ContentType.objects.get_for_model(Interface).pk
+ ip_addresses = (
+ IPAddress(address='192.0.2.1/24', assigned_object_type_id=interface_ct, assigned_object_id=interface.pk),
+ IPAddress(address='192.0.2.2/24', assigned_object_type_id=interface_ct, assigned_object_id=interface.pk),
+ IPAddress(address='192.0.2.3/24', assigned_object_type_id=interface_ct, assigned_object_id=interface.pk),
+ )
+ IPAddress.objects.bulk_create(ip_addresses)
+
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
@@ -1439,6 +1452,9 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
Service(virtual_machine=virtual_machines[2], name='Service 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[2003]),
)
Service.objects.bulk_create(services)
+ services[0].ipaddresses.add(ip_addresses[0])
+ services[1].ipaddresses.add(ip_addresses[1])
+ services[2].ipaddresses.add(ip_addresses[2])
def test_name(self):
params = {'name': ['Service 1', 'Service 2']}
@@ -1470,6 +1486,13 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'virtual_machine': [vms[0].name, vms[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_ipaddress(self):
+ ips = IPAddress.objects.all()[:2]
+ params = {'ipaddress_id': [ips[0].pk, ips[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'ipaddress': [str(ips[0].address), str(ips[1].address)]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = L2VPN.objects.all()
diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py
index d69445e78..b4ad39b5e 100644
--- a/netbox/netbox/forms/base.py
+++ b/netbox/netbox/forms/base.py
@@ -131,7 +131,7 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
def _extend_nullable_fields(self):
nullable_custom_fields = [
- name for name, customfield in self.custom_fields.items() if not customfield.required
+ name for name, customfield in self.custom_fields.items() if (not customfield.required and customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE)
]
self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 2de06dd10..8517efca1 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
-VERSION = '3.4.3'
+VERSION = '3.4.4'
# Hostname
HOSTNAME = platform.node()
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
index 795f4ad56..d855490d1 100644
--- a/netbox/netbox/views/generic/object_views.py
+++ b/netbox/netbox/views/generic/object_views.py
@@ -453,6 +453,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
if component_form.is_valid():
new_components.append(component_form)
+ else:
+ form.errors.update(component_form.errors)
+ break
if not form.errors and not component_form.errors:
try:
diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html
deleted file mode 100644
index b30de60c2..000000000
--- a/netbox/templates/dcim/device_import.html
+++ /dev/null
@@ -1,5 +0,0 @@
-{% extends 'generic/bulk_import.html' %}
-
-{% block tabs %}
- {% include 'dcim/inc/device_import_header.html' %}
-{% endblock %}
diff --git a/netbox/templates/dcim/device_import_child.html b/netbox/templates/dcim/device_import_child.html
deleted file mode 100644
index d0dc72b61..000000000
--- a/netbox/templates/dcim/device_import_child.html
+++ /dev/null
@@ -1,5 +0,0 @@
-{% extends 'generic/bulk_import.html' %}
-
-{% block tabs %}
- {% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %}
-{% endblock %}
diff --git a/netbox/templates/dcim/inc/device_import_header.html b/netbox/templates/dcim/inc/device_import_header.html
deleted file mode 100644
index 97e849c2a..000000000
--- a/netbox/templates/dcim/inc/device_import_header.html
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
- Images and Labels
- Images only
- Labels only
-
-
-
-
-
- Sort By {{ sort_display_name }}
-
-
-
-
+
+
+
+ View List
+
+
+
+ Images and Labels
+ Images only
+ Labels only
+
+
+
+
+
+ Sort By {{ sort_display_name }}
+
+
+
+
{% endblock %}
{% block content-wrapper %}
diff --git a/netbox/templates/dcim/rack_list.html b/netbox/templates/dcim/rack_list.html
new file mode 100644
index 000000000..897625af6
--- /dev/null
+++ b/netbox/templates/dcim/rack_list.html
@@ -0,0 +1,9 @@
+{% extends 'generic/object_list.html' %}
+{% load helpers %}
+{% load static %}
+
+{% block extra_controls %}
+
+ View Elevations
+
+{% endblock %}
\ No newline at end of file
diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html
index 8b3e317c0..e269e9da6 100644
--- a/netbox/templates/generic/object_list.html
+++ b/netbox/templates/generic/object_list.html
@@ -26,16 +26,15 @@ Context:
{% plugin_list_buttons model %}
-
{% block extra_controls %}{% endblock %}
{% if 'add' in actions %}
- {% add_button model %}
+ {% add_button model %}
{% endif %}
{% if 'import' in actions %}
- {% import_button model %}
+ {% import_button model %}
{% endif %}
{% if 'export' in actions %}
- {% export_button model %}
+ {% export_button model %}
{% endif %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html
index a0baf3325..6d986aed5 100644
--- a/netbox/templates/ipam/prefix.html
+++ b/netbox/templates/ipam/prefix.html
@@ -133,7 +133,7 @@
{% with first_available_ip=object.get_first_available_ip %}
{% if first_available_ip %}
{% if perms.ipam.add_ipaddress %}
-
{{ first_available_ip }}
+
{{ first_available_ip }}
{% else %}
{{ first_available_ip }}
{% endif %}
diff --git a/netbox/users/tables.py b/netbox/users/tables.py
index 8fbe9e8b3..0f1484887 100644
--- a/netbox/users/tables.py
+++ b/netbox/users/tables.py
@@ -19,6 +19,14 @@ COPY_BUTTON = """
"""
+class TokenActionsColumn(columns.ActionsColumn):
+ # Subclass ActionsColumn to disregard permissions for edit & delete buttons
+ actions = {
+ 'edit': columns.ActionsItem('Edit', 'pencil', None, 'warning'),
+ 'delete': columns.ActionsItem('Delete', 'trash-can-outline', None, 'danger'),
+ }
+
+
class TokenTable(NetBoxTable):
key = columns.TemplateColumn(
template_code=TOKEN
@@ -32,7 +40,7 @@ class TokenTable(NetBoxTable):
allowed_ips = columns.TemplateColumn(
template_code=ALLOWED_IPS
)
- actions = columns.ActionsColumn(
+ actions = TokenActionsColumn(
actions=('edit', 'delete'),
extra_buttons=COPY_BUTTON
)
diff --git a/netbox/utilities/exceptions.py b/netbox/utilities/exceptions.py
index 657e90745..d7418d0cb 100644
--- a/netbox/utilities/exceptions.py
+++ b/netbox/utilities/exceptions.py
@@ -24,6 +24,13 @@ class AbortRequest(Exception):
self.message = message
+class AbortScript(Exception):
+ """
+ Raised to cleanly abort a script.
+ """
+ pass
+
+
class PermissionsViolation(Exception):
"""
Raised when an operation was prevented because it would violate the
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
index b6f626eb4..23c2666df 100644
--- a/netbox/utilities/utils.py
+++ b/netbox/utilities/utils.py
@@ -527,6 +527,7 @@ def highlight_string(value, highlight, trim_pre=None, trim_post=None, trim_place
if type(highlight) is re.Pattern:
pre, match, post = highlight.split(value, maxsplit=1)
else:
+ highlight = re.escape(highlight)
pre, match, post = re.split(fr'({highlight})', value, maxsplit=1, flags=re.IGNORECASE)
except ValueError as e:
# Match not found
diff --git a/requirements.txt b/requirements.txt
index 3ab7faace..3cb2529a8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
bleach==5.0.1
-Django==4.1.5
+Django==4.1.6
django-cors-headers==3.13.0
django-debug-toolbar==3.8.1
django-filter==22.1
@@ -19,13 +19,13 @@ graphene-django==3.0.0
gunicorn==20.1.0
Jinja2==3.1.2
Markdown==3.3.7
-mkdocs-material==9.0.6
+mkdocs-material==9.0.10
mkdocstrings[python-legacy]==0.20.0
netaddr==0.8.0
Pillow==9.4.0
psycopg2-binary==2.9.5
PyYAML==6.0
-sentry-sdk==1.13.0
+sentry-sdk==1.14.0
social-auth-app-django==5.0.0
social-auth-core[openidconnect]==4.3.0
svgwrite==1.4.3