Просмотр исходного кода

Merge pull request #11655 from netbox-community/develop

Release v3.4.4
Jeremy Stretch 3 лет назад
Родитель
Сommit
65417dbf9e
39 измененных файлов с 324 добавлено и 261 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 47 86
      README.md
  4. 1 1
      SECURITY.md
  5. 1 1
      base_requirements.txt
  6. 13 0
      docs/customization/custom-scripts.md
  7. 1 1
      docs/index.md
  8. 4 1
      docs/installation/3-netbox.md
  9. 4 1
      docs/installation/4-gunicorn.md
  10. 1 1
      docs/plugins/development/navigation.md
  11. 27 1
      docs/release-notes/version-3.4.md
  12. 32 45
      netbox/dcim/forms/bulk_import.py
  13. 1 1
      netbox/dcim/forms/filtersets.py
  14. 0 1
      netbox/dcim/tables/devices.py
  15. 15 5
      netbox/dcim/tables/devicetypes.py
  16. 0 1
      netbox/dcim/urls.py
  17. 7 12
      netbox/dcim/views.py
  18. 7 3
      netbox/extras/forms/scripts.py
  19. 6 3
      netbox/extras/models/customfields.py
  20. 39 34
      netbox/extras/plugins/__init__.py
  21. 6 7
      netbox/extras/plugins/urls.py
  22. 9 1
      netbox/extras/scripts.py
  23. 1 0
      netbox/extras/tests/test_api.py
  24. 12 0
      netbox/ipam/filtersets.py
  25. 23 0
      netbox/ipam/tests/test_filtersets.py
  26. 1 1
      netbox/netbox/forms/base.py
  27. 1 1
      netbox/netbox/settings.py
  28. 3 0
      netbox/netbox/views/generic/object_views.py
  29. 0 5
      netbox/templates/dcim/device_import.html
  30. 0 5
      netbox/templates/dcim/device_import_child.html
  31. 0 8
      netbox/templates/dcim/inc/device_import_header.html
  32. 27 24
      netbox/templates/dcim/rack_elevation_list.html
  33. 9 0
      netbox/templates/dcim/rack_list.html
  34. 3 4
      netbox/templates/generic/object_list.html
  35. 1 1
      netbox/templates/ipam/prefix.html
  36. 9 1
      netbox/users/tables.py
  37. 7 0
      netbox/utilities/exceptions.py
  38. 1 0
      netbox/utilities/utils.py
  39. 3 3
      requirements.txt

+ 1 - 1
.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

+ 1 - 1
.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

+ 47 - 86
README.md

@@ -1,107 +1,73 @@
 <div align="center">
   <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
+
+  The premiere source of truth powering network automation
 </div>
 
+![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
+
 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.
-Available as open source software under the Apache 2.0 license, NetBox is
-employed by thousands of organizations around the world.
+Available as open source software under the Apache 2.0 license, NetBox serves
+as the cornerstone for network automation in thousands of organizations.
+
+* **Physical infrasucture:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
+* **Modern IPAM:** All the standard IPAM functionality you expect, plus VRF import/export tracking, VLAN management, and overlay support.
+* **Data circuits:** Confidently manage the delivery of crtical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
+* **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets.
+* **Organization:** Manage tenant and contact assignments natively.
+* **Powerful search:** Easily find anything you need using a single global search function.
+* **Comprehensive logging:** Leverage both automatic change logging and user-submitted journal entries to track your network's growth over time.
+* **Endless customization:** Custom fields, custom links, tags, export templates, custom validation, reports, scripts, and more!
+* **Flexible permissions:** An advanced permissions systems enables very flexible delegation of permissions.
+* **Integrations:** Easily connect NetBox to your other tooling via its REST & GraphQL APIs.
+* **Plugins:** Not finding what you need in the core application? Try one of many community plugins - or build your own!
 
-![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
+![Screenshot of NetBox UI](docs/media/screenshots/netbox-ui.png "NetBox UI")
 
-[![Timeline graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg)](https://github.com/netbox-community/netbox/commits)
-[![Issue status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg)](https://github.com/netbox-community/netbox/issues)
-[![Pull request status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg)](https://github.com/netbox-community/netbox/pulls)
-[![Top contributors](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg)](https://github.com/netbox-community/netbox/graphs/contributors)
-<br />Stats via [Repography](https://repography.com)
+## Getting Started
 
-## About NetBox
+* Just want to explore? Check out [our public demo](https://demo.netbox.dev/) right now!
+* The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction.
+* Choose your deployment: [self-hosted](https://github.com/netbox-community/netbox), [Docker](https://github.com/netbox-community/netbox-docker), or [NetBox Cloud](https://netboxlabs.com/netbox-cloud/).
+* Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox!
 
-![Screenshot of NetBox UI](docs/media/screenshots/netbox-ui.png "NetBox UI")
+## Get Involved
 
-Myriad infrastructure components can be modeled in NetBox, including:
-
-* Hierarchical regions, site groups, sites, and locations
-* Racks, devices, and device components
-* Cables and wireless connections
-* Power distribution
-* Data circuits and providers
-* Virtual machines and clusters
-* IP prefixes, ranges, and addresses
-* VRFs and route targets
-* L2VPN and overlays
-* FHRP groups (VRRP, HSRP, etc.)
-* AS numbers
-* VLANs and scoped VLAN groups
-* Organizational tenants and contacts
-
-In addition to its extensive built-in models and functionality, NetBox can be
-customized and extended through the use of:
-
-* Custom fields
-* Custom links
-* Configuration contexts
-* Custom model validation rules
-* Reports
-* Custom scripts
-* Export templates
-* Conditional webhooks
-* Plugins
-* Single sign-on (SSO) authentication
-* NAPALM integration
-* Detailed change logging
-
-NetBox also features a complete REST API as well as a GraphQL API for easily
-integrating with other tools and systems.
-
-The complete documentation for NetBox can be found at [docs.netbox.dev](https://docs.netbox.dev/).
-A public demo instance is available at [demo.netbox.dev](https://demo.netbox.dev).
-
-NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
-Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
-complete list of requirements, see `requirements.txt`. The code is available
-[on GitHub](https://github.com/netbox-community/netbox).
+* Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter!
+* Join the conversation on [the discussion forum](https://github.com/netbox-community/netbox/discussions) and [Slack](https://netdev.chat/)!
+* Already a power user? You can [suggest a feature](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+feature&template=feature_request.yaml) or [report a bug](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+bug&template=bug_report.yaml) on GitHub.
+* Contributions from the community are encouraged and appreciated! Check out our [contributing guide](CONTRIBUTING.md) to get started.
+
+## Project Stats
 
 <div align="center">
-  <h3>Thank you to our sponsors!</h3>
+  <a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg" alt="Timeline graph"></a>
+  <a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg" alt="Issues graph"></a>
+  <a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg" alt="Pull requests graph"></a>
+  <a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg" alt="Top contributors"></a>
+  <br />Stats via <a href="https://repography.com">Repography</a>
+</div>
 
-  [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
+## Sponsors
+
+<div align="center">
+
+  [![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
-  [![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com/)
+  [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
-  [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/)
+  [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com)
   <br />
-  [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io/)
+  [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
-  [![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech/)
+  [![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com)
 
 </div>
 
-### 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
 
 ![Screenshot of main page (dark mode)](docs/media/screenshots/home-dark.png "Main page (dark mode)")
 
@@ -110,8 +76,3 @@ our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
 ![Screenshot of prefixes hierarchy](docs/media/screenshots/prefixes-list.png "Prefixes hierarchy")
 
 ![Screenshot of cable trace](docs/media/screenshots/cable-trace.png "Cable tracing")
-
-### Related projects
-
-Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions)
-for a list of relevant community projects.

+ 1 - 1
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
 

+ 1 - 1
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

+ 13 - 0
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

+ 1 - 1
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/)

+ 4 - 1
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

+ 4 - 1
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/

+ 1 - 1
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
 

+ 27 - 1
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
 
 ---

+ 32 - 45
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
 #

+ 1 - 1
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'
     )

+ 0 - 1
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',

+ 15 - 5
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',
         )
 
 

+ 0 - 1
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'),

+ 7 - 12
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
 

+ 7 - 3
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):

+ 6 - 3
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

+ 39 - 34
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):

+ 6 - 7
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

+ 9 - 1
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(

+ 1 - 0
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 = {

+ 12 - 0
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'

+ 23 - 0
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()

+ 1 - 1
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)
 

+ 1 - 1
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()

+ 3 - 0
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:

+ 0 - 5
netbox/templates/dcim/device_import.html

@@ -1,5 +0,0 @@
-{% extends 'generic/bulk_import.html' %}
-
-{% block tabs %}
-  {% include 'dcim/inc/device_import_header.html' %}
-{% endblock %}

+ 0 - 5
netbox/templates/dcim/device_import_child.html

@@ -1,5 +0,0 @@
-{% extends 'generic/bulk_import.html' %}
-
-{% block tabs %}
-  {% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %}
-{% endblock %}

+ 0 - 8
netbox/templates/dcim/inc/device_import_header.html

@@ -1,8 +0,0 @@
-<ul class="nav nav-tabs px-3">
-    <li class="nav-item" role="presentation">
-        <a class ="nav-link{% if not active_tab %} active{% endif %}" href="{% url 'dcim:device_import' %}">Racked Devices</a>
-    </li>
-    <li class="nav-item" role="presentation">
-        <a class="nav-link{% if active_tab == 'child_import' %} active{% endif %}" href="{% url 'dcim:device_import_child' %}">Child Devices</a>
-    </li>
-</ul>

+ 27 - 24
netbox/templates/dcim/rack_elevation_list.html

@@ -5,31 +5,34 @@
 {% block title %}Rack Elevations{% endblock %}
 
 {% block controls %}
-    <div class="controls">
-        <div class="control-group">
-            <div class="btn-group btn-group-sm" role="group">
-                <select class="btn btn-sm btn-outline-secondary rack-view">
-                  <option value="images-and-labels" selected="selected">Images and Labels</option>
-                  <option value="images-only">Images only</option>
-                  <option value="labels-only">Labels only</option>
-                </select>
-            </div>
-            <div class="btn-group btn-group-sm" role="group">
-                <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-outline-secondary{% if rack_face == 'front' %} active{% endif %}">Front</a>
-                <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-outline-secondary{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
-            </div>
-            <div class="dropdown">
-              <button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-                <i class="mdi mdi-sort"></i>&nbsp;Sort By {{ sort_display_name }}
-              </button>
-              <ul class="dropdown-menu dropdown-menu-end">
-                {% for sort_key, sort_display_name in sort_choices.items %}
-                  <li><a class="dropdown-item{% if sort == sort_key %} active{% endif %}" href="{% url 'dcim:rack_elevation_list' %}{% querystring request sort=sort_key %}">{{ sort_display_name }}</a></li>
-                {% endfor %}
-              </ul>
-            </div>
-        </div>
+  <div class="controls">
+    <div class="control-group">
+      <a href="{% url 'dcim:rack_list' %}{% querystring request %}" class="btn btn-sm btn-primary">
+        <i class="mdi mdi-format-list-checkbox"></i> View List
+      </a>
+      <div class="btn-group btn-group-sm" role="group">
+        <select class="btn btn-sm btn-outline-secondary rack-view">
+          <option value="images-and-labels" selected="selected">Images and Labels</option>
+          <option value="images-only">Images only</option>
+          <option value="labels-only">Labels only</option>
+        </select>
+      </div>
+      <div class="btn-group btn-group-sm" role="group">
+        <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-outline-secondary{% if rack_face == 'front' %} active{% endif %}">Front</a>
+        <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-outline-secondary{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
+      </div>
+      <div class="dropdown">
+        <button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+          <i class="mdi mdi-sort"></i>&nbsp;Sort By {{ sort_display_name }}
+        </button>
+        <ul class="dropdown-menu dropdown-menu-end">
+          {% for sort_key, sort_display_name in sort_choices.items %}
+            <li><a class="dropdown-item{% if sort == sort_key %} active{% endif %}" href="{% url 'dcim:rack_elevation_list' %}{% querystring request sort=sort_key %}">{{ sort_display_name }}</a></li>
+          {% endfor %}
+        </ul>
+      </div>
     </div>
+  </div>
 {% endblock %}
 
 {% block content-wrapper %}

+ 9 - 0
netbox/templates/dcim/rack_list.html

@@ -0,0 +1,9 @@
+{% extends 'generic/object_list.html' %}
+{% load helpers %}
+{% load static %}
+
+{% block extra_controls %}
+  <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request %}" class="btn btn-sm btn-primary">
+    <i class="mdi mdi-view-day-outline"></i> View Elevations
+  </a>
+{% endblock %}

+ 3 - 4
netbox/templates/generic/object_list.html

@@ -26,16 +26,15 @@ Context:
   <div class="controls">
     <div class="control-group">
       {% 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 %}
     </div>
   </div>

+ 1 - 1
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 %}
-                    <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}">{{ first_available_ip }}</a>
+                    <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}{% if object.vrf %}&vrf={{ object.vrf_id }}{% endif %}">{{ first_available_ip }}</a>
                   {% else %}
                     {{ first_available_ip }}
                   {% endif %}

+ 9 - 1
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
     )

+ 7 - 0
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

+ 1 - 0
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

+ 3 - 3
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