diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index e5cbc2ece..3d2038b22 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -26,7 +26,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
- placeholder: v4.0.0
+ placeholder: v4.0.3
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 83125b4fa..bd9a17ff9 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: v4.0.0
+ placeholder: v4.0.3
validations:
required: true
- type: dropdown
diff --git a/.github/workflows/close-incomplete-issues.yml b/.github/workflows/close-incomplete-issues.yml
new file mode 100644
index 000000000..4d31d735e
--- /dev/null
+++ b/.github/workflows/close-incomplete-issues.yml
@@ -0,0 +1,32 @@
+# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
+name: Close incomplete issues
+
+on:
+ schedule:
+ - cron: '15 4 * * *'
+ workflow_dispatch:
+
+permissions:
+ actions: write
+ issues: write
+
+jobs:
+ stale:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/stale@v9
+ with:
+ close-issue-message: >
+ This issue is being closed as no further information has been provided. If
+ you would like to revisit this topic, please first modify your original post
+ to include all the requested detail, and then ask that the issue be reopened.
+ days-before-stale: 7
+ days-before-close: 7
+ only-issue-labels: 'status: revisions needed'
+ operations-per-run: 100
+ remove-stale-when-updated: false
+ stale-issue-label: 'pending closure'
+ stale-issue-message: >
+ This is a reminder that additional information is needed in order to further
+ triage this issue. If the requested details are not provided, the issue will
+ soon be closed automatically.
diff --git a/.github/workflows/close-stale-issues.yml b/.github/workflows/close-stale-issues.yml
index a1bbb0b7e..1ac1ea687 100644
--- a/.github/workflows/close-stale-issues.yml
+++ b/.github/workflows/close-stale-issues.yml
@@ -7,6 +7,7 @@ on:
workflow_dispatch:
permissions:
+ actions: write
issues: write
pull-requests: write
@@ -16,18 +17,19 @@ jobs:
steps:
- uses: actions/stale@v9
with:
+ # General parameters
+ operations-per-run: 100
+ remove-stale-when-updated: false
+
+ # Issue parameters
close-issue-message: >
This issue has been automatically closed due to lack of activity. In an
effort to reduce noise, please do not comment any further. Note that the
core maintainers may elect to reopen this issue at a later date if deemed
necessary.
- close-pr-message: >
- This PR has been automatically closed due to lack of activity.
- days-before-stale: 90
- days-before-close: 30
+ days-before-issue-stale: 90
+ days-before-issue-close: 30
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
- operations-per-run: 100
- remove-stale-when-updated: false
stale-issue-label: 'pending closure'
stale-issue-message: >
This issue has been automatically marked as stale because it has not had
@@ -37,6 +39,12 @@ jobs:
process by "bumping" the issue; doing so will result in its immediate closure
and you may be barred from participating in any future discussions. Please see
our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
+
+ # Pull request parameters
+ close-pr-message: >
+ This PR has been automatically closed due to lack of activity.
+ days-before-pr-stale: 15
+ days-before-pr-close: 15
stale-pr-label: 'pending closure'
stale-pr-message: >
This PR has been automatically marked as stale because it has not had
diff --git a/.gitignore b/.gitignore
index 93954fd41..ac5f420b4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@ yarn-error.log*
/venv/
/*.sh
local_requirements.txt
+local_settings.py
!upgrade.sh
fabfile.py
gunicorn.py
diff --git a/README.md b/README.md
index 8d2efed23..4d21003b5 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
@@ -95,16 +95,6 @@ NetBox automatically logs the creation, modification, and deletion of all manage
* Contributions from the community are encouraged and appreciated! Check out our [contributing guide](CONTRIBUTING.md) to get started.
* [Share your idea](https://plugin-ideas.netbox.dev/) for a new plugin, or [learn how to build one](https://github.com/netbox-community/netbox-plugin-tutorial) yourself!
-## Project Stats
-
-
-
-
-
-
- Stats via Repography
-
-
## Screenshots
diff --git a/base_requirements.txt b/base_requirements.txt
index 305df4dba..9912f1d6b 100644
--- a/base_requirements.txt
+++ b/base_requirements.txt
@@ -131,7 +131,7 @@ social-auth-app-django
strawberry-graphql
# Strawberry GraphQL Django extension
-# https://github.com/strawberry-graphql/strawberry-django/blob/main/CHANGELOG.md
+# https://github.com/strawberry-graphql/strawberry-django/releases
strawberry-graphql-django
# SVG image rendering (used for rack elevations)
diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json
index b6632dd4c..5cfdfd9d0 100644
--- a/contrib/generated_schema.json
+++ b/contrib/generated_schema.json
@@ -179,6 +179,9 @@
"usb-micro-ab",
"usb-3-b",
"usb-3-micro-b",
+ "molex-micro-fit-1x2",
+ "molex-micro-fit-2x2",
+ "molex-micro-fit-2x4",
"dc-terminal",
"saf-d-grid",
"neutrik-powercon-20",
@@ -281,6 +284,9 @@
"usb-a",
"usb-micro-b",
"usb-c",
+ "molex-micro-fit-1x2",
+ "molex-micro-fit-2x2",
+ "molex-micro-fit-2x4",
"dc-terminal",
"hdot-cx",
"saf-d-grid",
@@ -353,6 +359,8 @@
"800gbase-x-qsfpdd",
"800gbase-x-osfp",
"1000base-kx",
+ "2.5gbase-kx",
+ "5gbase-kr",
"10gbase-kr",
"10gbase-kx4",
"25gbase-kr",
@@ -373,6 +381,8 @@
"gsm",
"cdma",
"lte",
+ "4g",
+ "5g",
"sonet-oc3",
"sonet-oc12",
"sonet-oc48",
@@ -406,12 +416,15 @@
"e3",
"xdsl",
"docsis",
+ "bpon",
+ "epon",
+ "10g-epon",
"gpon",
"xg-pon",
"xgs-pon",
"ng-pon2",
- "epon",
- "10g-epon",
+ "25g-pon",
+ "50g-pon",
"cisco-stackwise",
"cisco-stackwise-plus",
"cisco-flexstack",
diff --git a/contrib/uwsgi.ini b/contrib/uwsgi.ini
index c74c05393..a8bedc1d7 100644
--- a/contrib/uwsgi.ini
+++ b/contrib/uwsgi.ini
@@ -26,6 +26,9 @@ chdir = netbox
; specify the WSGI module to load
module = netbox.wsgi
+; workaround to make uWSGI reloads work with pyuwsgi (not to be used if using uwsgi package instead)
+binary-path = venv/bin/python
+
; only log internal messages and errors (reverse proxy already logs the requests)
disable-logging = true
log-5xx = true
diff --git a/docs/_theme/main.html b/docs/_theme/main.html
index 4dfc4e14e..99907bf42 100644
--- a/docs/_theme/main.html
+++ b/docs/_theme/main.html
@@ -2,8 +2,8 @@
{% block site_meta %}
{{ super() }}
- {# Disable search indexing unless we're building for ReadTheDocs #}
- {% if not config.extra.readthedocs %}
+ {# Disable search indexing unless we're building for public consumption #}
+ {% if not config.extra.build_public %}
{% endif %}
{% endblock %}
diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md
index bda365995..90eb8c0cf 100644
--- a/docs/configuration/required-parameters.md
+++ b/docs/configuration/required-parameters.md
@@ -94,15 +94,25 @@ REDIS = {
}
```
-!!! note
- If you are upgrading from a NetBox release older than v2.7.0, please note that the Redis connection configuration
- settings have changed. Manual modification to bring the `REDIS` section inline with the above specification is
- necessary
-
!!! warning
It is highly recommended to keep the task and cache databases separate. Using the same database number on the
same Redis instance for both may result in queued background tasks being lost during cache flushing events.
+### UNIX Socket Support
+
+Redis may alternatively be configured by specifying a complete URL instead of individual components. This approach supports the use of a UNIX socket connection. For example:
+
+```python
+REDIS = {
+ 'tasks': {
+ 'URL': 'unix:///run/redis-netbox/redis.sock?db=0'
+ },
+ 'caching': {
+ 'URL': 'unix:///run/redis-netbox/redis.sock?db=1'
+ },
+}
+```
+
### Using Redis Sentinel
If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal
diff --git a/docs/configuration/security.md b/docs/configuration/security.md
index 45d5bed3f..15702f649 100644
--- a/docs/configuration/security.md
+++ b/docs/configuration/security.md
@@ -159,9 +159,12 @@ Note that enabling this setting causes NetBox to update a user's session in the
## LOGIN_REQUIRED
-Default: False
+Default: True
-Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox but not make any changes.
+When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes).
+
+!!! info "Changed in NetBox v4.0.2"
+ Prior to NetBox v4.0.2, this setting was disabled by default.
---
diff --git a/docs/configuration/system.md b/docs/configuration/system.md
index d0814bca6..a1e0ebb17 100644
--- a/docs/configuration/system.md
+++ b/docs/configuration/system.md
@@ -198,3 +198,11 @@ If `STORAGE_BACKEND` is not defined, this setting will be ignored.
Default: UTC
The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
+
+---
+
+## TRANSLATION_ENABLED
+
+Default: True
+
+Enables language translation for the user interface. (This parameter maps to Django's [USE_I18N](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-USE_I18N) setting.)
diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md
index 48f72aa9f..875b2b869 100644
--- a/docs/development/release-checklist.md
+++ b/docs/development/release-checklist.md
@@ -72,7 +72,7 @@ In cases where upgrading a dependency to its most recent release is breaking, it
### Update UI Dependencies
-Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](http://0.0.0.0:9000/development/web-ui/#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution.
+Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](./web-ui.md#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution.
### Rebuild the Device Type Definition Schema
diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md
index 9aa75ce4a..c042be6ec 100644
--- a/docs/plugins/development/index.md
+++ b/docs/plugins/development/index.md
@@ -55,18 +55,20 @@ project-name/
- template_content.py
- urls.py
- views.py
+ - pyproject.toml
- README.md
- - setup.py
```
The top level is the project root, which can have any name that you like. Immediately within the root should exist several items:
-* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment.
+* `pyproject.toml` - is a standard configuration file used to install the plugin package within the Python environment.
* `README.md` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write `README` files using a markup language such as Markdown to enable human-friendly display.
* The plugin source directory. This must be a valid Python package name, typically comprising only lowercase letters, numbers, and underscores.
The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class, discussed below.
+**Note:** The [Cookiecutter NetBox Plugin](https://github.com/netbox-community/cookiecutter-netbox-plugin) can be used to auto-generate all the needed directories and files for a new plugin.
+
## PluginConfig
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
@@ -136,31 +138,48 @@ Apps from this list are inserted *before* the plugin's `PluginConfig` in the ord
Any additional apps must be installed within the same Python environment as NetBox or `ImproperlyConfigured` exceptions will be raised when loading the plugin.
-## Create setup.py
+## Create pyproject.toml
-`setup.py` is the [setup script](https://docs.python.org/3.10/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:
+`pyproject.toml` is the [configuration file](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/) used to package and install our plugin once it's finished. It is used by packaging tools, as well as other tools. The primary function of this file is to call the build system to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. There are three possible TOML tables in this file:
-```python
-from setuptools import find_packages, setup
+* `[build-system]` allows you to declare which build backend you use and which other dependencies (if any) are needed to build your project.
+* `[project]` is the format that most build backends use to specify your project’s basic metadata, such as the author's name, project URL, etc.
+* `[tool]` has tool-specific subtables, e.g., `[tool.black]`, `[tool.mypy]`. Consult the particular tool’s documentation for reference.
+
+An example `pyproject.toml` is below:
+
+```
+# See PEP 518 for the spec of this file
+# https://www.python.org/dev/peps/pep-0518/
+
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "my-example-plugin"
+version = "0.1.0"
+authors = [
+ {name = "John Doe", email = "test@netboxlabs.com"},
+]
+description = "An example NetBox plugin."
+readme = "README.md"
+
+classifiers=[
+ 'Development Status :: 3 - Alpha',
+ 'Intended Audience :: Developers',
+ 'Natural Language :: English',
+ "Programming Language :: Python :: 3 :: Only",
+ 'Programming Language :: Python :: 3.10',
+ 'Programming Language :: Python :: 3.11',
+ 'Programming Language :: Python :: 3.12',
+]
+
+requires-python = ">=3.10.0"
-setup(
- name='my-example-plugin',
- version='0.1',
- description='An example NetBox plugin',
- url='https://github.com/jeremystretch/my-example-plugin',
- author='Jeremy Stretch',
- license='Apache 2.0',
- install_requires=[],
- packages=find_packages(),
- include_package_data=True,
- zip_safe=False,
-)
```
-Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html).
-
-!!! info
- `zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699)
+Many of these are self-explanatory, but for more information, see the [pyproject.toml documentation](https://packaging.python.org/en/latest/specifications/pyproject-toml/).
## Create a Virtual Environment
@@ -178,11 +197,12 @@ echo /opt/netbox/netbox > $VENV/lib/python3.10/site-packages/netbox.pth
## Development Installation
-To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):
+To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `pip` from the plugin's root directory with the `-e` flag:
```no-highlight
-$ python setup.py develop
+$ pip install -e .
```
+More information on editable builds can be found at [Editable installs for pyproject.toml ](https://peps.python.org/pep-0660/).
## Configure NetBox
diff --git a/docs/release-notes/version-4.0.md b/docs/release-notes/version-4.0.md
index a0a4fad84..14fdbd1d0 100644
--- a/docs/release-notes/version-4.0.md
+++ b/docs/release-notes/version-4.0.md
@@ -1,6 +1,90 @@
# NetBox v4.0
-## v4.0.1 (FUTURE)
+## v4.0.4 (FUTURE)
+
+---
+
+## v4.0.3 (2024-05-22)
+
+### Enhancements
+
+* [#12984](https://github.com/netbox-community/netbox/issues/12984) - Add Molex Micro-Fit power port & outlet types
+* [#13764](https://github.com/netbox-community/netbox/issues/13764) - Enable contact assignments for aggregates, prefixes, IP ranges, and IP addresses
+* [#14639](https://github.com/netbox-community/netbox/issues/14639) - Add Ukrainian translation support
+* [#14653](https://github.com/netbox-community/netbox/issues/14653) - Add an inventory items table column for all device components
+* [#14686](https://github.com/netbox-community/netbox/issues/14686) - Add German translation support
+* [#14855](https://github.com/netbox-community/netbox/issues/14855) - Add Chinese translation support
+* [#14948](https://github.com/netbox-community/netbox/issues/14948) - Introduce the `has_virtual_device_context` filter for devices
+* [#15353](https://github.com/netbox-community/netbox/issues/15353) - Improve error reporting when custom scripts fail to load
+* [#15496](https://github.com/netbox-community/netbox/issues/15496) - Implement dedicated views for management of circuit terminations
+* [#15603](https://github.com/netbox-community/netbox/issues/15603) - Add 4G & 5G cellular interface types
+* [#15962](https://github.com/netbox-community/netbox/issues/15962) - Enable UNIX socket connections for Redis
+
+### Bug Fixes
+
+* [#13293](https://github.com/netbox-community/netbox/issues/13293) - Limit interface selector for IP address to current device/VM
+* [#14953](https://github.com/netbox-community/netbox/issues/14953) - Ensure annotated count fields are present in REST API response data when creating new objects
+* [#14982](https://github.com/netbox-community/netbox/issues/14982) - Fix OpenAPI schema definition for SerializedPKRelatedFields
+* [#15082](https://github.com/netbox-community/netbox/issues/15082) - Strip whitespace from choice values & labels when creating a custom field choice set
+* [#16138](https://github.com/netbox-community/netbox/issues/16138) - Fix support for referencing users & groups in object permissions
+* [#16145](https://github.com/netbox-community/netbox/issues/16145) - Restore ability to reference custom scripts via module & name in REST API
+* [#16164](https://github.com/netbox-community/netbox/issues/16164) - Correct display of selected values in UI when filtering object list by a null value
+* [#16173](https://github.com/netbox-community/netbox/issues/16173) - Fix TypeError exception when viewing object list with no pagination preference defined
+* [#16228](https://github.com/netbox-community/netbox/issues/16228) - Fix permissions enforcement for GraphQL queries of users & groups
+* [#16232](https://github.com/netbox-community/netbox/issues/16232) - Preserve bulk action checkboxes on dynamic tables when using pagination
+* [#16240](https://github.com/netbox-community/netbox/issues/16240) - Fixed NoReverseMatch exception when adding circuit terminations to an object counts dashboard widget
+
+---
+
+## v4.0.2 (2024-05-14)
+
+!!! warning "Important"
+ This release includes an important security fix, and is a strongly recommended update for all users. More details will follow.
+
+### Enhancements
+
+* [#15119](https://github.com/netbox-community/netbox/issues/15119) - Add cluster & cluster group UI filter fields for VLAN groups
+* [#16090](https://github.com/netbox-community/netbox/issues/16090) - Include current NetBox version when an unsupported plugin is detected
+* [#16096](https://github.com/netbox-community/netbox/issues/16096) - Introduce the `ENABLE_TRANSLATION` configuration parameter
+* [#16107](https://github.com/netbox-community/netbox/issues/16107) - Change the default value for `LOGIN_REQUIRED` to True
+* [#16127](https://github.com/netbox-community/netbox/issues/16127) - Add integration point for unsupported settings
+
+### Bug Fixes
+
+* [#16077](https://github.com/netbox-community/netbox/issues/16077) - Fix display of parameter values when viewing configuration revisions
+* [#16078](https://github.com/netbox-community/netbox/issues/16078) - Fix integer filters mistakenly marked as required for GraphQL API
+* [#16101](https://github.com/netbox-community/netbox/issues/16101) - Fix initial loading of pagination widget for dynamic object tables
+* [#16123](https://github.com/netbox-community/netbox/issues/16123) - Fix custom script execution via REST API
+* [#16124](https://github.com/netbox-community/netbox/issues/16124) - Fix GraphQL API support for querying virtual machine interfaces
+
+---
+
+## v4.0.1 (2024-05-09)
+
+### Enhancements
+
+* [#15148](https://github.com/netbox-community/netbox/issues/15148) - Add copy-to-clipboard button for config context data
+* [#15328](https://github.com/netbox-community/netbox/issues/15328) - Add a virtual machines UI tab for host devices
+* [#15451](https://github.com/netbox-community/netbox/issues/15451) - Add 2.5 and 5 Gbps backplane Ethernet interface types
+* [#16010](https://github.com/netbox-community/netbox/issues/16010) - Enable Prometheus middleware only if metrics are enabled
+
+### Bug Fixes
+
+* [#15968](https://github.com/netbox-community/netbox/issues/15968) - Avoid resizing quick search field to display clear button
+* [#15973](https://github.com/netbox-community/netbox/issues/15973) - Fix AttributeError exception when modifying cable termination type
+* [#15977](https://github.com/netbox-community/netbox/issues/15977) - Hide all admin menu items for non-authenticated users
+* [#15982](https://github.com/netbox-community/netbox/issues/15982) - Restore the "assign IP" tab for assigning existing IP addresses to interfaces
+* [#15992](https://github.com/netbox-community/netbox/issues/15992) - Fix AttributeError exception when Sentry integration is enabled
+* [#15995](https://github.com/netbox-community/netbox/issues/15995) - Permit nullable fields referenced by unique constraints to be omitted from REST API requests
+* [#15999](https://github.com/netbox-community/netbox/issues/15999) - Fix layout of login form labels for certain languages
+* [#16003](https://github.com/netbox-community/netbox/issues/16003) - Enable cache busting for `setmode.js` asset to avoid breaking dark mode support on upgrade
+* [#16011](https://github.com/netbox-community/netbox/issues/16011) - Fix site tenant assignment by PK via REST API
+* [#16020](https://github.com/netbox-community/netbox/issues/16020) - Include Python version in system UI view
+* [#16022](https://github.com/netbox-community/netbox/issues/16022) - Fix database migration failure when encountering a script module which no longer exists on disk
+* [#16025](https://github.com/netbox-community/netbox/issues/16025) - Fix execution of scripts via the `runscript` management command
+* [#16031](https://github.com/netbox-community/netbox/issues/16031) - Render Markdown content in script log messages
+* [#16051](https://github.com/netbox-community/netbox/issues/16051) - Translate "empty" text for object tables
+* [#16061](https://github.com/netbox-community/netbox/issues/16061) - Omit hidden fields from display within event rule edit form
---
diff --git a/mkdocs.yml b/mkdocs.yml
index 6f7ea7045..cf1e66cea 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -42,7 +42,7 @@ plugins:
show_root_toc_entry: false
show_source: false
extra:
- readthedocs: !ENV READTHEDOCS
+ build_public: !ENV BUILD_PUBLIC
social:
- icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox
diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py
index a1fc8661a..e52673874 100644
--- a/netbox/circuits/filtersets.py
+++ b/netbox/circuits/filtersets.py
@@ -275,6 +275,17 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
queryset=ProviderNetwork.objects.all(),
label=_('ProviderNetwork (ID)'),
)
+ provider_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='circuit__provider_id',
+ queryset=Provider.objects.all(),
+ label=_('Provider (ID)'),
+ )
+ provider = django_filters.ModelMultipleChoiceFilter(
+ field_name='circuit__provider__slug',
+ queryset=Provider.objects.all(),
+ to_field_name='slug',
+ label=_('Provider (slug)'),
+ )
class Meta:
model = CircuitTermination
diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py
index 3ac311c56..ea15c3010 100644
--- a/netbox/circuits/forms/bulk_edit.py
+++ b/netbox/circuits/forms/bulk_edit.py
@@ -3,16 +3,18 @@ from django.utils.translation import gettext_lazy as _
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
from circuits.models import *
+from dcim.models import Site
from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import add_blank_choice
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
-from utilities.forms.rendering import FieldSet
-from utilities.forms.widgets import DatePicker, NumberWithOptions
+from utilities.forms.rendering import FieldSet, TabbedGroups
+from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions
__all__ = (
'CircuitBulkEditForm',
+ 'CircuitTerminationBulkEditForm',
'CircuitTypeBulkEditForm',
'ProviderBulkEditForm',
'ProviderAccountBulkEditForm',
@@ -172,3 +174,48 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = (
'tenant', 'commit_rate', 'description', 'comments',
)
+
+
+class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
+ description = forms.CharField(
+ label=_('Description'),
+ max_length=200,
+ required=False
+ )
+ site = DynamicModelChoiceField(
+ label=_('Site'),
+ queryset=Site.objects.all(),
+ required=False
+ )
+ provider_network = DynamicModelChoiceField(
+ label=_('Provider Network'),
+ queryset=ProviderNetwork.objects.all(),
+ required=False
+ )
+ port_speed = forms.IntegerField(
+ required=False,
+ label=_('Port speed (Kbps)'),
+ )
+ upstream_speed = forms.IntegerField(
+ required=False,
+ label=_('Upstream speed (Kbps)'),
+ )
+ mark_connected = forms.NullBooleanField(
+ label=_('Mark connected'),
+ required=False,
+ widget=BulkEditNullBooleanSelect
+ )
+
+ model = CircuitTermination
+ fieldsets = (
+ FieldSet(
+ 'description',
+ TabbedGroups(
+ FieldSet('site', name=_('Site')),
+ FieldSet('provider_network', name=_('Provider Network')),
+ ),
+ 'mark_connected', name=_('Circuit Termination')
+ ),
+ FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')),
+ )
+ nullable_fields = ('description')
diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py
index 8127d5bcb..1ceb44b60 100644
--- a/netbox/circuits/forms/bulk_import.py
+++ b/netbox/circuits/forms/bulk_import.py
@@ -1,10 +1,10 @@
from django import forms
-
-from circuits.choices import CircuitStatusChoices
-from circuits.models import *
-from dcim.models import Site
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
+
+from circuits.choices import *
+from circuits.models import *
+from dcim.models import Site
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
@@ -12,6 +12,7 @@ from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugFiel
__all__ = (
'CircuitImportForm',
'CircuitTerminationImportForm',
+ 'CircuitTerminationImportRelatedForm',
'CircuitTypeImportForm',
'ProviderImportForm',
'ProviderAccountImportForm',
@@ -111,7 +112,16 @@ class CircuitImportForm(NetBoxModelImportForm):
]
-class CircuitTerminationImportForm(forms.ModelForm):
+class BaseCircuitTerminationImportForm(forms.ModelForm):
+ circuit = CSVModelChoiceField(
+ label=_('Circuit'),
+ queryset=Circuit.objects.all(),
+ to_field_name='cid',
+ )
+ term_side = CSVChoiceField(
+ label=_('Termination'),
+ choices=CircuitTerminationSideChoices,
+ )
site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
@@ -125,9 +135,21 @@ class CircuitTerminationImportForm(forms.ModelForm):
required=False
)
+
+class CircuitTerminationImportRelatedForm(BaseCircuitTerminationImportForm):
class Meta:
model = CircuitTermination
fields = [
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
- 'pp_info', 'description',
+ 'pp_info', 'description'
+ ]
+
+
+class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm):
+
+ class Meta:
+ model = CircuitTermination
+ fields = [
+ 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
+ 'pp_info', 'description', 'tags'
]
diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py
index b2426e928..6f6473c3d 100644
--- a/netbox/circuits/forms/filtersets.py
+++ b/netbox/circuits/forms/filtersets.py
@@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext as _
-from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
+from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices, CircuitTerminationSideChoices
from circuits.models import *
from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN
@@ -13,6 +13,7 @@ from utilities.forms.widgets import DatePicker, NumberWithOptions
__all__ = (
'CircuitFilterForm',
+ 'CircuitTerminationFilterForm',
'CircuitTypeFilterForm',
'ProviderFilterForm',
'ProviderAccountFilterForm',
@@ -186,3 +187,46 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
)
)
tag = TagFilterField(model)
+
+
+class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
+ model = CircuitTermination
+ fieldsets = (
+ FieldSet('q', 'filter_id', 'tag'),
+ FieldSet('circuit_id', 'term_side', name=_('Circuit')),
+ FieldSet('provider_id', 'provider_network_id', name=_('Provider')),
+ FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
+ )
+ site_id = DynamicModelMultipleChoiceField(
+ queryset=Site.objects.all(),
+ required=False,
+ query_params={
+ 'region_id': '$region_id',
+ 'site_group_id': '$site_group_id',
+ },
+ label=_('Site')
+ )
+ circuit_id = DynamicModelMultipleChoiceField(
+ queryset=Circuit.objects.all(),
+ required=False,
+ label=_('Circuit')
+ )
+ term_side = forms.MultipleChoiceField(
+ label=_('Term Side'),
+ choices=CircuitTerminationSideChoices,
+ required=False
+ )
+ provider_network_id = DynamicModelMultipleChoiceField(
+ queryset=ProviderNetwork.objects.all(),
+ required=False,
+ query_params={
+ 'provider_id': '$provider_id'
+ },
+ label=_('Provider network')
+ )
+ provider_id = DynamicModelMultipleChoiceField(
+ queryset=Provider.objects.all(),
+ required=False,
+ label=_('Provider')
+ )
+ tag = TagFilterField(model)
diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py
index 7b65d52ad..fa21d7cd3 100644
--- a/netbox/circuits/models/circuits.py
+++ b/netbox/circuits/models/circuits.py
@@ -227,7 +227,7 @@ class CircuitTermination(
return f'{self.circuit}: Termination {self.term_side}'
def get_absolute_url(self):
- return self.circuit.get_absolute_url()
+ return reverse('circuits:circuittermination', args=[self.pk])
def clean(self):
super().clean()
diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py
index 6ae727eca..5d650df61 100644
--- a/netbox/circuits/tables/circuits.py
+++ b/netbox/circuits/tables/circuits.py
@@ -10,6 +10,7 @@ from .columns import CommitRateColumn
__all__ = (
'CircuitTable',
+ 'CircuitTerminationTable',
'CircuitTypeTable',
)
@@ -88,3 +89,31 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
)
+
+
+class CircuitTerminationTable(NetBoxTable):
+ circuit = tables.Column(
+ verbose_name=_('Circuit'),
+ linkify=True
+ )
+ provider = tables.Column(
+ verbose_name=_('Provider'),
+ linkify=True,
+ accessor='circuit.provider'
+ )
+ site = tables.Column(
+ verbose_name=_('Site'),
+ linkify=True
+ )
+ provider_network = tables.Column(
+ verbose_name=_('Provider Network'),
+ linkify=True
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = CircuitTermination
+ fields = (
+ 'pk', 'id', 'circuit', 'provider', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
+ 'xconnect_id', 'pp_info', 'description', 'created', 'last_updated', 'actions',
+ )
+ default_columns = ('pk', 'id', 'circuit', 'provider', 'term_side', 'description')
diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py
index 0480439eb..df10c3929 100644
--- a/netbox/circuits/tests/test_filtersets.py
+++ b/netbox/circuits/tests/test_filtersets.py
@@ -351,24 +351,26 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
providers = (
Provider(name='Provider 1', slug='provider-1'),
+ Provider(name='Provider 2', slug='provider-2'),
+ Provider(name='Provider 3', slug='provider-3'),
)
Provider.objects.bulk_create(providers)
provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]),
- ProviderNetwork(name='Provider Network 2', provider=providers[0]),
- ProviderNetwork(name='Provider Network 3', provider=providers[0]),
+ ProviderNetwork(name='Provider Network 2', provider=providers[1]),
+ ProviderNetwork(name='Provider Network 3', provider=providers[2]),
)
ProviderNetwork.objects.bulk_create(provider_networks)
circuits = (
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'),
- Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 2'),
- Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'),
+ Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 2'),
+ Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 3'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'),
- Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'),
- Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'),
- Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'),
+ Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 5'),
+ Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 6'),
+ Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 7'),
)
Circuit.objects.bulk_create(circuits)
@@ -413,10 +415,17 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_circuit_id(self):
- circuits = Circuit.objects.all()[:2]
+ circuits = Circuit.objects.filter(cid__in=['Circuit 1', 'Circuit 2'])
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ def test_provider(self):
+ providers = Provider.objects.all()[:2]
+ params = {'provider_id': [providers[0].pk, providers[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+ params = {'provider': [providers[0].slug, providers[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py
index 85e2304cf..577548703 100644
--- a/netbox/circuits/tests/test_views.py
+++ b/netbox/circuits/tests/test_views.py
@@ -5,8 +5,11 @@ from django.urls import reverse
from circuits.choices import *
from circuits.models import *
+from core.models import ObjectType
from dcim.models import Cable, Interface, Site
from ipam.models import ASN, RIR
+from netbox.choices import ImportFormatChoices
+from users.models import ObjectPermission
from utilities.testing import ViewTestCases, create_tags, create_test_device
@@ -115,6 +118,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod
def setUpTestData(cls):
+ Site.objects.create(name='Site 1', slug='site-1')
providers = (
Provider(name='Provider 1', slug='provider-1'),
@@ -184,6 +188,51 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'comments': 'New comments',
}
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+ def test_bulk_import_objects_with_terminations(self):
+ json_data = """
+ [
+ {
+ "cid": "Circuit 7",
+ "provider": "Provider 1",
+ "type": "Circuit Type 1",
+ "status": "active",
+ "description": "Testing Import",
+ "terminations": [
+ {
+ "term_side": "A",
+ "site": "Site 1"
+ },
+ {
+ "term_side": "Z",
+ "site": "Site 1"
+ }
+ ]
+ }
+ ]
+ """
+ initial_count = self._get_queryset().count()
+ data = {
+ 'data': json_data,
+ 'format': ImportFormatChoices.JSON,
+ }
+
+ # Assign model-level permission
+ obj_perm = ObjectPermission(
+ name='Test permission',
+ actions=['add']
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+
+ # Try GET with model-level permission
+ self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
+
+ # Test POST with permission
+ self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
+ self.assertEqual(self._get_queryset().count(), initial_count + 1)
+
class ProviderAccountTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ProviderAccount
@@ -287,10 +336,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
-class CircuitTerminationTestCase(
- ViewTestCases.EditObjectViewTestCase,
- ViewTestCases.DeleteObjectViewTestCase,
-):
+class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CircuitTermination
@classmethod
@@ -327,6 +373,24 @@ class CircuitTerminationTestCase(
'description': 'New description',
}
+ cls.csv_data = (
+ "circuit,term_side,site,description",
+ "Circuit 3,A,Site 1,Foo",
+ "Circuit 3,Z,Site 1,Bar",
+ )
+
+ cls.csv_update_data = (
+ "id,port_speed,description",
+ f"{circuit_terminations[0].pk},100,New description7",
+ f"{circuit_terminations[1].pk},200,New description8",
+ f"{circuit_terminations[2].pk},300,New description9",
+ )
+
+ cls.bulk_edit_data = {
+ 'port_speed': 400,
+ 'description': 'New description',
+ }
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
device = create_test_device('Device 1')
diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py
index 55a192c64..5c0ab99ee 100644
--- a/netbox/circuits/urls.py
+++ b/netbox/circuits/urls.py
@@ -48,7 +48,11 @@ urlpatterns = [
path('circuits//', include(get_model_urls('circuits', 'circuit'))),
# Circuit terminations
+ path('circuit-terminations/', views.CircuitTerminationListView.as_view(), name='circuittermination_list'),
path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
+ path('circuit-terminations/import/', views.CircuitTerminationBulkImportView.as_view(), name='circuittermination_import'),
+ path('circuit-terminations/edit/', views.CircuitTerminationBulkEditView.as_view(), name='circuittermination_bulk_edit'),
+ path('circuit-terminations/delete/', views.CircuitTerminationBulkDeleteView.as_view(), name='circuittermination_bulk_delete'),
path('circuit-terminations//', include(get_model_urls('circuits', 'circuittermination'))),
]
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index 54f875975..def9a3640 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -298,7 +298,7 @@ class CircuitBulkImportView(generic.BulkImportView):
'circuits.add_circuittermination',
]
related_object_forms = {
- 'terminations': forms.CircuitTerminationImportForm,
+ 'terminations': forms.CircuitTerminationImportRelatedForm,
}
def prep_related_object_data(self, parent, data):
@@ -408,6 +408,18 @@ class CircuitContactsView(ObjectContactsView):
# Circuit terminations
#
+class CircuitTerminationListView(generic.ObjectListView):
+ queryset = CircuitTermination.objects.all()
+ filterset = filtersets.CircuitTerminationFilterSet
+ filterset_form = forms.CircuitTerminationFilterForm
+ table = tables.CircuitTerminationTable
+
+
+@register_model_view(CircuitTermination)
+class CircuitTerminationView(generic.ObjectView):
+ queryset = CircuitTermination.objects.all()
+
+
@register_model_view(CircuitTermination, 'edit')
class CircuitTerminationEditView(generic.ObjectEditView):
queryset = CircuitTermination.objects.all()
@@ -419,5 +431,23 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all()
+class CircuitTerminationBulkImportView(generic.BulkImportView):
+ queryset = CircuitTermination.objects.all()
+ model_form = forms.CircuitTerminationImportForm
+
+
+class CircuitTerminationBulkEditView(generic.BulkEditView):
+ queryset = CircuitTermination.objects.all()
+ filterset = filtersets.CircuitTerminationFilterSet
+ table = tables.CircuitTerminationTable
+ form = forms.CircuitTerminationBulkEditForm
+
+
+class CircuitTerminationBulkDeleteView(generic.BulkDeleteView):
+ queryset = CircuitTermination.objects.all()
+ filterset = filtersets.CircuitTerminationFilterSet
+ table = tables.CircuitTerminationTable
+
+
# Trace view
register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView)
diff --git a/netbox/core/api/schema.py b/netbox/core/api/schema.py
index 5f64dcc53..bcc49d3fc 100644
--- a/netbox/core/api/schema.py
+++ b/netbox/core/api/schema.py
@@ -255,3 +255,14 @@ class NetBoxAutoSchema(AutoSchema):
if '{id}' in self.path:
return f"{self.method.capitalize()} a {model_name} object."
return f"{self.method.capitalize()} a list of {model_name} objects."
+
+
+class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
+ target_class = 'netbox.api.fields.SerializedPKRelatedField'
+
+ def map_serializer_field(self, auto_schema, direction):
+ if direction == "response":
+ component = auto_schema.resolve_serializer(self.target.serializer, direction)
+ return component.ref if component else None
+ else:
+ return build_basic_type(OpenApiTypes.INT)
diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py
index 1e5e41069..60e1477e5 100644
--- a/netbox/dcim/api/serializers_/sites.py
+++ b/netbox/dcim/api/serializers_/sites.py
@@ -21,7 +21,7 @@ __all__ = (
class RegionSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
- site_count = serializers.IntegerField(read_only=True)
+ site_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = Region
@@ -35,7 +35,7 @@ class RegionSerializer(NestedGroupModelSerializer):
class SiteGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
- site_count = serializers.IntegerField(read_only=True)
+ site_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = SiteGroup
@@ -86,8 +86,8 @@ class LocationSerializer(NestedGroupModelSerializer):
parent = NestedLocationSerializer(required=False, allow_null=True, default=None)
status = ChoiceField(choices=LocationStatusChoices, required=False)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
- rack_count = serializers.IntegerField(read_only=True)
- device_count = serializers.IntegerField(read_only=True)
+ rack_count = serializers.IntegerField(read_only=True, default=0)
+ device_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = Location
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index b00784265..e8a7194fc 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -399,6 +399,10 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_USB_MICRO_AB = 'usb-micro-ab'
TYPE_USB_3_B = 'usb-3-b'
TYPE_USB_3_MICROB = 'usb-3-micro-b'
+ # Molex
+ TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2'
+ TYPE_MOLEX_MICRO_FIT_2X2 = 'molex-micro-fit-2x2'
+ TYPE_MOLEX_MICRO_FIT_2X4 = 'molex-micro-fit-2x4'
# Direct current (DC)
TYPE_DC = 'dc-terminal'
# Proprietary
@@ -520,6 +524,11 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_USB_3_B, 'USB 3.0 Type B'),
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
)),
+ ('Molex', (
+ (TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'),
+ (TYPE_MOLEX_MICRO_FIT_2X2, 'Molex Micro-Fit 2x2'),
+ (TYPE_MOLEX_MICRO_FIT_2X4, 'Molex Micro-Fit 2x4'),
+ )),
('DC', (
(TYPE_DC, 'DC Terminal'),
)),
@@ -635,6 +644,10 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_USB_A = 'usb-a'
TYPE_USB_MICROB = 'usb-micro-b'
TYPE_USB_C = 'usb-c'
+ # Molex
+ TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2'
+ TYPE_MOLEX_MICRO_FIT_2X2 = 'molex-micro-fit-2x2'
+ TYPE_MOLEX_MICRO_FIT_2X4 = 'molex-micro-fit-2x4'
# Direct current (DC)
TYPE_DC = 'dc-terminal'
# Proprietary
@@ -749,6 +762,11 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_USB_MICROB, 'USB Micro B'),
(TYPE_USB_C, 'USB Type C'),
)),
+ ('Molex', (
+ (TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'),
+ (TYPE_MOLEX_MICRO_FIT_2X2, 'Molex Micro-Fit 2x2'),
+ (TYPE_MOLEX_MICRO_FIT_2X4, 'Molex Micro-Fit 2x4'),
+ )),
('DC', (
(TYPE_DC, 'DC Terminal'),
)),
@@ -848,6 +866,8 @@ class InterfaceTypeChoices(ChoiceSet):
# Ethernet Backplane
TYPE_1GE_KX = '1000base-kx'
+ TYPE_2GE_KX = '2.5gbase-kx'
+ TYPE_5GE_KR = '5gbase-kr'
TYPE_10GE_KR = '10gbase-kr'
TYPE_10GE_KX4 = '10gbase-kx4'
TYPE_25GE_KR = '25gbase-kr'
@@ -872,6 +892,8 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_GSM = 'gsm'
TYPE_CDMA = 'cdma'
TYPE_LTE = 'lte'
+ TYPE_4G = '4g'
+ TYPE_5G = '5g'
# SONET
TYPE_SONET_OC3 = 'sonet-oc3'
@@ -919,12 +941,15 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_DOCSIS = 'docsis'
# PON
+ TYPE_BPON = 'bpon'
+ TYPE_EPON = 'epon'
+ TYPE_10G_EPON = '10g-epon'
TYPE_GPON = 'gpon'
TYPE_XG_PON = 'xg-pon'
TYPE_XGS_PON = 'xgs-pon'
TYPE_NG_PON2 = 'ng-pon2'
- TYPE_EPON = 'epon'
- TYPE_10G_EPON = '10g-epon'
+ TYPE_25G_PON = '25g-pon'
+ TYPE_50G_PON = '50g-pon'
# Stacking
TYPE_STACKWISE = 'cisco-stackwise'
@@ -1008,6 +1033,8 @@ class InterfaceTypeChoices(ChoiceSet):
_('Ethernet (backplane)'),
(
(TYPE_1GE_KX, '1000BASE-KX (1GE)'),
+ (TYPE_2GE_KX, '2.5GBASE-KX (2.5GE)'),
+ (TYPE_5GE_KR, '5GBASE-KR (5GE)'),
(TYPE_10GE_KR, '10GBASE-KR (10GE)'),
(TYPE_10GE_KX4, '10GBASE-KX4 (10GE)'),
(TYPE_25GE_KR, '25GBASE-KR (25GE)'),
@@ -1038,6 +1065,8 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_GSM, 'GSM'),
(TYPE_CDMA, 'CDMA'),
(TYPE_LTE, 'LTE'),
+ (TYPE_4G, '4G'),
+ (TYPE_5G, '5G'),
)
),
(
@@ -1106,12 +1135,15 @@ class InterfaceTypeChoices(ChoiceSet):
(
'PON',
(
- (TYPE_GPON, 'GPON (2.5 Gbps / 1.25 Gps)'),
+ (TYPE_BPON, 'BPON (622 Mbps / 155 Mbps)'),
+ (TYPE_EPON, 'EPON (1 Gbps)'),
+ (TYPE_10G_EPON, '10G-EPON (10 Gbps)'),
+ (TYPE_GPON, 'GPON (2.5 Gbps / 1.25 Gbps)'),
(TYPE_XG_PON, 'XG-PON (10 Gbps / 2.5 Gbps)'),
(TYPE_XGS_PON, 'XGS-PON (10 Gbps)'),
(TYPE_NG_PON2, 'NG-PON2 (TWDM-PON) (4x10 Gbps)'),
- (TYPE_EPON, 'EPON (1 Gbps)'),
- (TYPE_10G_EPON, '10G-EPON (10 Gbps)'),
+ (TYPE_25G_PON, '25G-PON (25 Gbps)'),
+ (TYPE_50G_PON, '50G-PON (50 Gbps)'),
)
),
(
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index ad1e29f26..2fb1e9949 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -1100,6 +1100,10 @@ class DeviceFilterSet(
queryset=IPAddress.objects.all(),
label=_('OOB IP (ID)'),
)
+ has_virtual_device_context = django_filters.BooleanFilter(
+ method='_has_virtual_device_context',
+ label=_('Has virtual device context'),
+ )
class Meta:
model = Device
@@ -1176,6 +1180,12 @@ class DeviceFilterSet(
def _device_bays(self, queryset, name, value):
return queryset.exclude(devicebays__isnull=value)
+ def _has_virtual_device_context(self, queryset, name, value):
+ params = Q(vdcs__isnull=False)
+ if value:
+ return queryset.filter(params).distinct()
+ return queryset.exclude(params)
+
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py
index b57f4ad2c..44bea047a 100644
--- a/netbox/dcim/forms/connections.py
+++ b/netbox/dcim/forms/connections.py
@@ -90,14 +90,14 @@ def get_cable_form(a_type, b_type):
class _CableForm(CableForm, metaclass=FormMetaclass):
def __init__(self, *args, initial=None, **kwargs):
-
initial = initial or {}
+
if a_type:
- ct = ContentType.objects.get_for_model(a_type)
- initial['a_terminations_type'] = f'{ct.app_label}.{ct.model}'
+ a_ct = ContentType.objects.get_for_model(a_type)
+ initial['a_terminations_type'] = f'{a_ct.app_label}.{a_ct.model}'
if b_type:
- ct = ContentType.objects.get_for_model(b_type)
- initial['b_terminations_type'] = f'{ct.app_label}.{ct.model}'
+ b_ct = ContentType.objects.get_for_model(b_type)
+ initial['b_terminations_type'] = f'{b_ct.app_label}.{b_ct.model}'
# TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict()
for field_name in ('a_terminations', 'b_terminations'):
@@ -108,8 +108,17 @@ def get_cable_form(a_type, b_type):
if self.instance and self.instance.pk:
# Initialize A/B terminations when modifying an existing Cable instance
- self.initial['a_terminations'] = self.instance.a_terminations
- self.initial['b_terminations'] = self.instance.b_terminations
+ if a_type and self.instance.a_terminations and a_ct == ContentType.objects.get_for_model(self.instance.a_terminations[0]):
+ self.initial['a_terminations'] = self.instance.a_terminations
+ if b_type and self.instance.b_terminations and b_ct == ContentType.objects.get_for_model(self.instance.b_terminations[0]):
+ self.initial['b_terminations'] = self.instance.b_terminations
+ else:
+ # Need to clear terminations if swapped type - but need to do it only
+ # if not from instance
+ if a_type:
+ initial.pop('a_terminations', None)
+ if b_type:
+ initial.pop('b_terminations', None)
def clean(self):
super().clean()
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 21854b53f..0a28a4ec4 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -657,6 +657,7 @@ class DeviceFilterForm(
),
FieldSet(
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
+ 'has_virtual_device_context',
name=_('Miscellaneous')
)
)
@@ -813,6 +814,13 @@ class DeviceFilterForm(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
+ has_virtual_device_context = forms.NullBooleanField(
+ required=False,
+ label=_('Has virtual device contexts'),
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
tag = TagFilterField(model)
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 169631506..4925fb517 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -313,6 +313,10 @@ class ModularDeviceComponentTable(DeviceComponentTable):
verbose_name=_('Module'),
linkify=True
)
+ inventory_items = columns.ManyToManyColumn(
+ linkify_item=True,
+ verbose_name=_('Inventory Items'),
+ )
class CableTerminationTable(NetBoxTable):
@@ -366,7 +370,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.ConsolePort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description',
- 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
+ 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -410,7 +414,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.ConsoleServerPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description',
- 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
+ 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -461,8 +465,8 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.PowerPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected',
- 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created',
- 'last_updated',
+ 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items',
+ 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@@ -513,8 +517,8 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.PowerOutlet
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port',
- 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created',
- 'last_updated',
+ 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items',
+ 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
@@ -618,10 +622,6 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
verbose_name=_('VRF'),
linkify=True
)
- inventory_items = columns.ManyToManyColumn(
- linkify_item=True,
- verbose_name=_('Inventory Items'),
- )
tags = columns.TagColumn(
url_name='dcim:interface_list'
)
@@ -713,8 +713,8 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
model = models.FrontPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port',
- 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
- 'created', 'last_updated',
+ 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer',
+ 'inventory_items', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
@@ -766,7 +766,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
model = models.RearPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description',
- 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated',
+ 'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index 96ea020b3..0a22f5a82 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -2103,6 +2103,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
+ # VirtualDeviceContext assignment for filtering
+ VirtualDeviceContext.objects.create(device=devices[0], name="VDC 1", identifier=1, status='active')
+
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -2336,6 +2339,12 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_has_virtual_device_context(self):
+ params = {'has_virtual_device_context': 'true'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ params = {'has_virtual_device_context': 'false'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Module.objects.all()
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index ca37084f2..670995231 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -28,7 +28,9 @@ from utilities.permissions import get_permission_for_model
from utilities.query import count_related
from utilities.query_functions import CollateAsChar
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
+from virtualization.filtersets import VirtualMachineFilterSet
from virtualization.models import VirtualMachine
+from virtualization.tables import VirtualMachineTable
from . import filtersets, forms, tables
from .choices import DeviceFaceChoices
from .models import *
@@ -2085,6 +2087,24 @@ class DeviceRenderConfigView(generic.ObjectView):
}
+@register_model_view(Device, 'virtual-machines')
+class DeviceVirtualMachinesView(generic.ObjectChildrenView):
+ queryset = Device.objects.all()
+ child_model = VirtualMachine
+ table = VirtualMachineTable
+ filterset = VirtualMachineFilterSet
+ tab = ViewTab(
+ label=_('Virtual Machines'),
+ badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
+ weight=2200,
+ hide_if_empty=True,
+ permission='virtualization.view_virtualmachine'
+ )
+
+ def get_children(self, request, parent):
+ return self.child_model.objects.restrict(request.user, 'view').filter(cluster=parent.cluster, device=parent)
+
+
class DeviceBulkImportView(generic.BulkImportView):
queryset = Device.objects.all()
model_form = forms.DeviceImportForm
@@ -2965,7 +2985,6 @@ class InventoryItemChildrenView(generic.ObjectChildrenView):
child_model = InventoryItem
table = tables.InventoryItemTable
filterset = filtersets.InventoryItemFilterSet
- template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Children'),
badge=lambda obj: obj.child_items.count(),
diff --git a/netbox/extras/api/serializers_/journaling.py b/netbox/extras/api/serializers_/journaling.py
index 46ab0477b..1a44e7e2e 100644
--- a/netbox/extras/api/serializers_/journaling.py
+++ b/netbox/extras/api/serializers_/journaling.py
@@ -43,7 +43,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
def validate(self, data):
# Validate that the parent object exists
- if 'assigned_object_type' in data and 'assigned_object_id' in data:
+ if not self.nested and 'assigned_object_type' in data and 'assigned_object_id' in data:
try:
data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id'])
except ObjectDoesNotExist:
@@ -51,10 +51,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}"
)
- # Enforce model validation
- super().validate(data)
-
- return data
+ return super().validate(data)
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_assigned_object(self, instance):
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
index 0a5303741..05087b2d5 100644
--- a/netbox/extras/api/views.py
+++ b/netbox/extras/api/views.py
@@ -1,3 +1,4 @@
+from django.http import Http404
from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection
from rest_framework import status
@@ -215,21 +216,32 @@ class ScriptViewSet(ModelViewSet):
_ignore_model_permissions = True
lookup_value_regex = '[^/]+' # Allow dots
+ def _get_script(self, pk):
+ # If pk is numeric, retrieve script by ID
+ if pk.isnumeric():
+ return get_object_or_404(self.queryset, pk=pk)
+
+ # Default to retrieval by module & name
+ try:
+ module_name, script_name = pk.split('.', maxsplit=1)
+ except ValueError:
+ raise Http404
+ return get_object_or_404(self.queryset, module__file_path=f'{module_name}.py', name=script_name)
+
def retrieve(self, request, pk):
- script = get_object_or_404(self.queryset, pk=pk)
+ script = self._get_script(pk)
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
return Response(serializer.data)
def post(self, request, pk):
"""
- Run a Script identified by the id and return the pending Job as the result
+ Run a Script identified by its numeric PK or module & name and return the pending Job as the result
"""
-
if not request.user.has_perm('extras.run_script'):
raise PermissionDenied("This user does not have permission to run scripts.")
- script = get_object_or_404(self.queryset, pk=pk)
+ script = self._get_script(pk)
input_serializer = serializers.ScriptInputSerializer(
data=request.data,
context={'script': script}
@@ -240,9 +252,9 @@ class ScriptViewSet(ModelViewSet):
raise RQWorkerNotRunningException()
if input_serializer.is_valid():
- script.result = Job.enqueue(
+ Job.enqueue(
run_script,
- instance=script.module,
+ instance=script,
name=script.python_class.class_name,
user=request.user,
data=input_serializer.data['data'],
diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py
index 229d94232..ebd6e6c08 100644
--- a/netbox/extras/forms/model_forms.py
+++ b/netbox/extras/forms/model_forms.py
@@ -122,7 +122,7 @@ class CustomFieldChoiceSetForm(forms.ModelForm):
label = label.replace('\\:', ':')
except ValueError:
value, label = line, line
- data.append((value, label))
+ data.append((value.strip(), label.strip()))
return data
@@ -279,10 +279,7 @@ class EventRuleForm(NetBoxModelForm):
FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')),
FieldSet('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', name=_('Events')),
FieldSet('conditions', name=_('Conditions')),
- FieldSet(
- 'action_type', 'action_choice', 'action_object_type', 'action_object_id', 'action_data',
- name=_('Action')
- ),
+ FieldSet('action_type', 'action_choice', 'action_data', name=_('Action')),
)
class Meta:
diff --git a/netbox/extras/models/scripts.py b/netbox/extras/models/scripts.py
index 9118ab2ca..98d79c53c 100644
--- a/netbox/extras/models/scripts.py
+++ b/netbox/extras/models/scripts.py
@@ -96,6 +96,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
Proxy model for script module files.
"""
objects = ScriptModuleManager()
+ error = None
event_rules = GenericRelation(
to='extras.EventRule',
@@ -126,6 +127,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
try:
module = self.get_module()
except Exception as e:
+ self.error = e
logger.debug(f"Failed to load script: {self.python_name} error: {e}")
module = None
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index 3738f3102..8c78ad0de 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -1,10 +1,10 @@
import json
import django_tables2 as tables
-from django.conf import settings
from django.utils.translation import gettext_lazy as _
from extras.models import *
+from netbox.constants import EMPTY_TABLE_TEXT
from netbox.tables import BaseTable, NetBoxTable, columns
from .template_code import *
@@ -550,7 +550,7 @@ class ScriptResultsTable(BaseTable):
)
class Meta(BaseTable.Meta):
- empty_text = _('No results found')
+ empty_text = _(EMPTY_TABLE_TEXT)
fields = (
'index', 'time', 'status', 'message',
)
@@ -581,7 +581,7 @@ class ReportResultsTable(BaseTable):
)
class Meta(BaseTable.Meta):
- empty_text = _('No results found')
+ empty_text = _(EMPTY_TABLE_TEXT)
fields = (
'index', 'method', 'time', 'status', 'object', 'url', 'message',
)
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index ff147cc31..3a82539fb 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -1052,12 +1052,27 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
})
-class ScriptView(generic.ObjectView):
+class BaseScriptView(generic.ObjectView):
queryset = Script.objects.all()
+ def _get_script_class(self, script):
+ """
+ Return an instance of the Script's Python class
+ """
+ if script_class := script.python_class:
+ return script_class()
+
+
+class ScriptView(BaseScriptView):
+
def get(self, request, **kwargs):
script = self.get_object(**kwargs)
- script_class = script.python_class()
+ script_class = self._get_script_class(script)
+ if not script_class:
+ return render(request, 'extras/script.html', {
+ 'script': script,
+ })
+
form = script_class.as_form(initial=normalize_querydict(request.GET))
return render(request, 'extras/script.html', {
@@ -1069,11 +1084,16 @@ class ScriptView(generic.ObjectView):
def post(self, request, **kwargs):
script = self.get_object(**kwargs)
- script_class = script.python_class()
if not request.user.has_perm('extras.run_script', obj=script):
return HttpResponseForbidden()
+ script_class = self._get_script_class(script)
+ if not script_class:
+ return render(request, 'extras/script.html', {
+ 'script': script,
+ })
+
form = script_class.as_form(request.POST, request.FILES)
# Allow execution only if RQ worker process is running
@@ -1103,21 +1123,22 @@ class ScriptView(generic.ObjectView):
})
-class ScriptSourceView(generic.ObjectView):
+class ScriptSourceView(BaseScriptView):
queryset = Script.objects.all()
def get(self, request, **kwargs):
script = self.get_object(**kwargs)
+ script_class = self._get_script_class(script)
return render(request, 'extras/script/source.html', {
'script': script,
- 'script_class': script.python_class(),
+ 'script_class': script_class,
'job_count': script.jobs.count(),
'tab': 'source',
})
-class ScriptJobsView(generic.ObjectView):
+class ScriptJobsView(BaseScriptView):
queryset = Script.objects.all()
def get(self, request, **kwargs):
diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py
index 6610bcaf3..80fb04226 100644
--- a/netbox/ipam/forms/filtersets.py
+++ b/netbox/ipam/forms/filtersets.py
@@ -10,7 +10,7 @@ from tenancy.forms import TenancyFilterForm
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet
-from virtualization.models import VirtualMachine
+from virtualization.models import VirtualMachine, ClusterGroup, Cluster
from vpn.models import L2VPN
__all__ = (
@@ -168,6 +168,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
name=_('Addressing')
),
+ FieldSet('vlan_id', name=_('VLAN Assignment')),
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
@@ -249,6 +250,12 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
+ vlan_id = DynamicModelMultipleChoiceField(
+ queryset=VLAN.objects.all(),
+ required=False,
+ label=_('VLAN'),
+ )
+
tag = TagFilterField(model)
@@ -405,6 +412,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
+ FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('min_vid', 'max_vid', name=_('VLAN ID')),
)
model = VLANGroup
@@ -445,6 +453,17 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
max_value=VLAN_VID_MAX,
label=_('Maximum VID')
)
+ cluster = DynamicModelMultipleChoiceField(
+ queryset=Cluster.objects.all(),
+ required=False,
+ label=_('Cluster')
+ )
+ cluster_group = DynamicModelMultipleChoiceField(
+ queryset=ClusterGroup.objects.all(),
+ required=False,
+ label=_('Cluster group')
+ )
+
tag = TagFilterField(model)
diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py
index 1282cec25..4e405a035 100644
--- a/netbox/ipam/forms/model_forms.py
+++ b/netbox/ipam/forms/model_forms.py
@@ -355,6 +355,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
):
self.initial['primary_for_parent'] = True
+ if type(instance.assigned_object) is Interface:
+ self.fields['interface'].widget.add_query_params({
+ 'device_id': instance.assigned_object.device.pk,
+ })
+ elif type(instance.assigned_object) is VMInterface:
+ self.fields['vminterface'].widget.add_query_params({
+ 'virtual_machine_id': instance.assigned_object.virtual_machine.pk,
+ })
+
# Disable object assignment fields if the IP address is designated as primary
if self.initial.get('primary_for_parent'):
self.fields['interface'].disabled = True
diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py
index 2ae380d63..0b8e3a8df 100644
--- a/netbox/ipam/models/ip.py
+++ b/netbox/ipam/models/ip.py
@@ -18,6 +18,7 @@ from ipam.querysets import PrefixQuerySet
from ipam.validators import DNSValidator
from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel
+from netbox.models.features import ContactsMixin
__all__ = (
'Aggregate',
@@ -74,7 +75,7 @@ class RIR(OrganizationalModel):
return reverse('ipam:rir', args=[self.pk])
-class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
+class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
"""
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
@@ -206,7 +207,7 @@ class Role(OrganizationalModel):
return reverse('ipam:role', args=[self.pk])
-class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
+class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
"""
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
@@ -486,7 +487,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
return min(utilization, 100)
-class IPRange(PrimaryModel):
+class IPRange(ContactsMixin, PrimaryModel):
"""
A range of IP addresses, defined by start and end addresses.
"""
@@ -695,7 +696,7 @@ class IPRange(PrimaryModel):
return min(float(child_count) / self.size * 100, 100)
-class IPAddress(PrimaryModel):
+class IPAddress(ContactsMixin, PrimaryModel):
"""
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like
diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py
index 16ee7bbf0..20ba35c6b 100644
--- a/netbox/ipam/tests/test_api.py
+++ b/netbox/ipam/tests/test_api.py
@@ -648,6 +648,9 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
bulk_update_data = {
'description': 'New description',
}
+ graphql_filter = {
+ 'address': '192.168.0.1/24',
+ }
@classmethod
def setUpTestData(cls):
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 044474ec4..cab9058d8 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -9,6 +9,7 @@ from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet
from dcim.models import Interface, Site
from netbox.views import generic
+from tenancy.views import ObjectContactsView
from utilities.query import count_related
from utilities.tables import get_table_ordering
from utilities.views import ViewTab, register_model_view
@@ -214,7 +215,6 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
child_model = ASN
table = tables.ASNTable
filterset = filtersets.ASNFilterSet
- template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('ASNs'),
badge=lambda x: x.get_child_asns().count(),
@@ -406,6 +406,11 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
table = tables.AggregateTable
+@register_model_view(Aggregate, 'contacts')
+class AggregateContactsView(ObjectContactsView):
+ queryset = Aggregate.objects.all()
+
+
#
# Prefix/VLAN roles
#
@@ -644,6 +649,11 @@ class PrefixBulkDeleteView(generic.BulkDeleteView):
table = tables.PrefixTable
+@register_model_view(Prefix, 'contacts')
+class PrefixContactsView(ObjectContactsView):
+ queryset = Prefix.objects.all()
+
+
#
# IP Ranges
#
@@ -727,6 +737,11 @@ class IPRangeBulkDeleteView(generic.BulkDeleteView):
table = tables.IPRangeTable
+@register_model_view(IPRange, 'contacts')
+class IPRangeContactsView(ObjectContactsView):
+ queryset = IPRange.objects.all()
+
+
#
# IP addresses
#
@@ -883,7 +898,6 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
- template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(),
@@ -895,6 +909,11 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
return parent.get_related_ips().restrict(request.user, 'view')
+@register_model_view(IPAddress, 'contacts')
+class IPAddressContactsView(ObjectContactsView):
+ queryset = IPAddress.objects.all()
+
+
#
# VLAN groups
#
@@ -955,7 +974,6 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
child_model = VLAN
table = tables.VLANTable
filterset = filtersets.VLANFilterSet
- template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('VLANs'),
badge=lambda x: x.get_child_vlans().count(),
@@ -1111,7 +1129,6 @@ class VLANInterfacesView(generic.ObjectChildrenView):
child_model = Interface
table = tables.VLANDevicesTable
filterset = InterfaceFilterSet
- template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Device Interfaces'),
badge=lambda x: x.get_interfaces().count(),
@@ -1129,7 +1146,6 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface
table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet
- template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('VM Interfaces'),
badge=lambda x: x.get_vminterfaces().count(),
diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py
index b22fd7b2f..84ead5339 100644
--- a/netbox/netbox/configuration_example.py
+++ b/netbox/netbox/configuration_example.py
@@ -157,9 +157,8 @@ LOGGING = {}
# authenticated to NetBox indefinitely.
LOGIN_PERSISTENCE = False
-# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
-# are permitted to access most data in NetBox but not make any changes.
-LOGIN_REQUIRED = False
+# Setting this to False will permit unauthenticated users to access most areas of NetBox (but not make any changes).
+LOGIN_REQUIRED = True
# The length of time (in seconds) for which a user will remain logged into the web UI before being prompted to
# re-authenticate. (Default: 1209600 [14 days])
diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py
index 6a6928021..e797f4f29 100644
--- a/netbox/netbox/constants.py
+++ b/netbox/netbox/constants.py
@@ -41,3 +41,6 @@ DEFAULT_ACTION_PERMISSIONS = {
# General-purpose tokens
CENSOR_TOKEN = '********'
CENSOR_TOKEN_CHANGED = '***CHANGED***'
+
+# Placeholder text for empty tables
+EMPTY_TABLE_TEXT = 'No results found'
diff --git a/netbox/netbox/graphql/filter_mixins.py b/netbox/netbox/graphql/filter_mixins.py
index bfb958563..322435c72 100644
--- a/netbox/netbox/graphql/filter_mixins.py
+++ b/netbox/netbox/graphql/filter_mixins.py
@@ -87,7 +87,7 @@ def map_strawberry_type(field):
pass
elif issubclass(type(field), django_filters.NumberFilter):
should_create_function = True
- attr_type = int
+ attr_type = int | None
elif issubclass(type(field), django_filters.ModelMultipleChoiceFilter):
should_create_function = True
attr_type = List[str] | None
diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py
index 2a58b277e..002dfd98a 100644
--- a/netbox/netbox/navigation/menu.py
+++ b/netbox/netbox/navigation/menu.py
@@ -258,6 +258,7 @@ CIRCUITS_MENU = Menu(
items=(
get_model_item('circuits', 'circuit', _('Circuits')),
get_model_item('circuits', 'circuittype', _('Circuit Types')),
+ get_model_item('circuits', 'circuittermination', _('Circuit Terminations')),
),
),
MenuGroup(
@@ -372,19 +373,19 @@ ADMIN_MENU = Menu(
link=f'users:user_list',
link_text=_('Users'),
auth_required=True,
- permissions=[f'auth.view_user'],
+ permissions=[f'users.view_user'],
buttons=(
MenuItemButton(
link=f'users:user_add',
title='Add',
icon_class='mdi mdi-plus-thick',
- permissions=[f'auth.add_user']
+ permissions=[f'users.add_user']
),
MenuItemButton(
link=f'users:user_import',
title='Import',
icon_class='mdi mdi-upload',
- permissions=[f'auth.add_user']
+ permissions=[f'users.add_user']
)
)
),
@@ -392,19 +393,19 @@ ADMIN_MENU = Menu(
link=f'users:group_list',
link_text=_('Groups'),
auth_required=True,
- permissions=[f'auth.view_group'],
+ permissions=[f'users.view_group'],
buttons=(
MenuItemButton(
link=f'users:group_add',
title='Add',
icon_class='mdi mdi-plus-thick',
- permissions=[f'auth.add_group']
+ permissions=[f'users.add_group']
),
MenuItemButton(
link=f'users:group_import',
title='Import',
icon_class='mdi mdi-upload',
- permissions=[f'auth.add_group']
+ permissions=[f'users.add_group']
)
)
),
diff --git a/netbox/netbox/plugins/__init__.py b/netbox/netbox/plugins/__init__.py
index 8db945e74..e2f0f22fc 100644
--- a/netbox/netbox/plugins/__init__.py
+++ b/netbox/netbox/plugins/__init__.py
@@ -138,13 +138,15 @@ class PluginConfig(AppConfig):
min_version = version.parse(cls.min_version)
if current_version < min_version:
raise ImproperlyConfigured(
- f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version}."
+ f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version} (current: "
+ f"{netbox_version})."
)
if cls.max_version is not None:
max_version = version.parse(cls.max_version)
if current_version > max_version:
raise ImproperlyConfigured(
- f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version}."
+ f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version} (current: "
+ f"{netbox_version})."
)
# Verify required configuration settings
diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py
index d560ef1dd..d911aabb0 100644
--- a/netbox/netbox/preferences.py
+++ b/netbox/netbox/preferences.py
@@ -23,7 +23,7 @@ PREFERENCES = {
),
description=_('Enable dynamic UI navigation'),
default=False,
- experimental=True
+ warning=_('Experimental feature')
),
'locale.language': UserPreference(
label=_('Language'),
@@ -31,7 +31,12 @@ PREFERENCES = {
('', _('Auto')),
*settings.LANGUAGES,
),
- description=_('Forces UI translation to the specified language.')
+ description=_('Forces UI translation to the specified language'),
+ warning=(
+ _("Support for translation has been disabled locally")
+ if not settings.TRANSLATION_ENABLED
+ else ''
+ )
),
'pagination.per_page': UserPreference(
label=_('Page length'),
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 38df16551..b764fd930 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -25,7 +25,7 @@ from utilities.string import trailing_slash
# Environment setup
#
-VERSION = '4.0.1-dev'
+VERSION = '4.0.4-dev'
HOSTNAME = platform.node()
# Set the base directory two levels up
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -105,7 +105,7 @@ LANGUAGE_CODE = getattr(configuration, 'DEFAULT_LANGUAGE', 'en-us')
LANGUAGE_COOKIE_PATH = CSRF_COOKIE_PATH
LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
-LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
+LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True)
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
@@ -156,6 +156,7 @@ SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
+TRANSLATION_ENABLED = getattr(configuration, 'TRANSLATION_ENABLED', True)
# Load any dynamic configuration parameters which have been hard-coded in the configuration file
for param in CONFIG_PARAMS:
@@ -241,6 +242,7 @@ if 'tasks' not in REDIS:
TASKS_REDIS = REDIS['tasks']
TASKS_REDIS_HOST = TASKS_REDIS.get('HOST', 'localhost')
TASKS_REDIS_PORT = TASKS_REDIS.get('PORT', 6379)
+TASKS_REDIS_URL = TASKS_REDIS.get('URL')
TASKS_REDIS_SENTINELS = TASKS_REDIS.get('SENTINELS', [])
TASKS_REDIS_USING_SENTINEL = all([
isinstance(TASKS_REDIS_SENTINELS, (list, tuple)),
@@ -269,7 +271,7 @@ CACHING_REDIS_SENTINEL_SERVICE = REDIS['caching'].get('SENTINEL_SERVICE', 'defau
CACHING_REDIS_PROTO = 'rediss' if REDIS['caching'].get('SSL', False) else 'redis'
CACHING_REDIS_SKIP_TLS_VERIFY = REDIS['caching'].get('INSECURE_SKIP_TLS_VERIFY', False)
CACHING_REDIS_CA_CERT_PATH = REDIS['caching'].get('CA_CERT_PATH', False)
-CACHING_REDIS_URL = f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_USERNAME_HOST}:{CACHING_REDIS_PORT}/{CACHING_REDIS_DATABASE}'
+CACHING_REDIS_URL = REDIS['caching'].get('URL', f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_USERNAME_HOST}:{CACHING_REDIS_PORT}/{CACHING_REDIS_DATABASE}')
# Configure Django's default cache to use Redis
CACHES = {
@@ -372,7 +374,6 @@ if not DJANGO_ADMIN_ENABLED:
# Middleware
MIDDLEWARE = [
"strawberry_django.middlewares.debug_toolbar.DebugToolbarMiddleware",
- 'django_prometheus.middleware.PrometheusBeforeMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
@@ -386,8 +387,14 @@ MIDDLEWARE = [
'netbox.middleware.RemoteUserMiddleware',
'netbox.middleware.CoreMiddleware',
'netbox.middleware.MaintenanceModeMiddleware',
- 'django_prometheus.middleware.PrometheusAfterMiddleware',
]
+if METRICS_ENABLED:
+ # If metrics are enabled, add the before & after Prometheus middleware
+ MIDDLEWARE = [
+ 'django_prometheus.middleware.PrometheusBeforeMiddleware',
+ *MIDDLEWARE,
+ 'django_prometheus.middleware.PrometheusAfterMiddleware',
+ ]
# URLs
ROOT_URLCONF = 'netbox.urls'
@@ -440,6 +447,9 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
# Use timezone-aware datetime objects
USE_TZ = True
+# Toggle language translation support
+USE_I18N = TRANSLATION_ENABLED
+
# WSGI
WSGI_APPLICATION = 'netbox.wsgi.application'
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
@@ -669,6 +679,12 @@ if TASKS_REDIS_USING_SENTINEL:
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
},
}
+elif TASKS_REDIS_URL:
+ RQ_PARAMS = {
+ 'URL': TASKS_REDIS_URL,
+ 'SSL': TASKS_REDIS_SSL,
+ 'SSL_CERT_REQS': None if TASKS_REDIS_SKIP_TLS_VERIFY else 'required',
+ }
else:
RQ_PARAMS = {
'HOST': TASKS_REDIS_HOST,
@@ -703,6 +719,7 @@ RQ_QUEUES.update({
# Supported translation languages
LANGUAGES = (
+ ('de', _('German')),
('en', _('English')),
('es', _('Spanish')),
('fr', _('French')),
@@ -710,6 +727,8 @@ LANGUAGES = (
('pt', _('Portuguese')),
('ru', _('Russian')),
('tr', _('Turkish')),
+ ('uk', _('Ukrainian')),
+ ('zh', _('Chinese')),
)
LOCALE_PATHS = (
BASE_DIR + '/translations',
@@ -796,3 +815,10 @@ for plugin_name in PLUGINS:
RQ_QUEUES.update({
f"{plugin_name}.{queue}": RQ_PARAMS for queue in plugin_config.queues
})
+
+# UNSUPPORTED FUNCTIONALITY: Import any local overrides.
+try:
+ from .local_settings import *
+ _UNSUPPORTED_SETTINGS = True
+except ImportError:
+ pass
diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py
index 38f7248e6..b191896fa 100644
--- a/netbox/netbox/tables/tables.py
+++ b/netbox/netbox/tables/tables.py
@@ -1,4 +1,5 @@
from copy import deepcopy
+from functools import cached_property
import django_tables2 as tables
from django.contrib.auth.models import AnonymousUser
@@ -14,6 +15,7 @@ from django_tables2.data import TableQuerysetData
from core.models import ObjectType
from extras.choices import *
from extras.models import CustomField, CustomLink
+from netbox.constants import EMPTY_TABLE_TEXT
from netbox.registry import registry
from netbox.tables import columns
from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -188,6 +190,7 @@ class NetBoxTable(BaseTable):
actions = columns.ActionsColumn()
exempt_columns = ('pk', 'actions')
+ embedded = False
class Meta(BaseTable.Meta):
pass
@@ -217,12 +220,12 @@ class NetBoxTable(BaseTable):
super().__init__(*args, extra_columns=extra_columns, **kwargs)
- @property
+ @cached_property
def htmx_url(self):
"""
Return the base HTML request URL for embedded tables.
"""
- if getattr(self, 'embedded', False):
+ if self.embedded:
viewname = get_viewname(self._meta.model, action='list')
try:
return reverse(viewname)
@@ -258,7 +261,7 @@ class SearchTable(tables.Table):
attrs = {
'class': 'table table-hover object-list',
}
- empty_text = _('No results found')
+ empty_text = _(EMPTY_TABLE_TEXT)
def __init__(self, data, highlight=None, **kwargs):
self.highlight = highlight
diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py
index 24bc53005..9ce20e204 100644
--- a/netbox/netbox/tests/test_plugins.py
+++ b/netbox/netbox/tests/test_plugins.py
@@ -42,6 +42,7 @@ class PluginTest(TestCase):
url = reverse('admin:dummy_plugin_dummymodel_add')
self.assertEqual(url, '/admin/dummy_plugin/dummymodel/add/')
+ @override_settings(LOGIN_REQUIRED=False)
def test_views(self):
# Test URL resolution
@@ -53,7 +54,7 @@ class PluginTest(TestCase):
response = client.get(url)
self.assertEqual(response.status_code, 200)
- @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
def test_api_views(self):
# Test URL resolution
@@ -65,6 +66,7 @@ class PluginTest(TestCase):
response = client.get(url)
self.assertEqual(response.status_code, 200)
+ @override_settings(LOGIN_REQUIRED=False)
def test_registered_views(self):
# Test URL resolution
diff --git a/netbox/netbox/tests/test_views.py b/netbox/netbox/tests/test_views.py
index 1942471b0..ccba73baa 100644
--- a/netbox/netbox/tests/test_views.py
+++ b/netbox/netbox/tests/test_views.py
@@ -1,24 +1,76 @@
import urllib.parse
-from utilities.testing import TestCase
from django.urls import reverse
+from django.test import override_settings
+
+from dcim.models import Site
+from netbox.constants import EMPTY_TABLE_TEXT
+from netbox.search.backends import search_backend
+from utilities.testing import TestCase
class HomeViewTestCase(TestCase):
def test_home(self):
-
url = reverse('home')
-
response = self.client.get(url)
self.assertHttpStatus(response, 200)
- def test_search(self):
+class SearchViewTestCase(TestCase):
+
+ @classmethod
+ def setUpTestData(cls):
+ sites = (
+ Site(name='Site Alpha', slug='alpha', description='Red'),
+ Site(name='Site Bravo', slug='bravo', description='Red'),
+ Site(name='Site Charlie', slug='charlie', description='Green'),
+ Site(name='Site Delta', slug='delta', description='Green'),
+ Site(name='Site Echo', slug='echo', description='Blue'),
+ Site(name='Site Foxtrot', slug='foxtrot', description='Blue'),
+ )
+ Site.objects.bulk_create(sites)
+ search_backend.cache(sites)
+
+ def test_search(self):
+ url = reverse('search')
+ response = self.client.get(url)
+ self.assertHttpStatus(response, 200)
+
+ def test_search_query(self):
url = reverse('search')
params = {
- 'q': 'foo',
+ 'q': 'red',
}
+ query = urllib.parse.urlencode(params)
- response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+ # Test without view permission
+ response = self.client.get(f'{url}?{query}')
self.assertHttpStatus(response, 200)
+ content = str(response.content)
+ self.assertIn(EMPTY_TABLE_TEXT, content)
+
+ # Add view permissions & query again. Only matching objects should be listed
+ self.add_permissions('dcim.view_site')
+ response = self.client.get(f'{url}?{query}')
+ self.assertHttpStatus(response, 200)
+ content = str(response.content)
+ self.assertIn('Site Alpha', content)
+ self.assertIn('Site Bravo', content)
+ self.assertNotIn('Site Charlie', content)
+ self.assertNotIn('Site Delta', content)
+ self.assertNotIn('Site Echo', content)
+ self.assertNotIn('Site Foxtrot', content)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ def test_search_no_results(self):
+ url = reverse('search')
+ params = {
+ 'q': 'xxxxxxxxx', # Matches nothing
+ }
+ query = urllib.parse.urlencode(params)
+
+ response = self.client.get(f'{url}?{query}')
+ self.assertHttpStatus(response, 200)
+ content = str(response.content)
+ self.assertIn(EMPTY_TABLE_TEXT, content)
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
index be574204c..87e352710 100644
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/netbox/netbox/views/generic/bulk_views.py
@@ -163,7 +163,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# If this is an HTMX request, return only the rendered table HTML
if htmx_partial(request):
- if not request.htmx.target:
+ if request.GET.get('embedded', False):
table.embedded = True
# Hide selection checkboxes
if 'pk' in table.base_columns:
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
index 65ab490e0..243ae2547 100644
--- a/netbox/netbox/views/generic/object_views.py
+++ b/netbox/netbox/views/generic/object_views.py
@@ -93,6 +93,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
child_model = None
table = None
filterset = None
+ template_name = 'generic/object_children.html'
def get_children(self, request, parent):
"""
diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css
index 10e9f7d59..a1179a319 100644
Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ
diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js
index 6c0d46b53..58c419b3d 100644
Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ
diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map
index 14f5520e4..f70987c66 100644
Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ
diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json
index 4a931232f..e69037f9d 100644
--- a/netbox/project-static/package.json
+++ b/netbox/project-static/package.json
@@ -30,7 +30,7 @@
"gridstack": "10.1.2",
"htmx.org": "1.9.12",
"query-string": "9.0.0",
- "sass": "1.76.0",
+ "sass": "1.77.2",
"tom-select": "2.3.1",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"
diff --git a/netbox/project-static/src/search.ts b/netbox/project-static/src/search.ts
index 8275b126e..4be740196 100644
--- a/netbox/project-static/src/search.ts
+++ b/netbox/project-static/src/search.ts
@@ -10,9 +10,9 @@ function quickSearchEventHandler(event: Event): void {
const clearbtn = document.getElementById("quicksearch_clear") as HTMLAnchorElement;
if (isTruthy(clearbtn)) {
if (quicksearch.value === "") {
- clearbtn.classList.add("d-none");
+ clearbtn.classList.add("invisible");
} else {
- clearbtn.classList.remove("d-none");
+ clearbtn.classList.remove("invisible");
}
}
}
diff --git a/netbox/project-static/styles/_variables.scss b/netbox/project-static/styles/_variables.scss
index 6ac3c4896..afd4bc6bd 100644
--- a/netbox/project-static/styles/_variables.scss
+++ b/netbox/project-static/styles/_variables.scss
@@ -1,7 +1,7 @@
// Global variables
// Set base fonts
-$font-family-base: 'Inter';
+$font-family-sans-serif: 'Inter';
// See https://github.com/tabler/tabler/issues/1812
$font-family-monospace: 'Roboto Mono';
diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock
index d5d5a642e..16c8b4dbc 100644
--- a/netbox/project-static/yarn.lock
+++ b/netbox/project-static/yarn.lock
@@ -1816,9 +1816,9 @@ ignore@^5.2.0:
integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
immutable@^4.0.0:
- version "4.3.5"
- resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.5.tgz#f8b436e66d59f99760dc577f5c99a4fd2a5cc5a0"
- integrity sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==
+ version "4.3.6"
+ resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.6.tgz#6a05f7858213238e587fb83586ffa3b4b27f0447"
+ integrity sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==
import-fresh@^3.2.1:
version "3.3.0"
@@ -2482,7 +2482,16 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0"
is-regex "^1.1.4"
-sass@1.76.0, sass@^1.7.3:
+sass@1.77.2:
+ version "1.77.2"
+ resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.2.tgz#18d4ed2eefc260cdc8099c5439ec1303fd5863aa"
+ integrity sha512-eb4GZt1C3avsX3heBNlrc7I09nyT00IUuo4eFhAbeXWU2fvA7oXI53SxODVAA+zgZCk9aunAZgO+losjR3fAwA==
+ dependencies:
+ chokidar ">=3.0.0 <4.0.0"
+ immutable "^4.0.0"
+ source-map-js ">=0.6.2 <2.0.0"
+
+sass@^1.7.3:
version "1.76.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.76.0.tgz#fe15909500735ac154f0dc7386d656b62b03987d"
integrity sha512-nc3LeqvF2FNW5xGF1zxZifdW3ffIz5aBb7I7tSvOoNu7z1RQ6pFt9MBuiPtjgaI62YWrM/txjWlOCFiGtf2xpw==
diff --git a/netbox/templates/circuits/circuittermination.html b/netbox/templates/circuits/circuittermination.html
new file mode 100644
index 000000000..d74d2c636
--- /dev/null
+++ b/netbox/templates/circuits/circuittermination.html
@@ -0,0 +1,51 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+ {{ block.super }}
+ {{ object.circuit.provider }}
+{% endblock %}
+
+{% block content %}
+
+
+
+
+ {% if object %}
+
+
+ {% trans "Circuit" %}
+
+ {{ object.circuit|linkify }}
+
+
+
+ {% trans "Provider" %}
+
+ {{ object.circuit.provider|linkify }}
+
+
+ {% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
+
+ {% else %}
+
+ {% trans "None" %}
+
+ {% endif %}
+
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/panels/custom_fields.html' %}
+ {% include 'inc/panels/tags.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html
index a0afebb02..acec208c0 100644
--- a/netbox/templates/circuits/inc/circuit_termination.html
+++ b/netbox/templates/circuits/inc/circuit_termination.html
@@ -27,93 +27,7 @@
{% if termination %}
- {% if termination.site %}
-
- {% trans "Site" %}
-
- {% if termination.site.region %}
- {{ termination.site.region|linkify }} /
- {% endif %}
- {{ termination.site|linkify }}
-
-
-
- {% trans "Termination" %}
-
- {% if termination.mark_connected %}
-
- {% trans "Marked as connected" %}
- {% elif termination.cable %}
- {{ termination.cable }} {% trans "to" %}
- {% for peer in termination.link_peers %}
- {% if peer.device %}
- {{ peer.device|linkify }}
- {% elif peer.circuit %}
- {{ peer.circuit|linkify }}
- {% endif %}
- {{ peer|linkify }}{% if not forloop.last %},{% endif %}
- {% endfor %}
-
- {% elif perms.dcim.add_cable %}
-
-
- {% trans "Connect" %}
-
-
-
- {% endif %}
-
-
- {% else %}
-
- {% trans "Provider Network" %}
- {{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}
-
- {% endif %}
-
- {% trans "Speed" %}
-
- {% if termination.port_speed and termination.upstream_speed %}
- {{ termination.port_speed|humanize_speed }}
- {{ termination.upstream_speed|humanize_speed }}
- {% elif termination.port_speed %}
- {{ termination.port_speed|humanize_speed }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
- {% trans "Cross-Connect" %}
- {{ termination.xconnect_id|placeholder }}
-
-
- {% trans "Patch Panel/Port" %}
- {{ termination.pp_info|placeholder }}
-
-
- {% trans "Description" %}
- {{ termination.description|placeholder }}
-
+ {% include 'circuits/inc/circuit_termination_fields.html' with termination=termination %}
{% trans "Tags" %}
diff --git a/netbox/templates/circuits/inc/circuit_termination_fields.html b/netbox/templates/circuits/inc/circuit_termination_fields.html
new file mode 100644
index 000000000..97d194f24
--- /dev/null
+++ b/netbox/templates/circuits/inc/circuit_termination_fields.html
@@ -0,0 +1,90 @@
+{% load helpers %}
+{% load i18n %}
+
+{% if termination.site %}
+
+ {% trans "Site" %}
+
+ {% if termination.site.region %}
+ {{ termination.site.region|linkify }} /
+ {% endif %}
+ {{ termination.site|linkify }}
+
+
+
+ {% trans "Termination" %}
+
+ {% if termination.mark_connected %}
+
+ {% trans "Marked as connected" %}
+ {% elif termination.cable %}
+ {{ termination.cable }} {% trans "to" %}
+ {% for peer in termination.link_peers %}
+ {% if peer.device %}
+ {{ peer.device|linkify }}
+ {% elif peer.circuit %}
+ {{ peer.circuit|linkify }}
+ {% endif %}
+ {{ peer|linkify }}{% if not forloop.last %},{% endif %}
+ {% endfor %}
+
+ {% elif perms.dcim.add_cable %}
+
+
+ {% trans "Connect" %}
+
+
+
+ {% endif %}
+
+
+{% else %}
+
+ {% trans "Provider Network" %}
+ {{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}
+
+{% endif %}
+
+ {% trans "Speed" %}
+
+ {% if termination.port_speed and termination.upstream_speed %}
+ {{ termination.port_speed|humanize_speed }}
+ {{ termination.upstream_speed|humanize_speed }}
+ {% elif termination.port_speed %}
+ {{ termination.port_speed|humanize_speed }}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+
+
+
+ {% trans "Cross-Connect" %}
+ {{ termination.xconnect_id|placeholder }}
+
+
+ {% trans "Patch Panel/Port" %}
+ {{ termination.pp_info|placeholder }}
+
+
+ {% trans "Description" %}
+ {{ termination.description|placeholder }}
+
diff --git a/netbox/templates/core/configrevision.html b/netbox/templates/core/configrevision.html
index 0aa7b3f05..71831e161 100644
--- a/netbox/templates/core/configrevision.html
+++ b/netbox/templates/core/configrevision.html
@@ -33,7 +33,7 @@
- {% include 'core/inc/config_data.html' with config=config.data %}
+ {% include 'core/inc/config_data.html' with config=object.data %}
diff --git a/netbox/templates/core/system.html b/netbox/templates/core/system.html
index 95aa4bdb3..320038ac6 100644
--- a/netbox/templates/core/system.html
+++ b/netbox/templates/core/system.html
@@ -40,7 +40,7 @@
{{ stats.django_version }}
- {% trans "PotsgreSQL version" %}
+ {% trans "PostgreSQL version" %}
{{ stats.postgresql_version }}
diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html
index 8a4f86579..fe616dc8a 100644
--- a/netbox/templates/extras/script.html
+++ b/netbox/templates/extras/script.html
@@ -14,38 +14,43 @@
{% trans "You do not have permission to run scripts" %}.
{% endif %}
-