diff --git a/.github/ISSUE_TEMPLATE/01-feature_request.yaml b/.github/ISSUE_TEMPLATE/01-feature_request.yaml
index 7cf1ea56c..039e24fc4 100644
--- a/.github/ISSUE_TEMPLATE/01-feature_request.yaml
+++ b/.github/ISSUE_TEMPLATE/01-feature_request.yaml
@@ -15,7 +15,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
- placeholder: v4.2.8
+ placeholder: v4.2.9
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/02-bug_report.yaml b/.github/ISSUE_TEMPLATE/02-bug_report.yaml
index a39404a9d..0f18e6267 100644
--- a/.github/ISSUE_TEMPLATE/02-bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/02-bug_report.yaml
@@ -27,7 +27,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
- placeholder: v4.2.8
+ placeholder: v4.2.9
validations:
required: true
- type: dropdown
diff --git a/.github/workflows/lock-threads.yml b/.github/workflows/lock-threads.yml
index b77a84ad1..2148e563b 100644
--- a/.github/workflows/lock-threads.yml
+++ b/.github/workflows/lock-threads.yml
@@ -16,7 +16,7 @@ jobs:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
steps:
- - uses: dessant/lock-threads@v5
+ - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
with:
issue-inactive-days: 90
pr-inactive-days: 30
diff --git a/.github/workflows/update-translation-strings.yml b/.github/workflows/update-translation-strings.yml
index 8a4f05456..3152232e2 100644
--- a/.github/workflows/update-translation-strings.yml
+++ b/.github/workflows/update-translation-strings.yml
@@ -48,7 +48,7 @@ jobs:
run: python netbox/manage.py makemessages -l ${{ env.LOCALE }}
- name: Commit changes
- uses: EndBug/add-and-commit@v9
+ uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
with:
add: 'netbox/translations/'
default_author: github_actions
diff --git a/docs/configuration/default-values.md b/docs/configuration/default-values.md
index d90e6eafc..1d1992a8e 100644
--- a/docs/configuration/default-values.md
+++ b/docs/configuration/default-values.md
@@ -69,7 +69,7 @@ For a complete list of available preferences, log into NetBox and navigate to `/
!!! tip "Dynamic Configuration Parameter"
-Default: 50
+Default: `50`
The default maximum number of objects to display per page within each list of objects.
@@ -79,7 +79,7 @@ The default maximum number of objects to display per page within each list of ob
!!! tip "Dynamic Configuration Parameter"
-Default: 15
+Default: `15`
The default value for the `amperage` field when creating new power feeds.
@@ -89,7 +89,7 @@ The default value for the `amperage` field when creating new power feeds.
!!! tip "Dynamic Configuration Parameter"
-Default: 80
+Default: `80`
The default value (percentage) for the `max_utilization` field when creating new power feeds.
@@ -99,7 +99,7 @@ The default value (percentage) for the `max_utilization` field when creating new
!!! tip "Dynamic Configuration Parameter"
-Default: 120
+Default: `120`
The default value for the `voltage` field when creating new power feeds.
@@ -109,7 +109,7 @@ The default value for the `voltage` field when creating new power feeds.
!!! tip "Dynamic Configuration Parameter"
-Default: 22
+Default: `22`
Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`.
@@ -119,6 +119,6 @@ Default height (in pixels) of a unit within a rack elevation. For best results,
!!! tip "Dynamic Configuration Parameter"
-Default: 220
+Default: `220`
Default width (in pixels) of a unit within a rack elevation.
diff --git a/docs/configuration/development.md b/docs/configuration/development.md
index 6e1a4d9c4..a76fb80b0 100644
--- a/docs/configuration/development.md
+++ b/docs/configuration/development.md
@@ -2,7 +2,7 @@
## DEBUG
-Default: False
+Default: `False`
This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only
clients which access NetBox from a recognized [internal IP address](./system.md#internal_ips) will see debugging tools in the user
@@ -16,6 +16,6 @@ interface.
## DEVELOPER
-Default: False
+Default: `False`
This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Additionally, enabling this setting disables the debug warning banner in the UI. Set this to `True` **only** if you are actively developing the NetBox code base.
diff --git a/docs/configuration/error-reporting.md b/docs/configuration/error-reporting.md
index 56f187845..3b86a78d2 100644
--- a/docs/configuration/error-reporting.md
+++ b/docs/configuration/error-reporting.md
@@ -2,7 +2,7 @@
## SENTRY_DSN
-Default: None
+Default: `None`
Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be True for this parameter to take effect. For example:
@@ -14,7 +14,7 @@ SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
## SENTRY_ENABLED
-Default: False
+Default: `False`
Set to True to enable automatic error reporting via [Sentry](https://sentry.io/).
@@ -25,7 +25,7 @@ Set to True to enable automatic error reporting via [Sentry](https://sentry.io/)
## SENTRY_SAMPLE_RATE
-Default: 1.0 (all)
+Default: `1.0` (all)
The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors).
@@ -33,7 +33,7 @@ The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (repo
## SENTRY_SEND_DEFAULT_PII
-Default: False
+Default: `False`
Maps to the Sentry SDK's [`send_default_pii`](https://docs.sentry.io/platforms/python/configuration/options/#send-default-pii) parameter. If enabled, certain personally identifiable information (PII) is added.
@@ -60,7 +60,7 @@ SENTRY_TAGS = {
## SENTRY_TRACES_SAMPLE_RATE
-Default: 0 (disabled)
+Default: `0` (disabled)
The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions).
diff --git a/docs/configuration/graphql-api.md b/docs/configuration/graphql-api.md
index a792da544..9b02d745c 100644
--- a/docs/configuration/graphql-api.md
+++ b/docs/configuration/graphql-api.md
@@ -4,7 +4,7 @@
!!! tip "Dynamic Configuration Parameter"
-Default: True
+Default: `True`
Setting this to False will disable the GraphQL API.
@@ -12,6 +12,6 @@ Setting this to False will disable the GraphQL API.
## GRAPHQL_MAX_ALIASES
-Default: 10
+Default: `10`
The maximum number of queries that a GraphQL API request may contain.
diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md
index b9d079564..e4d46f428 100644
--- a/docs/configuration/miscellaneous.md
+++ b/docs/configuration/miscellaneous.md
@@ -55,7 +55,7 @@ Sets content for the top banner in the user interface.
## CENSUS_REPORTING_ENABLED
-Default: True
+Default: `True`
Enables anonymous census reporting. To opt out of census reporting, set this to False.
@@ -67,7 +67,7 @@ This data enables the project maintainers to estimate how many NetBox deployment
!!! tip "Dynamic Configuration Parameter"
-Default: 90
+Default: `90`
The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain
changes in the database indefinitely.
@@ -79,7 +79,7 @@ changes in the database indefinitely.
## CHANGELOG_SKIP_EMPTY_CHANGES
-Default: True
+Default: `True`
If enabled, a change log record will not be created when an object is updated without any changes to its existing field values.
@@ -100,7 +100,7 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da
!!! tip "Dynamic Configuration Parameter"
-Default: True
+Default: `True`
By default, NetBox will prevent the creation of duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This validation can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to False.
@@ -128,7 +128,7 @@ The maximum amount (in bytes) of uploaded data that will be held in memory befor
!!! tip "Dynamic Configuration Parameter"
-Default: 90
+Default: `90`
The number of days to retain job results (scripts and reports). Set this to `0` to retain job results in the database indefinitely.
@@ -141,7 +141,7 @@ The number of days to retain job results (scripts and reports). Set this to `0`
!!! tip "Dynamic Configuration Parameter"
-Default: False
+Default: `False`
Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
@@ -161,7 +161,7 @@ This specifies the URL to use when presenting a map of a physical location by st
!!! tip "Dynamic Configuration Parameter"
-Default: 1000
+Default: `1000`
A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`.
@@ -169,7 +169,7 @@ A web user or API consumer can request an arbitrary number of objects by appendi
## METRICS_ENABLED
-Default: False
+Default: `False`
Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../integrations/prometheus-metrics.md) documentation for more details.
@@ -179,7 +179,7 @@ Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Pr
!!! tip "Dynamic Configuration Parameter"
-Default: False
+Default: `False`
When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead.
@@ -203,7 +203,7 @@ If no queue is defined the queue named `default` will be used.
## RELEASE_CHECK_URL
-Default: None (disabled)
+Default: `None` (disabled)
This parameter defines the URL of the repository that will be checked for new NetBox releases. When a new release is detected, a message will be displayed to administrative users on the home page. This can be set to the official repository (`'https://api.github.com/repos/netbox-community/netbox/releases'`) or a custom fork. Set this to `None` to disable automatic update checks.
diff --git a/docs/configuration/plugins.md b/docs/configuration/plugins.md
index 765c3d50f..9e19622f9 100644
--- a/docs/configuration/plugins.md
+++ b/docs/configuration/plugins.md
@@ -2,7 +2,7 @@
## PLUGINS
-Default: Empty
+Default: `[]`
A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins will not take effect unless they are listed here.
@@ -13,7 +13,7 @@ A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins wil
## PLUGINS_CONFIG
-Default: Empty
+Default: `[]`
This parameter holds configuration settings for individual NetBox plugins. It is defined as a dictionary, with each key using the name of an installed plugin. The specific parameters supported are unique to each plugin: Reference the plugin's documentation to determine the supported parameters. An example configuration is shown below:
diff --git a/docs/configuration/security.md b/docs/configuration/security.md
index 950d2df34..771eba5c5 100644
--- a/docs/configuration/security.md
+++ b/docs/configuration/security.md
@@ -2,7 +2,7 @@
## ALLOW_TOKEN_RETRIEVAL
-Default: False
+Default: `False`
!!! note
The default value of this parameter changed from true to false in NetBox v4.3.0.
@@ -50,7 +50,7 @@ Although it is not recommended, the default validation rules can be disabled by
## CORS_ORIGIN_ALLOW_ALL
-Default: False
+Default: `False`
If True, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below).
@@ -82,7 +82,7 @@ The name of the cookie to use for the cross-site request forgery (CSRF) authenti
## CSRF_COOKIE_SECURE
-Default: False
+Default: `False`
If true, the cookie employed for cross-site request forgery (CSRF) protection will be marked as secure, meaning that it can only be sent across an HTTPS connection.
@@ -162,7 +162,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
## LOGIN_PERSISTENCE
-Default: False
+Default: `False`
If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days.
@@ -172,7 +172,7 @@ Note that enabling this setting causes NetBox to update a user's session in the
## LOGIN_REQUIRED
-Default: True
+Default: `True`
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).
@@ -183,7 +183,7 @@ When enabled, only authenticated users are permitted to access any part of NetBo
## LOGIN_TIMEOUT
-Default: 1209600 seconds (14 days)
+Default: `1209600` seconds (14 days)
The lifetime (in seconds) of the authentication cookie issued to a NetBox user upon login.
@@ -210,7 +210,7 @@ The view name or URL to which a user is redirected after logging out.
## SECURE_HSTS_INCLUDE_SUBDOMAINS
-Default: False
+Default: `False`
If true, the `includeSubDomains` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to apply the HSTS policy to all subdomains of the current domain.
@@ -218,7 +218,7 @@ If true, the `includeSubDomains` directive will be included in the HTTP Strict T
## SECURE_HSTS_PRELOAD
-Default: False
+Default: `False`
If true, the `preload` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to preload the site in HTTPS. Browsers that use the HSTS preload list will force the site to be accessed via HTTPS even if the user types HTTP in the address bar.
@@ -226,7 +226,7 @@ If true, the `preload` directive will be included in the HTTP Strict Transport S
## SECURE_HSTS_SECONDS
-Default: 0
+Default: `0`
If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict Transport Security (HSTS) header on all responses that do not already have it. This will instruct the browser that the website must be accessed via HTTPS, blocking any HTTP request.
@@ -234,7 +234,7 @@ If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict
## SECURE_SSL_REDIRECT
-Default: False
+Default: `False`
If true, all non-HTTPS requests will be automatically redirected to use HTTPS.
@@ -253,7 +253,7 @@ The name used for the session cookie. See the [Django documentation](https://doc
## SESSION_COOKIE_SECURE
-Default: False
+Default: `False`
If true, the cookie employed for session authentication will be marked as secure, meaning that it can only be sent across an HTTPS connection.
@@ -261,6 +261,6 @@ If true, the cookie employed for session authentication will be marked as secure
## SESSION_FILE_PATH
-Default: None
+Default: `None`
HTTP session data is used to track authenticated users when they access NetBox. By default, NetBox stores session data in its PostgreSQL database. However, this inhibits authentication to a standby instance of NetBox without write access to the database. Alternatively, a local file path may be specified here and NetBox will store session data as files instead of using the database. Note that the NetBox system user must have read and write permissions to this path.
diff --git a/docs/configuration/system.md b/docs/configuration/system.md
index 63e7cf64f..fe01e40b1 100644
--- a/docs/configuration/system.md
+++ b/docs/configuration/system.md
@@ -2,7 +2,7 @@
## BASE_PATH
-Default: None
+Default: `None`
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set:
@@ -74,7 +74,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
## HTTP_PROXIES
-Default: Empty
+Default: `None`
A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://requests.readthedocs.io/en/latest/user/advanced/#proxies). For example:
@@ -101,7 +101,7 @@ addresses (and [`DEBUG`](./development.md#debug) is true).
## ISOLATED_DEPLOYMENT
-Default: False
+Default: `False`
Set this configuration parameter to True for NetBox deployments which do not have Internet access. This will disable miscellaneous functionality which depends on access to the Internet.
@@ -253,7 +253,7 @@ The specific configuration settings for each storage backend can be found in the
## TIME_ZONE
-Default: UTC
+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).
@@ -261,6 +261,6 @@ The time zone NetBox will use when dealing with dates and times. It is recommend
## TRANSLATION_ENABLED
-Default: True
+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/integrations/rest-api.md b/docs/integrations/rest-api.md
index e0d2c445f..7a0d3e176 100644
--- a/docs/integrations/rest-api.md
+++ b/docs/integrations/rest-api.md
@@ -217,26 +217,34 @@ If we wanted to assign this IP address to a virtual machine interface instead, w
### Brief Format
-Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of an IP address looks like this:
+Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of a prefix looks like this:
-```
+```no-highlight
GET /api/ipam/prefixes/13980/
+```
+```json
{
"id": 13980,
"url": "http://netbox/api/ipam/prefixes/13980/",
+ "display_url": "http://netbox/api/ipam/prefixes/13980/",
+ "display": "192.0.2.0/24",
"family": {
"value": 4,
"label": "IPv4"
},
"prefix": "192.0.2.0/24",
- "site": {
- "id": 3,
- "url": "http://netbox/api/dcim/sites/17/",
- "name": "Site 23A",
- "slug": "site-23a"
- },
"vrf": null,
+ "scope_type": "dcim.site",
+ "scope_id": 3,
+ "scope": {
+ "id": 3,
+ "url": "http://netbox/api/dcim/sites/3/",
+ "display": "Site 23A",
+ "name": "Site 23A",
+ "slug": "site-23a",
+ "description": ""
+ },
"tenant": null,
"vlan": null,
"status": {
@@ -250,24 +258,36 @@ GET /api/ipam/prefixes/13980/
"slug": "staging"
},
"is_pool": false,
+ "mark_utilized": false,
"description": "Example prefix",
+ "comments": "",
"tags": [],
"custom_fields": {},
- "created": "2018-12-10",
- "last_updated": "2019-03-01T20:02:46.173540Z"
+ "created": "2025-03-01T20:01:23.458302Z",
+ "last_updated": "2025-03-01T20:02:46.173540Z",
+ "children": 0,
+ "_depth": 0
}
```
The brief format is much more terse:
-```
+```no-highlight
GET /api/ipam/prefixes/13980/?brief=1
+```
+```json
{
"id": 13980,
"url": "http://netbox/api/ipam/prefixes/13980/",
- "family": 4,
- "prefix": "10.40.3.0/24"
+ "display": "192.0.2.0/24",
+ "family": {
+ "value": 4,
+ "label": "IPv4"
+ },
+ "prefix": "192.0.2.0/24",
+ "description": "Example prefix",
+ "_depth": 0
}
```
@@ -400,25 +420,31 @@ curl -s -X POST \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
http://netbox/api/ipam/prefixes/ \
---data '{"prefix": "192.0.2.0/24", "site": 6}' | jq '.'
+--data '{"prefix": "192.0.2.0/24", "scope_type": "dcim.site", "scope_id": 6}' | jq '.'
```
```json
{
"id": 18691,
"url": "http://netbox/api/ipam/prefixes/18691/",
+ "display_url": "http://netbox/api/ipam/prefixes/18691/",
+ "display": "192.0.2.0/24",
"family": {
"value": 4,
"label": "IPv4"
},
"prefix": "192.0.2.0/24",
- "site": {
+ "vrf": null,
+ "scope_type": "dcim.site",
+ "scope_id": 6,
+ "scope": {
"id": 6,
"url": "http://netbox/api/dcim/sites/6/",
+ "display": "US-East 4",
"name": "US-East 4",
- "slug": "us-east-4"
+ "slug": "us-east-4",
+ "description": ""
},
- "vrf": null,
"tenant": null,
"vlan": null,
"status": {
@@ -427,11 +453,15 @@ http://netbox/api/ipam/prefixes/ \
},
"role": null,
"is_pool": false,
+ "mark_utilized": false,
"description": "",
+ "comments": "",
"tags": [],
"custom_fields": {},
- "created": "2020-08-04",
- "last_updated": "2020-08-04T20:08:39.007125Z"
+ "created": "2025-04-29T15:44:47.597092Z",
+ "last_updated": "2025-04-29T15:44:47.597092Z",
+ "children": 0,
+ "_depth": 0
}
```
@@ -490,18 +520,24 @@ http://netbox/api/ipam/prefixes/18691/ \
{
"id": 18691,
"url": "http://netbox/api/ipam/prefixes/18691/",
+ "display_url": "http://netbox/api/ipam/prefixes/18691/",
+ "display": "192.0.2.0/24",
"family": {
"value": 4,
"label": "IPv4"
},
"prefix": "192.0.2.0/24",
- "site": {
+ "vrf": null,
+ "scope_type": "dcim.site",
+ "scope_id": 6,
+ "scope": {
"id": 6,
"url": "http://netbox/api/dcim/sites/6/",
+ "display": "US-East 4",
"name": "US-East 4",
- "slug": "us-east-4"
+ "slug": "us-east-4",
+ "description": ""
},
- "vrf": null,
"tenant": null,
"vlan": null,
"status": {
@@ -510,11 +546,15 @@ http://netbox/api/ipam/prefixes/18691/ \
},
"role": null,
"is_pool": false,
+ "mark_utilized": false,
"description": "",
+ "comments": "",
"tags": [],
"custom_fields": {},
- "created": "2020-08-04",
- "last_updated": "2020-08-04T20:14:55.709430Z"
+ "created": "2025-04-29T15:44:47.597092Z",
+ "last_updated": "2025-04-29T15:49:40.689109Z",
+ "children": 0,
+ "_depth": 0
}
```
@@ -568,6 +608,23 @@ http://netbox/api/dcim/sites/ \
!!! note
The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted.
+## Uploading Files
+
+As JSON does not support the inclusion of binary data, files cannot be uploaded using JSON-formatted API requests. Instead, we can use form data encoding to attach a local file.
+
+For example, we can upload an image attachment using the `curl` command shown below. Note that the `@` signifies a local file on disk to be uploaded.
+
+```no-highlight
+curl -X POST \
+-H "Authorization: Token $TOKEN" \
+-H "Accept: application/json; indent=4" \
+-F "object_type=dcim.site" \
+-F "object_id=2" \
+-F "name=attachment1.png" \
+-F "image=@local_file.png" \
+http://netbox/api/extras/image-attachments/
+```
+
## Authentication
The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API.
@@ -653,6 +710,7 @@ Note that we are _not_ passing an existing REST API token with this request. If
{
"id": 6,
"url": "https://netbox/api/users/tokens/6/",
+ "display_url": "https://netbox/api/users/tokens/6/",
"display": "**********************************3c9cb9",
"user": {
"id": 2,
diff --git a/docs/release-notes/version-4.2.md b/docs/release-notes/version-4.2.md
index 612fb9b02..45484f406 100644
--- a/docs/release-notes/version-4.2.md
+++ b/docs/release-notes/version-4.2.md
@@ -1,5 +1,42 @@
# NetBox v4.2
+## v4.2.9 (2025-04-30)
+
+### Enhancements
+
+* [#17151](https://github.com/netbox-community/netbox/issues/17151) - Display circuit type with background color in circuits list
+* [#17319](https://github.com/netbox-community/netbox/issues/17319) - Improve layout of component template edit forms
+* [#17405](https://github.com/netbox-community/netbox/issues/17405) - Display plugin icons in plugins list
+* [#18215](https://github.com/netbox-community/netbox/issues/18215) - Link to script results list from script history
+* [#18334](https://github.com/netbox-community/netbox/issues/18334) - Add region, site group, site, location, and rack filters for modules
+* [#18982](https://github.com/netbox-community/netbox/issues/18982) - Reference rack as related object in changelog records for rack reservations
+* [#18989](https://github.com/netbox-community/netbox/issues/18989) - List virtual circuits under provider view
+* [#19110](https://github.com/netbox-community/netbox/issues/19110) - Enable filtering devices and virtual machines by primary IP address
+* [#19358](https://github.com/netbox-community/netbox/issues/19358) - Move release info from footer to the navigation menu
+
+### Bug Fixes
+
+* [#15739](https://github.com/netbox-community/netbox/issues/15739) - Account for parallel cables when calculating total path length
+* [#15971](https://github.com/netbox-community/netbox/issues/15971) - Preserve "none" selection in filter form fields
+* [#16238](https://github.com/netbox-community/netbox/issues/16238) - Fix styling for white, gray, and black custom link buttons
+* [#17613](https://github.com/netbox-community/netbox/issues/17613) - Fix layout of object view content on mobile
+* [#17676](https://github.com/netbox-community/netbox/issues/17676) - Fix support for module bay creation when bulk importing module types
+* [#18706](https://github.com/netbox-community/netbox/issues/18706) - Fix validation for VLANs assigned to both a group and a site
+* [#18717](https://github.com/netbox-community/netbox/issues/18717) - Ensure change logs populated for many-to-one changes
+* [#19117](https://github.com/netbox-community/netbox/issues/19117) - Avoid `AttributeError` exception when bulk import objects which have a multi-object custom field with a default value
+* [#19204](https://github.com/netbox-community/netbox/issues/19204) - Improve JSON serialization support for data returned by a custom script
+* [#19217](https://github.com/netbox-community/netbox/issues/19217) - Ensure static assets for the debug toolbar are installed even if `DEBUG` is false
+* [#19228](https://github.com/netbox-community/netbox/issues/19228) - Fix ordering of custom scripts to avoid `NoReverseMatch` exception
+* [#19229](https://github.com/netbox-community/netbox/issues/19229) - Fix `ValueError` exception when attempting to nullify interface mode when a VLAN is assigned
+* [#19275](https://github.com/netbox-community/netbox/issues/19275) - `type` field should not be required when bulk editing interfaces
+* [#19279](https://github.com/netbox-community/netbox/issues/19279) - `status` field should not be required when bulk editing inventory items
+* [#19281](https://github.com/netbox-community/netbox/issues/19281) - Fix form validation failure when attempting to create a service from a service template
+* [#19320](https://github.com/netbox-community/netbox/issues/19320) - Include Q-in-Q VLAN (if any) in VM interface details
+* [#19322](https://github.com/netbox-community/netbox/issues/19322) - Correct URL paths for bulk import views
+* [#19346](https://github.com/netbox-community/netbox/issues/19346) - Ensure all redirect URLs are validated before use
+
+---
+
## v4.2.8 (2025-04-22)
### Enhancements
diff --git a/netbox/account/views.py b/netbox/account/views.py
index b1858ef3c..f28d5eff5 100644
--- a/netbox/account/views.py
+++ b/netbox/account/views.py
@@ -12,7 +12,7 @@ from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import render, resolve_url
from django.urls import reverse
from django.utils.decorators import method_decorator
-from django.utils.http import url_has_allowed_host_and_scheme, urlencode
+from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View
@@ -28,6 +28,7 @@ from netbox.config import get_config
from netbox.views import generic
from users import forms, tables
from users.models import UserConfig
+from utilities.request import safe_for_redirect
from utilities.string import remove_linebreaks
from utilities.views import register_model_view
@@ -148,7 +149,7 @@ class LoginView(View):
data = request.POST if request.method == "POST" else request.GET
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
- if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
+ if redirect_url and safe_for_redirect(redirect_url):
logger.debug(f"Redirecting user to {remove_linebreaks(redirect_url)}")
else:
if redirect_url:
diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py
index b568d0e90..3643446bd 100644
--- a/netbox/circuits/tables/circuits.py
+++ b/netbox/circuits/tables/circuits.py
@@ -61,9 +61,8 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
linkify=True,
verbose_name=_('Account')
)
- type = tables.Column(
+ type = columns.ColoredLabelColumn(
verbose_name=_('Type'),
- linkify=True
)
status = columns.ChoiceFieldColumn()
termination_a = columns.TemplateColumn(
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index e93ba5f99..62056cfbe 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -35,7 +35,19 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
def get_extra_context(self, request, instance):
return {
- 'related_models': self.get_related_models(request, instance),
+ 'related_models': self.get_related_models(
+ request,
+ instance,
+ omit=(),
+ extra=(
+ (
+ VirtualCircuit.objects.restrict(request.user, 'view').filter(
+ provider_network__provider=instance
+ ),
+ 'provider_id',
+ ),
+ ),
+ ),
}
@@ -51,7 +63,7 @@ class ProviderDeleteView(generic.ObjectDeleteView):
queryset = Provider.objects.all()
-@register_model_view(Provider, 'bulk_import', detail=False)
+@register_model_view(Provider, 'bulk_import', path='import', detail=False)
class ProviderBulkImportView(generic.BulkImportView):
queryset = Provider.objects.all()
model_form = forms.ProviderImportForm
@@ -112,7 +124,7 @@ class ProviderAccountDeleteView(generic.ObjectDeleteView):
queryset = ProviderAccount.objects.all()
-@register_model_view(ProviderAccount, 'bulk_import', detail=False)
+@register_model_view(ProviderAccount, 'bulk_import', path='import', detail=False)
class ProviderAccountBulkImportView(generic.BulkImportView):
queryset = ProviderAccount.objects.all()
model_form = forms.ProviderAccountImportForm
@@ -186,7 +198,7 @@ class ProviderNetworkDeleteView(generic.ObjectDeleteView):
queryset = ProviderNetwork.objects.all()
-@register_model_view(ProviderNetwork, 'bulk_import', detail=False)
+@register_model_view(ProviderNetwork, 'bulk_import', path='import', detail=False)
class ProviderNetworkBulkImportView(generic.BulkImportView):
queryset = ProviderNetwork.objects.all()
model_form = forms.ProviderNetworkImportForm
@@ -243,7 +255,7 @@ class CircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = CircuitType.objects.all()
-@register_model_view(CircuitType, 'bulk_import', detail=False)
+@register_model_view(CircuitType, 'bulk_import', path='import', detail=False)
class CircuitTypeBulkImportView(generic.BulkImportView):
queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeImportForm
@@ -299,7 +311,7 @@ class CircuitDeleteView(generic.ObjectDeleteView):
queryset = Circuit.objects.all()
-@register_model_view(Circuit, 'bulk_import', detail=False)
+@register_model_view(Circuit, 'bulk_import', path='import', detail=False)
class CircuitBulkImportView(generic.BulkImportView):
queryset = Circuit.objects.all()
model_form = forms.CircuitImportForm
@@ -439,7 +451,7 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all()
-@register_model_view(CircuitTermination, 'bulk_import', detail=False)
+@register_model_view(CircuitTermination, 'bulk_import', path='import', detail=False)
class CircuitTerminationBulkImportView(generic.BulkImportView):
queryset = CircuitTermination.objects.all()
model_form = forms.CircuitTerminationImportForm
@@ -500,7 +512,7 @@ class CircuitGroupDeleteView(generic.ObjectDeleteView):
queryset = CircuitGroup.objects.all()
-@register_model_view(CircuitGroup, 'bulk_import', detail=False)
+@register_model_view(CircuitGroup, 'bulk_import', path='import', detail=False)
class CircuitGroupBulkImportView(generic.BulkImportView):
queryset = CircuitGroup.objects.all()
model_form = forms.CircuitGroupImportForm
@@ -550,7 +562,7 @@ class CircuitGroupAssignmentDeleteView(generic.ObjectDeleteView):
queryset = CircuitGroupAssignment.objects.all()
-@register_model_view(CircuitGroupAssignment, 'bulk_import', detail=False)
+@register_model_view(CircuitGroupAssignment, 'bulk_import', path='import', detail=False)
class CircuitGroupAssignmentBulkImportView(generic.BulkImportView):
queryset = CircuitGroupAssignment.objects.all()
model_form = forms.CircuitGroupAssignmentImportForm
@@ -607,7 +619,7 @@ class VirtualCircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = VirtualCircuitType.objects.all()
-@register_model_view(VirtualCircuitType, 'bulk_import', detail=False)
+@register_model_view(VirtualCircuitType, 'bulk_import', path='import', detail=False)
class VirtualCircuitTypeBulkImportView(generic.BulkImportView):
queryset = VirtualCircuitType.objects.all()
model_form = forms.VirtualCircuitTypeImportForm
diff --git a/netbox/core/migrations/0013_job_data_encoder.py b/netbox/core/migrations/0013_job_data_encoder.py
new file mode 100644
index 000000000..b6e567e97
--- /dev/null
+++ b/netbox/core/migrations/0013_job_data_encoder.py
@@ -0,0 +1,17 @@
+import django.core.serializers.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0012_job_object_type_optional'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='job',
+ name='data',
+ field=models.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True),
+ ),
+ ]
diff --git a/netbox/core/migrations/0013_datasource_sync_interval.py b/netbox/core/migrations/0014_datasource_sync_interval.py
similarity index 76%
rename from netbox/core/migrations/0013_datasource_sync_interval.py
rename to netbox/core/migrations/0014_datasource_sync_interval.py
index ec3d2a5d6..4b2e3ddd7 100644
--- a/netbox/core/migrations/0013_datasource_sync_interval.py
+++ b/netbox/core/migrations/0014_datasource_sync_interval.py
@@ -1,12 +1,10 @@
-# Generated by Django 5.1.6 on 2025-02-26 19:45
-
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('core', '0012_job_object_type_optional'),
+ ('core', '0013_job_data_encoder'),
]
operations = [
diff --git a/netbox/core/migrations/0014_remove_redundant_indexes.py b/netbox/core/migrations/0015_remove_redundant_indexes.py
similarity index 83%
rename from netbox/core/migrations/0014_remove_redundant_indexes.py
rename to netbox/core/migrations/0015_remove_redundant_indexes.py
index fc90a67fa..b5ff0db6e 100644
--- a/netbox/core/migrations/0014_remove_redundant_indexes.py
+++ b/netbox/core/migrations/0015_remove_redundant_indexes.py
@@ -1,12 +1,10 @@
-# Generated by Django 5.2b1 on 2025-04-03 18:32
-
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('core', '0013_datasource_sync_interval'),
+ ('core', '0014_datasource_sync_interval'),
]
operations = [
diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py
index 70c004892..8c704ecad 100644
--- a/netbox/core/models/jobs.py
+++ b/netbox/core/models/jobs.py
@@ -5,6 +5,7 @@ import django_rq
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
+from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.urls import reverse
@@ -90,8 +91,9 @@ class Job(models.Model):
)
data = models.JSONField(
verbose_name=_('data'),
+ encoder=DjangoJSONEncoder,
null=True,
- blank=True
+ blank=True,
)
error = models.TextField(
verbose_name=_('error'),
diff --git a/netbox/core/plugins.py b/netbox/core/plugins.py
index 1381fd6d8..0a5bd8fe3 100644
--- a/netbox/core/plugins.py
+++ b/netbox/core/plugins.py
@@ -49,6 +49,7 @@ class Plugin:
The representation of a NetBox plugin in the catalog API.
"""
id: str = ''
+ icon_url: str = ''
status: str = ''
title_short: str = ''
title_long: str = ''
@@ -210,6 +211,7 @@ def get_catalog_plugins():
# Populate plugin data
plugins[data['config_name']] = Plugin(
id=data['id'],
+ icon_url=data['icon'],
status=data['status'],
title_short=data['title_short'],
title_long=data['title_long'],
diff --git a/netbox/core/signals.py b/netbox/core/signals.py
index bdaa60f97..4b537b2d4 100644
--- a/netbox/core/signals.py
+++ b/netbox/core/signals.py
@@ -2,7 +2,7 @@ import logging
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
-from django.db.models.fields.reverse_related import ManyToManyRel
+from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _
@@ -145,8 +145,10 @@ def handle_deleted_object(sender, instance, **kwargs):
# instance being deleted, and explicitly call .remove() on the remote M2M field to delete
# the association. This triggers an m2m_changed signal with the `post_remove` action type
# for the forward direction of the relationship, ensuring that the change is recorded.
+ # Similarly, for many-to-one relationships, we set the value on the related object to None
+ # and save it to trigger a change record on that object.
for relation in instance._meta.related_objects:
- if type(relation) is not ManyToManyRel:
+ if type(relation) not in [ManyToManyRel, ManyToOneRel]:
continue
related_model = relation.related_model
related_field_name = relation.remote_field.name
@@ -156,7 +158,11 @@ def handle_deleted_object(sender, instance, **kwargs):
continue
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
obj.snapshot() # Ensure the change record includes the "before" state
- getattr(obj, related_field_name).remove(instance)
+ if type(relation) is ManyToManyRel:
+ getattr(obj, related_field_name).remove(instance)
+ elif type(relation) is ManyToOneRel and relation.field.null is True:
+ setattr(obj, related_field_name, None)
+ obj.save()
# Enqueue the object for event processing
queue = events_queue.get()
diff --git a/netbox/core/tables/plugins.py b/netbox/core/tables/plugins.py
index 20bd0eee6..e1b80af42 100644
--- a/netbox/core/tables/plugins.py
+++ b/netbox/core/tables/plugins.py
@@ -12,6 +12,12 @@ __all__ = (
)
+PLUGIN_NAME_TEMPLATE = """
+
+{{ record.title_long }}
+"""
+
+
class PluginVersionTable(BaseTable):
version = tables.Column(
verbose_name=_('Version')
@@ -42,8 +48,9 @@ class PluginVersionTable(BaseTable):
class CatalogPluginTable(BaseTable):
- title_long = tables.Column(
- verbose_name=_('Name'),
+ title_long = columns.TemplateColumn(
+ template_code=PLUGIN_NAME_TEMPLATE,
+ verbose_name=_('Name')
)
author = tables.Column(
accessor=tables.A('author__name'),
diff --git a/netbox/core/views.py b/netbox/core/views.py
index 25d0f697b..1264c6c1b 100644
--- a/netbox/core/views.py
+++ b/netbox/core/views.py
@@ -103,7 +103,7 @@ class DataSourceDeleteView(generic.ObjectDeleteView):
queryset = DataSource.objects.all()
-@register_model_view(DataSource, 'bulk_import', detail=False)
+@register_model_view(DataSource, 'bulk_import', path='import', detail=False)
class DataSourceBulkImportView(generic.BulkImportView):
queryset = DataSource.objects.all()
model_form = forms.DataSourceImportForm
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index af6c0e276..a31cf136d 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -1390,10 +1390,75 @@ class ModuleFilterSet(NetBoxModelFilterSet):
lookup_expr='in',
label=_('Module bay (ID)'),
)
+ region_id = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='device__site__region',
+ lookup_expr='in',
+ label=_('Region (ID)'),
+ )
+ region = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='device__site__region',
+ lookup_expr='in',
+ to_field_name='slug',
+ label=_('Region (slug)'),
+ )
+ site_group_id = TreeNodeMultipleChoiceFilter(
+ queryset=SiteGroup.objects.all(),
+ field_name='device__site__group',
+ lookup_expr='in',
+ label=_('Site group (ID)'),
+ )
+ site_group = TreeNodeMultipleChoiceFilter(
+ queryset=SiteGroup.objects.all(),
+ field_name='device__site__group',
+ lookup_expr='in',
+ to_field_name='slug',
+ label=_('Site group (slug)'),
+ )
+ site_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__site',
+ queryset=Site.objects.all(),
+ label=_('Site (ID)'),
+ )
+ site = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__site__slug',
+ queryset=Site.objects.all(),
+ to_field_name='slug',
+ label=_('Site name (slug)'),
+ )
+ location_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__location',
+ queryset=Location.objects.all(),
+ label=_('Location (ID)'),
+ )
+ location = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__location__slug',
+ queryset=Location.objects.all(),
+ to_field_name='slug',
+ label=_('Location (slug)'),
+ )
+ rack_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__rack',
+ queryset=Rack.objects.all(),
+ label=_('Rack (ID)'),
+ )
+ rack = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__rack__name',
+ queryset=Rack.objects.all(),
+ to_field_name='name',
+ label=_('Rack (name)'),
+ )
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
label=_('Device (ID)'),
)
+ device = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__name',
+ queryset=Device.objects.all(),
+ to_field_name='name',
+ label=_('Device (name)'),
+ )
status = django_filters.MultipleChoiceFilter(
choices=ModuleStatusChoices,
null_value=None
diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py
index 0ea3aee63..efcaf4903 100644
--- a/netbox/dcim/forms/bulk_create.py
+++ b/netbox/dcim/forms/bulk_create.py
@@ -121,11 +121,11 @@ class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
class InventoryItemBulkCreateForm(
- form_from_model(InventoryItem, ['role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
+ form_from_model(InventoryItem, ['status', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
DeviceBulkAddComponentForm
):
model = InventoryItem
field_order = (
- 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
+ 'name', 'label', 'status', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags',
)
diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py
index 23109f66b..a3a781be5 100644
--- a/netbox/dcim/forms/common.py
+++ b/netbox/dcim/forms/common.py
@@ -41,7 +41,6 @@ class InterfaceCommonForm(forms.Form):
def clean(self):
super().clean()
-
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
if 'tagged_vlans' in self.fields.keys():
tagged_vlans = self.cleaned_data.get('tagged_vlans') if self.is_bound else \
@@ -61,6 +60,12 @@ class InterfaceCommonForm(forms.Form):
"or they must be global"
).format(vlans=', '.join(invalid_vlans))
})
+ # Validate mode change
+ if self.instance.pk and (self.instance.mode != self.cleaned_data['mode']):
+ if 'untagged_vlan' not in self.cleaned_data and self.instance.untagged_vlan is not None:
+ self.instance.untagged_vlan = None
+ if 'tagged_vlans' not in self.cleaned_data and self.instance.tagged_vlans is not None:
+ self.instance.tagged_vlans.clear()
class ModuleCommonForm(forms.Form):
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index ae042993e..813a578d6 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -959,8 +959,56 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
model = Module
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
+ FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')),
)
+ device_id = DynamicModelMultipleChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ query_params={
+ 'site_id': '$site_id',
+ 'location_id': '$location_id',
+ 'rack_id': '$rack_id',
+ },
+ label=_('Device')
+ )
+ region_id = DynamicModelMultipleChoiceField(
+ queryset=Region.objects.all(),
+ required=False,
+ label=_('Region')
+ )
+ site_group_id = DynamicModelMultipleChoiceField(
+ queryset=SiteGroup.objects.all(),
+ required=False,
+ label=_('Site group')
+ )
+ site_id = DynamicModelMultipleChoiceField(
+ queryset=Site.objects.all(),
+ required=False,
+ query_params={
+ 'region_id': '$region_id',
+ 'group_id': '$site_group_id',
+ },
+ label=_('Site')
+ )
+ location_id = DynamicModelMultipleChoiceField(
+ queryset=Location.objects.all(),
+ required=False,
+ query_params={
+ 'site_id': '$site_id',
+ },
+ label=_('Location')
+ )
+ rack_id = DynamicModelMultipleChoiceField(
+ queryset=Rack.objects.all(),
+ required=False,
+ label=_('Rack'),
+ null_option='None',
+ query_params={
+ 'site_id': '$site_id',
+ 'location_id': '$location_id',
+ }
+ )
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index ac9b0ea9a..d8cff372f 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -993,7 +993,7 @@ class ComponentTemplateForm(forms.ModelForm):
class ModularComponentTemplateForm(ComponentTemplateForm):
device_type = DynamicModelChoiceField(
label=_('Device type'),
- queryset=DeviceType.objects.all().all(),
+ queryset=DeviceType.objects.all(),
required=False,
context={
'parent': 'manufacturer',
@@ -1008,6 +1008,16 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
}
)
+ fieldsets = (
+ FieldSet(
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'type', 'description'
+ ),
+ )
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -1024,10 +1034,6 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
class ConsolePortTemplateForm(ModularComponentTemplateForm):
- fieldsets = (
- FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'),
- )
-
class Meta:
model = ConsolePortTemplate
fields = [
@@ -1036,10 +1042,6 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm):
class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
- fieldsets = (
- FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'),
- )
-
class Meta:
model = ConsoleServerPortTemplate
fields = [
@@ -1050,7 +1052,11 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
class PowerPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet(
- 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
),
)
@@ -1072,7 +1078,13 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
)
fieldsets = (
- FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'),
+ FieldSet(
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
+ ),
)
class Meta:
@@ -1095,7 +1107,11 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet(
- 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge',
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge',
),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('rf_role', name=_('Wireless')),
@@ -1122,8 +1138,11 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet(
- 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
- 'description',
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
),
)
@@ -1137,7 +1156,13 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
class RearPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
- FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description'),
+ FieldSet(
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'type', 'color', 'positions', 'description',
+ ),
)
class Meta:
@@ -1149,7 +1174,13 @@ class RearPortTemplateForm(ModularComponentTemplateForm):
class ModuleBayTemplateForm(ModularComponentTemplateForm):
fieldsets = (
- FieldSet('device_type', 'module_type', 'name', 'label', 'position', 'description'),
+ FieldSet(
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'position', 'description',
+ ),
)
class Meta:
diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py
index 1e898a386..bcf91c547 100644
--- a/netbox/dcim/forms/object_create.py
+++ b/netbox/dcim/forms/object_create.py
@@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import *
from netbox.forms import NetBoxModelForm
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
-from utilities.forms.rendering import FieldSet
+from utilities.forms.rendering import FieldSet, TabbedGroups
from utilities.forms.widgets import APISelect
from . import model_forms
@@ -118,7 +118,13 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position
fieldsets = (
- FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description'),
+ FieldSet(
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'type', 'color', 'rear_port', 'description',
+ ),
)
class Meta(model_forms.FrontPortTemplateForm.Meta):
diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py
index 821f91402..3f2cc3ef6 100644
--- a/netbox/dcim/forms/object_import.py
+++ b/netbox/dcim/forms/object_import.py
@@ -159,7 +159,7 @@ class ModuleBayTemplateImportForm(forms.ModelForm):
class Meta:
model = ModuleBayTemplate
fields = [
- 'device_type', 'name', 'label', 'position', 'description',
+ 'device_type', 'module_type', 'name', 'label', 'position', 'description',
]
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index f70e0225e..0a28d5acb 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -3,7 +3,6 @@ import itertools
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
from django.db import models
-from django.db.models import Sum
from django.dispatch import Signal
from django.utils.translation import gettext_lazy as _
@@ -774,9 +773,28 @@ class CablePath(models.Model):
Return a tuple containing the sum of the length of each cable in the path
and a flag indicating whether the length is definitive.
"""
+ cable_ct = ObjectType.objects.get_for_model(Cable).pk
+
+ # Pre-cache cable lengths by ID
cable_ids = self.get_cable_ids()
- cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False)
- total_length = cables.aggregate(total=Sum('_abs_length'))['total']
+ cables = {
+ cable['pk']: cable['_abs_length']
+ for cable in Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False).values('pk', '_abs_length')
+ }
+
+ # Iterate through each set of nodes in the path. For cables, add the length of the longest cable to the total
+ # length of the path.
+ total_length = 0
+ for node_set in self.path:
+ hop_length = 0
+ for node in node_set:
+ ct, pk = decompile_path_node(node)
+ if ct != cable_ct:
+ break # Not a cable
+ if pk in cables and cables[pk] > hop_length:
+ hop_length = cables[pk]
+ total_length += hop_length
+
is_definitive = len(cables) == len(cable_ids)
return total_length, is_definitive
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index 390d9ae9e..b15cd8b34 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -732,3 +732,8 @@ class RackReservation(PrimaryModel):
@property
def unit_list(self):
return array_to_string(self.units)
+
+ def to_objectchange(self, action):
+ objectchange = super().to_objectchange(action)
+ objectchange.related_object = self.rack
+ return objectchange
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index 59e9f647a..ba8d4203d 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -2859,15 +2859,23 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
addresses = IPAddress.objects.filter(address__family=4)
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'primary_ip4': [str(addresses[0].address), str(addresses[1].address)]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip4_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+ params = {'primary_ip4': [str(addresses[2].address)]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_primary_ip6(self):
addresses = IPAddress.objects.filter(address__family=6)
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'primary_ip6': [str(addresses[0].address), str(addresses[1].address)]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+ params = {'primary_ip6': [str(addresses[2].address)]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_virtual_chassis_id(self):
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
@@ -2961,6 +2969,29 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
+ regions = (
+ Region(name='Region 1', slug='region-1'),
+ Region(name='Region 2', slug='region-2'),
+ Region(name='Region 3', slug='region-3'),
+ )
+ for region in regions:
+ region.save()
+
+ groups = (
+ SiteGroup(name='Site Group 1', slug='site-group-1'),
+ SiteGroup(name='Site Group 2', slug='site-group-2'),
+ SiteGroup(name='Site Group 3', slug='site-group-3'),
+ )
+ for group in groups:
+ group.save()
+
+ sites = Site.objects.bulk_create((
+ Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+ Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+ Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
+ Site(name='Site X', slug='site-x'),
+ ))
+
manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
@@ -2968,11 +2999,66 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Manufacturer.objects.bulk_create(manufacturers)
- devices = (
- create_test_device('Test Device 1'),
- create_test_device('Test Device 2'),
- create_test_device('Test Device 3'),
+ device_types = (
+ DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
+ DeviceType(manufacturer=manufacturers[1], model='Device Type 2', slug='device-type-2'),
+ DeviceType(manufacturer=manufacturers[2], model='Device Type 3', slug='device-type-3'),
)
+ DeviceType.objects.bulk_create(device_types)
+
+ roles = (
+ DeviceRole(name='Device Role 1', slug='device-role-1'),
+ DeviceRole(name='Device Role 2', slug='device-role-2'),
+ DeviceRole(name='Device Role 3', slug='device-role-3'),
+ )
+ for role in roles:
+ role.save()
+
+ locations = (
+ Location(name='Location 1', slug='location-1', site=sites[0]),
+ Location(name='Location 2', slug='location-2', site=sites[1]),
+ Location(name='Location 3', slug='location-3', site=sites[2]),
+ )
+ for location in locations:
+ location.save()
+
+ racks = (
+ Rack(name='Rack 1', site=sites[0]),
+ Rack(name='Rack 2', site=sites[1]),
+ Rack(name='Rack 3', site=sites[2]),
+ )
+ Rack.objects.bulk_create(racks)
+
+ devices = (
+ Device(
+ name='Test Device 1',
+ device_type=device_types[0],
+ role=roles[0],
+ site=sites[0],
+ location=locations[0],
+ rack=racks[0],
+ status='active',
+ ),
+ Device(
+ name='Test Device 2',
+ device_type=device_types[1],
+ role=roles[1],
+ site=sites[1],
+ location=locations[1],
+ rack=racks[1],
+ status='planned',
+ ),
+ Device(
+ name='Test Device 3',
+ device_type=device_types[2],
+ role=roles[2],
+ site=sites[2],
+ location=locations[2],
+ rack=racks[2],
+ status='offline',
+ ),
+ )
+ Device.objects.bulk_create(devices)
module_types = (
ModuleType(manufacturer=manufacturers[0], model='Module Type 1'),
@@ -3120,6 +3206,41 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'asset_tag': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_region(self):
+ regions = Region.objects.all()[:2]
+ params = {'region_id': [regions[0].pk, regions[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+ params = {'region': [regions[0].slug, regions[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+
+ def test_site_group(self):
+ site_groups = SiteGroup.objects.all()[:2]
+ params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+ params = {'site_group': [site_groups[0].slug, site_groups[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]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+ params = {'site': [sites[0].slug, sites[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+
+ def test_location(self):
+ locations = Location.objects.all()[:2]
+ params = {'location_id': [locations[0].pk, locations[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+ params = {'location': [locations[0].slug, locations[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+
+ def test_rack(self):
+ racks = Rack.objects.all()[:2]
+ params = {'rack_id': [racks[0].pk, racks[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+ params = {'rack': [racks[0].name, racks[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+
class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsolePort.objects.all()
@@ -6722,15 +6843,23 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
addresses = IPAddress.objects.filter(address__family=4)
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'primary_ip4': [str(addresses[0].address), str(addresses[1].address)]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip4_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+ params = {'primary_ip4': [str(addresses[2].address)]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_primary_ip6(self):
addresses = IPAddress.objects.filter(address__family=6)
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'primary_ip6': [str(addresses[0].address), str(addresses[1].address)]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+ params = {'primary_ip6': [str(addresses[2].address)]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
index 3c43d1834..69192f0a1 100644
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -1217,6 +1217,13 @@ front-ports:
- name: Front Port 3
type: 8p8c
rear_port: Rear Port 3
+module-bays:
+ - name: Module Bay 1
+ position: 1
+ - name: Module Bay 2
+ position: 2
+ - name: Module Bay 3
+ position: 3
"""
# Create the manufacturer
@@ -1234,6 +1241,7 @@ front-ports:
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
+ 'dcim.add_modulebaytemplate',
)
form_data = {
@@ -1288,6 +1296,11 @@ front-ports:
self.assertEqual(fp1.rear_port, rp1)
self.assertEqual(fp1.rear_port_position, 1)
+ self.assertEqual(module_type.modulebaytemplates.count(), 3)
+ mb1 = ModuleBayTemplate.objects.first()
+ self.assertEqual(mb1.name, 'Module Bay 1')
+ self.assertEqual(mb1.position, '1')
+
def test_export_objects(self):
url = reverse('dcim:moduletype_list')
self.add_permissions('dcim.view_moduletype')
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 970bee4d4..faa9f6bb6 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -22,6 +22,7 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
from utilities.query import count_related
from utilities.query_functions import CollateAsChar
+from utilities.request import safe_for_redirect
from utilities.views import (
GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
)
@@ -279,7 +280,7 @@ class RegionDeleteView(generic.ObjectDeleteView):
queryset = Region.objects.all()
-@register_model_view(Region, 'bulk_import', detail=False)
+@register_model_view(Region, 'bulk_import', path='import', detail=False)
class RegionBulkImportView(generic.BulkImportView):
queryset = Region.objects.all()
model_form = forms.RegionImportForm
@@ -405,7 +406,7 @@ class SiteGroupDeleteView(generic.ObjectDeleteView):
queryset = SiteGroup.objects.all()
-@register_model_view(SiteGroup, 'bulk_import', detail=False)
+@register_model_view(SiteGroup, 'bulk_import', path='import', detail=False)
class SiteGroupBulkImportView(generic.BulkImportView):
queryset = SiteGroup.objects.all()
model_form = forms.SiteGroupImportForm
@@ -496,7 +497,7 @@ class SiteDeleteView(generic.ObjectDeleteView):
queryset = Site.objects.all()
-@register_model_view(Site, 'bulk_import', detail=False)
+@register_model_view(Site, 'bulk_import', path='import', detail=False)
class SiteBulkImportView(generic.BulkImportView):
queryset = Site.objects.all()
model_form = forms.SiteImportForm
@@ -594,7 +595,7 @@ class LocationDeleteView(generic.ObjectDeleteView):
queryset = Location.objects.all()
-@register_model_view(Location, 'bulk_import', detail=False)
+@register_model_view(Location, 'bulk_import', path='import', detail=False)
class LocationBulkImportView(generic.BulkImportView):
queryset = Location.objects.all()
model_form = forms.LocationImportForm
@@ -663,7 +664,7 @@ class RackRoleDeleteView(generic.ObjectDeleteView):
queryset = RackRole.objects.all()
-@register_model_view(RackRole, 'bulk_import', detail=False)
+@register_model_view(RackRole, 'bulk_import', path='import', detail=False)
class RackRoleBulkImportView(generic.BulkImportView):
queryset = RackRole.objects.all()
model_form = forms.RackRoleImportForm
@@ -724,7 +725,7 @@ class RackTypeDeleteView(generic.ObjectDeleteView):
queryset = RackType.objects.all()
-@register_model_view(RackType, 'bulk_import', detail=False)
+@register_model_view(RackType, 'bulk_import', path='import', detail=False)
class RackTypeBulkImportView(generic.BulkImportView):
queryset = RackType.objects.all()
model_form = forms.RackTypeImportForm
@@ -903,7 +904,7 @@ class RackDeleteView(generic.ObjectDeleteView):
queryset = Rack.objects.all()
-@register_model_view(Rack, 'bulk_import', detail=False)
+@register_model_view(Rack, 'bulk_import', path='import', detail=False)
class RackBulkImportView(generic.BulkImportView):
queryset = Rack.objects.all()
model_form = forms.RackImportForm
@@ -960,7 +961,7 @@ class RackReservationDeleteView(generic.ObjectDeleteView):
queryset = RackReservation.objects.all()
-@register_model_view(RackReservation, 'bulk_import', detail=False)
+@register_model_view(RackReservation, 'bulk_import', path='import', detail=False)
class RackReservationImportView(generic.BulkImportView):
queryset = RackReservation.objects.all()
model_form = forms.RackReservationImportForm
@@ -1031,7 +1032,7 @@ class ManufacturerDeleteView(generic.ObjectDeleteView):
queryset = Manufacturer.objects.all()
-@register_model_view(Manufacturer, 'bulk_import', detail=False)
+@register_model_view(Manufacturer, 'bulk_import', path='import', detail=False)
class ManufacturerBulkImportView(generic.BulkImportView):
queryset = Manufacturer.objects.all()
model_form = forms.ManufacturerImportForm
@@ -1252,7 +1253,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
)
-@register_model_view(DeviceType, 'bulk_import', detail=False)
+@register_model_view(DeviceType, 'bulk_import', path='import', detail=False)
class DeviceTypeImportView(generic.BulkImportView):
additional_permissions = [
'dcim.add_devicetype',
@@ -1522,7 +1523,7 @@ class ModuleTypeModuleBaysView(ModuleTypeComponentsView):
)
-@register_model_view(ModuleType, 'bulk_import', detail=False)
+@register_model_view(ModuleType, 'bulk_import', path='import', detail=False)
class ModuleTypeImportView(generic.BulkImportView):
additional_permissions = [
'dcim.add_moduletype',
@@ -1533,6 +1534,7 @@ class ModuleTypeImportView(generic.BulkImportView):
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
+ 'dcim.add_modulebaytemplate',
]
queryset = ModuleType.objects.all()
model_form = forms.ModuleTypeImportForm
@@ -1544,6 +1546,7 @@ class ModuleTypeImportView(generic.BulkImportView):
'interfaces': forms.InterfaceTemplateImportForm,
'rear-ports': forms.RearPortTemplateImportForm,
'front-ports': forms.FrontPortTemplateImportForm,
+ 'module-bays': forms.ModuleBayTemplateImportForm,
}
def prep_related_object_data(self, parent, data):
@@ -2018,7 +2021,7 @@ class DeviceRoleDeleteView(generic.ObjectDeleteView):
queryset = DeviceRole.objects.all()
-@register_model_view(DeviceRole, 'bulk_import', detail=False)
+@register_model_view(DeviceRole, 'bulk_import', path='import', detail=False)
class DeviceRoleBulkImportView(generic.BulkImportView):
queryset = DeviceRole.objects.all()
model_form = forms.DeviceRoleImportForm
@@ -2082,7 +2085,7 @@ class PlatformDeleteView(generic.ObjectDeleteView):
queryset = Platform.objects.all()
-@register_model_view(Platform, 'bulk_import', detail=False)
+@register_model_view(Platform, 'bulk_import', path='import', detail=False)
class PlatformBulkImportView(generic.BulkImportView):
queryset = Platform.objects.all()
model_form = forms.PlatformImportForm
@@ -2365,7 +2368,7 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
return self.child_model.objects.restrict(request.user, 'view').filter(cluster=parent.cluster, device=parent)
-@register_model_view(Device, 'bulk_import', detail=False)
+@register_model_view(Device, 'bulk_import', path='import', detail=False)
class DeviceBulkImportView(generic.BulkImportView):
queryset = Device.objects.all()
model_form = forms.DeviceImportForm
@@ -2438,7 +2441,7 @@ class ModuleDeleteView(generic.ObjectDeleteView):
queryset = Module.objects.all()
-@register_model_view(Module, 'bulk_import', detail=False)
+@register_model_view(Module, 'bulk_import', path='import', detail=False)
class ModuleBulkImportView(generic.BulkImportView):
queryset = Module.objects.all()
model_form = forms.ModuleImportForm
@@ -2499,7 +2502,7 @@ class ConsolePortDeleteView(generic.ObjectDeleteView):
queryset = ConsolePort.objects.all()
-@register_model_view(ConsolePort, 'bulk_import', detail=False)
+@register_model_view(ConsolePort, 'bulk_import', path='import', detail=False)
class ConsolePortBulkImportView(generic.BulkImportView):
queryset = ConsolePort.objects.all()
model_form = forms.ConsolePortImportForm
@@ -2574,7 +2577,7 @@ class ConsoleServerPortDeleteView(generic.ObjectDeleteView):
queryset = ConsoleServerPort.objects.all()
-@register_model_view(ConsoleServerPort, 'bulk_import', detail=False)
+@register_model_view(ConsoleServerPort, 'bulk_import', path='import', detail=False)
class ConsoleServerPortBulkImportView(generic.BulkImportView):
queryset = ConsoleServerPort.objects.all()
model_form = forms.ConsoleServerPortImportForm
@@ -2649,7 +2652,7 @@ class PowerPortDeleteView(generic.ObjectDeleteView):
queryset = PowerPort.objects.all()
-@register_model_view(PowerPort, 'bulk_import', detail=False)
+@register_model_view(PowerPort, 'bulk_import', path='import', detail=False)
class PowerPortBulkImportView(generic.BulkImportView):
queryset = PowerPort.objects.all()
model_form = forms.PowerPortImportForm
@@ -2724,7 +2727,7 @@ class PowerOutletDeleteView(generic.ObjectDeleteView):
queryset = PowerOutlet.objects.all()
-@register_model_view(PowerOutlet, 'bulk_import', detail=False)
+@register_model_view(PowerOutlet, 'bulk_import', path='import', detail=False)
class PowerOutletBulkImportView(generic.BulkImportView):
queryset = PowerOutlet.objects.all()
model_form = forms.PowerOutletImportForm
@@ -2856,7 +2859,7 @@ class InterfaceDeleteView(generic.ObjectDeleteView):
queryset = Interface.objects.all()
-@register_model_view(Interface, 'bulk_import', detail=False)
+@register_model_view(Interface, 'bulk_import', path='import', detail=False)
class InterfaceBulkImportView(generic.BulkImportView):
queryset = Interface.objects.all()
model_form = forms.InterfaceImportForm
@@ -2942,7 +2945,7 @@ class FrontPortDeleteView(generic.ObjectDeleteView):
queryset = FrontPort.objects.all()
-@register_model_view(FrontPort, 'bulk_import', detail=False)
+@register_model_view(FrontPort, 'bulk_import', path='import', detail=False)
class FrontPortBulkImportView(generic.BulkImportView):
queryset = FrontPort.objects.all()
model_form = forms.FrontPortImportForm
@@ -3017,7 +3020,7 @@ class RearPortDeleteView(generic.ObjectDeleteView):
queryset = RearPort.objects.all()
-@register_model_view(RearPort, 'bulk_import', detail=False)
+@register_model_view(RearPort, 'bulk_import', path='import', detail=False)
class RearPortBulkImportView(generic.BulkImportView):
queryset = RearPort.objects.all()
model_form = forms.RearPortImportForm
@@ -3092,7 +3095,7 @@ class ModuleBayDeleteView(generic.ObjectDeleteView):
queryset = ModuleBay.objects.all()
-@register_model_view(ModuleBay, 'bulk_import', detail=False)
+@register_model_view(ModuleBay, 'bulk_import', path='import', detail=False)
class ModuleBayBulkImportView(generic.BulkImportView):
queryset = ModuleBay.objects.all()
model_form = forms.ModuleBayImportForm
@@ -3239,7 +3242,7 @@ class DeviceBayDepopulateView(generic.ObjectEditView):
})
-@register_model_view(DeviceBay, 'bulk_import', detail=False)
+@register_model_view(DeviceBay, 'bulk_import', path='import', detail=False)
class DeviceBayBulkImportView(generic.BulkImportView):
queryset = DeviceBay.objects.all()
model_form = forms.DeviceBayImportForm
@@ -3305,7 +3308,7 @@ class InventoryItemDeleteView(generic.ObjectDeleteView):
queryset = InventoryItem.objects.all()
-@register_model_view(InventoryItem, 'bulk_import', detail=False)
+@register_model_view(InventoryItem, 'bulk_import', path='import', detail=False)
class InventoryItemBulkImportView(generic.BulkImportView):
queryset = InventoryItem.objects.all()
model_form = forms.InventoryItemImportForm
@@ -3386,7 +3389,7 @@ class InventoryItemRoleDeleteView(generic.ObjectDeleteView):
queryset = InventoryItemRole.objects.all()
-@register_model_view(InventoryItemRole, 'bulk_import', detail=False)
+@register_model_view(InventoryItemRole, 'bulk_import', path='import', detail=False)
class InventoryItemRoleBulkImportView(generic.BulkImportView):
queryset = InventoryItemRole.objects.all()
model_form = forms.InventoryItemRoleImportForm
@@ -3582,7 +3585,7 @@ class CableDeleteView(generic.ObjectDeleteView):
queryset = Cable.objects.all()
-@register_model_view(Cable, 'bulk_import', detail=False)
+@register_model_view(Cable, 'bulk_import', path='import', detail=False)
class CableBulkImportView(generic.BulkImportView):
queryset = Cable.objects.all()
model_form = forms.CableImportForm
@@ -3811,7 +3814,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
)
))
- if '_addanother' in request.POST:
+ if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
return redirect(request.get_full_path())
return redirect(self.get_return_url(request, device))
@@ -3883,7 +3886,7 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
})
-@register_model_view(VirtualChassis, 'bulk_import', detail=False)
+@register_model_view(VirtualChassis, 'bulk_import', path='import', detail=False)
class VirtualChassisBulkImportView(generic.BulkImportView):
queryset = VirtualChassis.objects.all()
model_form = forms.VirtualChassisImportForm
@@ -3940,7 +3943,7 @@ class PowerPanelDeleteView(generic.ObjectDeleteView):
queryset = PowerPanel.objects.all()
-@register_model_view(PowerPanel, 'bulk_import', detail=False)
+@register_model_view(PowerPanel, 'bulk_import', path='import', detail=False)
class PowerPanelBulkImportView(generic.BulkImportView):
queryset = PowerPanel.objects.all()
model_form = forms.PowerPanelImportForm
@@ -3992,7 +3995,7 @@ class PowerFeedDeleteView(generic.ObjectDeleteView):
queryset = PowerFeed.objects.all()
-@register_model_view(PowerFeed, 'bulk_import', detail=False)
+@register_model_view(PowerFeed, 'bulk_import', path='import', detail=False)
class PowerFeedBulkImportView(generic.BulkImportView):
queryset = PowerFeed.objects.all()
model_form = forms.PowerFeedImportForm
@@ -4064,7 +4067,7 @@ class VirtualDeviceContextDeleteView(generic.ObjectDeleteView):
queryset = VirtualDeviceContext.objects.all()
-@register_model_view(VirtualDeviceContext, 'bulk_import', detail=False)
+@register_model_view(VirtualDeviceContext, 'bulk_import', path='import', detail=False)
class VirtualDeviceContextBulkImportView(generic.BulkImportView):
queryset = VirtualDeviceContext.objects.all()
model_form = forms.VirtualDeviceContextImportForm
@@ -4114,7 +4117,7 @@ class MACAddressDeleteView(generic.ObjectDeleteView):
queryset = MACAddress.objects.all()
-@register_model_view(MACAddress, 'bulk_import', detail=False)
+@register_model_view(MACAddress, 'bulk_import', path='import', detail=False)
class MACAddressBulkImportView(generic.BulkImportView):
queryset = MACAddress.objects.all()
model_form = forms.MACAddressImportForm
diff --git a/netbox/extras/migrations/0128_tableconfig.py b/netbox/extras/migrations/0128_tableconfig.py
index ed29d1e16..e6d45199d 100644
--- a/netbox/extras/migrations/0128_tableconfig.py
+++ b/netbox/extras/migrations/0128_tableconfig.py
@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('core', '0014_remove_redundant_indexes'),
+ ('core', '0015_remove_redundant_indexes'),
('extras', '0127_configtemplate_as_attachment_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
diff --git a/netbox/extras/models/scripts.py b/netbox/extras/models/scripts.py
index 9cf2c2f9a..91732f8f0 100644
--- a/netbox/extras/models/scripts.py
+++ b/netbox/extras/models/scripts.py
@@ -123,7 +123,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
ordered = [
script_objects.pop(sc) for sc in self.module_scripts.keys() if sc in script_objects
]
- ordered.extend(script_objects.items())
+ ordered.extend(script_objects.values())
return ordered
@property
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index a6ed0c945..e6f488fde 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -5,6 +5,8 @@ from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from extras.models import *
+from core.tables import JobTable
+from core.models import Job
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.events import get_event_text
from netbox.tables import BaseTable, NetBoxTable, columns
@@ -26,6 +28,7 @@ __all__ = (
'SavedFilterTable',
'ReportResultsTable',
'ScriptResultsTable',
+ 'ScriptJobTable',
'SubscriptionTable',
'TableConfigTable',
'TaggedItemTable',
@@ -693,6 +696,23 @@ class ScriptResultsTable(BaseTable):
return format_html("{}", value, value)
+class ScriptJobTable(JobTable):
+ id = tables.TemplateColumn(
+ template_code="""{{ record.id }}""",
+ verbose_name=_('ID'),
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = Job
+ fields = (
+ 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
+ 'completed', 'user', 'error', 'job_id',
+ )
+ default_columns = (
+ 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
+ )
+
+
class ReportResultsTable(BaseTable):
index = tables.Column(
verbose_name=_('Line')
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 91d62c0d4..ae9337779 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -14,7 +14,6 @@ from jinja2.exceptions import TemplateError
from core.choices import ManagedFileRootPathChoices
from core.models import Job
-from core.tables import JobTable
from dcim.models import Device, DeviceRole, Platform
from extras.choices import LogLevelChoices
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
@@ -36,7 +35,7 @@ from virtualization.models import VirtualMachine
from . import filtersets, forms, tables
from .constants import LOG_LEVEL_RANK
from .models import *
-from .tables import ReportResultsTable, ScriptResultsTable
+from .tables import ReportResultsTable, ScriptResultsTable, ScriptJobTable
#
@@ -83,7 +82,7 @@ class CustomFieldDeleteView(generic.ObjectDeleteView):
queryset = CustomField.objects.select_related('choice_set')
-@register_model_view(CustomField, 'bulk_import', detail=False)
+@register_model_view(CustomField, 'bulk_import', path='import', detail=False)
class CustomFieldBulkImportView(generic.BulkImportView):
queryset = CustomField.objects.select_related('choice_set')
model_form = forms.CustomFieldImportForm
@@ -152,7 +151,7 @@ class CustomFieldChoiceSetDeleteView(generic.ObjectDeleteView):
queryset = CustomFieldChoiceSet.objects.all()
-@register_model_view(CustomFieldChoiceSet, 'bulk_import', detail=False)
+@register_model_view(CustomFieldChoiceSet, 'bulk_import', path='import', detail=False)
class CustomFieldChoiceSetBulkImportView(generic.BulkImportView):
queryset = CustomFieldChoiceSet.objects.all()
model_form = forms.CustomFieldChoiceSetImportForm
@@ -202,7 +201,7 @@ class CustomLinkDeleteView(generic.ObjectDeleteView):
queryset = CustomLink.objects.all()
-@register_model_view(CustomLink, 'bulk_import', detail=False)
+@register_model_view(CustomLink, 'bulk_import', path='import', detail=False)
class CustomLinkBulkImportView(generic.BulkImportView):
queryset = CustomLink.objects.all()
model_form = forms.CustomLinkImportForm
@@ -257,7 +256,7 @@ class ExportTemplateDeleteView(generic.ObjectDeleteView):
queryset = ExportTemplate.objects.all()
-@register_model_view(ExportTemplate, 'bulk_import', detail=False)
+@register_model_view(ExportTemplate, 'bulk_import', path='import', detail=False)
class ExportTemplateBulkImportView(generic.BulkImportView):
queryset = ExportTemplate.objects.all()
model_form = forms.ExportTemplateImportForm
@@ -317,7 +316,7 @@ class SavedFilterDeleteView(SharedObjectViewMixin, generic.ObjectDeleteView):
queryset = SavedFilter.objects.all()
-@register_model_view(SavedFilter, 'bulk_import', detail=False)
+@register_model_view(SavedFilter, 'bulk_import', path='import', detail=False)
class SavedFilterBulkImportView(SharedObjectViewMixin, generic.BulkImportView):
queryset = SavedFilter.objects.all()
model_form = forms.SavedFilterImportForm
@@ -457,7 +456,7 @@ class NotificationGroupDeleteView(generic.ObjectDeleteView):
queryset = NotificationGroup.objects.all()
-@register_model_view(NotificationGroup, 'bulk_import', detail=False)
+@register_model_view(NotificationGroup, 'bulk_import', path='import', detail=False)
class NotificationGroupBulkImportView(generic.BulkImportView):
queryset = NotificationGroup.objects.all()
model_form = forms.NotificationGroupImportForm
@@ -603,7 +602,7 @@ class WebhookDeleteView(generic.ObjectDeleteView):
queryset = Webhook.objects.all()
-@register_model_view(Webhook, 'bulk_import', detail=False)
+@register_model_view(Webhook, 'bulk_import', path='import', detail=False)
class WebhookBulkImportView(generic.BulkImportView):
queryset = Webhook.objects.all()
model_form = forms.WebhookImportForm
@@ -653,7 +652,7 @@ class EventRuleDeleteView(generic.ObjectDeleteView):
queryset = EventRule.objects.all()
-@register_model_view(EventRule, 'bulk_import', detail=False)
+@register_model_view(EventRule, 'bulk_import', path='import', detail=False)
class EventRuleBulkImportView(generic.BulkImportView):
queryset = EventRule.objects.all()
model_form = forms.EventRuleImportForm
@@ -726,7 +725,7 @@ class TagDeleteView(generic.ObjectDeleteView):
queryset = Tag.objects.all()
-@register_model_view(Tag, 'bulk_import', detail=False)
+@register_model_view(Tag, 'bulk_import', path='import', detail=False)
class TagBulkImportView(generic.BulkImportView):
queryset = Tag.objects.all()
model_form = forms.TagImportForm
@@ -902,7 +901,7 @@ class ConfigTemplateDeleteView(generic.ObjectDeleteView):
queryset = ConfigTemplate.objects.all()
-@register_model_view(ConfigTemplate, 'bulk_import', detail=False)
+@register_model_view(ConfigTemplate, 'bulk_import', path='import', detail=False)
class ConfigTemplateBulkImportView(generic.BulkImportView):
queryset = ConfigTemplate.objects.all()
model_form = forms.ConfigTemplateImportForm
@@ -1081,7 +1080,7 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
return reverse(viewname, kwargs={'pk': obj.pk})
-@register_model_view(JournalEntry, 'bulk_import', detail=False)
+@register_model_view(JournalEntry, 'bulk_import', path='import', detail=False)
class JournalEntryBulkImportView(generic.BulkImportView):
queryset = JournalEntry.objects.all()
model_form = forms.JournalEntryImportForm
@@ -1393,7 +1392,7 @@ class ScriptJobsView(BaseScriptView):
def get(self, request, **kwargs):
script = self.get_object(**kwargs)
- jobs_table = JobTable(
+ jobs_table = ScriptJobTable(
data=script.jobs.all(),
orderable=False,
user=request.user
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index 087359caa..087bcc3b0 100644
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -1256,8 +1256,20 @@ class PrimaryIPFilterSet(django_filters.FilterSet):
queryset=IPAddress.objects.all(),
label=_('Primary IPv4 (ID)'),
)
+ primary_ip4 = django_filters.ModelMultipleChoiceFilter(
+ field_name='primary_ip4__address',
+ queryset=IPAddress.objects.all(),
+ to_field_name='address',
+ label=_('Primary IPv4 (address)'),
+ )
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip6',
queryset=IPAddress.objects.all(),
label=_('Primary IPv6 (ID)'),
)
+ primary_ip6 = django_filters.ModelMultipleChoiceFilter(
+ field_name='primary_ip6__address',
+ queryset=IPAddress.objects.all(),
+ to_field_name='address',
+ label=_('Primary IPv6 (address)'),
+ )
diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py
index 0612a29dc..66674a749 100644
--- a/netbox/ipam/forms/model_forms.py
+++ b/netbox/ipam/forms/model_forms.py
@@ -864,6 +864,7 @@ class ServiceCreateForm(ServiceForm):
# Fields which may be populated from a ServiceTemplate are not required
for field in ('name', 'protocol', 'ports'):
self.fields[field].required = False
+ self.fields[field].widget.is_required = False
def clean(self):
super().clean()
diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py
index 98491ad23..67c9d9414 100644
--- a/netbox/ipam/models/vlans.py
+++ b/netbox/ipam/models/vlans.py
@@ -1,4 +1,5 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField, IntegerRangeField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -6,7 +7,7 @@ from django.db import models
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.utils.translation import gettext_lazy as _
-from dcim.models import Interface
+from dcim.models import Interface, Site, SiteGroup
from ipam.choices import *
from ipam.constants import *
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
@@ -285,12 +286,20 @@ class VLAN(PrimaryModel):
super().clean()
# Validate VLAN group (if assigned)
- if self.group and self.site and self.group.scope != self.site:
- raise ValidationError(
- _(
- "VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
- ).format(group=self.group, scope=self.group.scope, site=self.site)
- )
+ if self.group and self.site and self.group.scope_type == ContentType.objects.get_for_model(Site):
+ if self.site != self.group.scope:
+ raise ValidationError(
+ _(
+ "VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
+ ).format(group=self.group, scope=self.group.scope, site=self.site)
+ )
+ if self.group and self.site and self.group.scope_type == ContentType.objects.get_for_model(SiteGroup):
+ if self.site not in self.group.scope.sites.all():
+ raise ValidationError(
+ _(
+ "The assigned site {site} is not a member of the assigned group {group} (scope: {scope})."
+ ).format(group=self.group, scope=self.group.scope, site=self.site)
+ )
# Check that the VLAN ID is permitted in the assigned group (if any)
if self.group:
diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py
index 246ca0e22..656f62d87 100644
--- a/netbox/ipam/tests/test_models.py
+++ b/netbox/ipam/tests/test_models.py
@@ -1,8 +1,10 @@
+from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from netaddr import IPNetwork, IPSet
from utilities.data import string_to_ranges
+from dcim.models import Site, SiteGroup
from ipam.choices import *
from ipam.models import *
@@ -679,3 +681,54 @@ class TestVLAN(TestCase):
)
with self.assertRaises(ValidationError):
vlan.full_clean()
+
+ def test_vlan_group_site_validation(self):
+ sitegroup = SiteGroup.objects.create(
+ name='Site Group 1',
+ slug='site-group-1',
+ )
+ sites = Site.objects.bulk_create((
+ Site(
+ name='Site 1',
+ slug='site-1',
+ ),
+ Site(
+ name='Site 2',
+ slug='site-2',
+ ),
+ ))
+ sitegroup.sites.add(sites[0])
+ vlangroups = VLANGroup.objects.bulk_create((
+ VLANGroup(
+ name='VLAN Group 1',
+ slug='vlan-group-1',
+ scope=sitegroup,
+ scope_type=ContentType.objects.get_for_model(SiteGroup),
+ ),
+ VLANGroup(
+ name='VLAN Group 2',
+ slug='vlan-group-2',
+ scope=sites[0],
+ scope_type=ContentType.objects.get_for_model(Site),
+ ),
+ VLANGroup(
+ name='VLAN Group 2',
+ slug='vlan-group-2',
+ scope=sites[1],
+ scope_type=ContentType.objects.get_for_model(Site),
+ ),
+ ))
+ vlan = VLAN(
+ name='VLAN 1',
+ vid=1,
+ group=vlangroups[0],
+ site=sites[0],
+ )
+
+ # VLAN Group 1 and 2 should be valid
+ vlan.full_clean()
+ vlan.group = vlangroups[1]
+ vlan.full_clean()
+ vlan.group = vlangroups[2]
+ with self.assertRaises(ValidationError):
+ vlan.full_clean()
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 6dd946a6a..34ce6144f 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -69,7 +69,7 @@ class VRFDeleteView(generic.ObjectDeleteView):
queryset = VRF.objects.all()
-@register_model_view(VRF, 'bulk_import', detail=False)
+@register_model_view(VRF, 'bulk_import', path='import', detail=False)
class VRFBulkImportView(generic.BulkImportView):
queryset = VRF.objects.all()
model_form = forms.VRFImportForm
@@ -119,7 +119,7 @@ class RouteTargetDeleteView(generic.ObjectDeleteView):
queryset = RouteTarget.objects.all()
-@register_model_view(RouteTarget, 'bulk_import', detail=False)
+@register_model_view(RouteTarget, 'bulk_import', path='import', detail=False)
class RouteTargetBulkImportView(generic.BulkImportView):
queryset = RouteTarget.objects.all()
model_form = forms.RouteTargetImportForm
@@ -176,7 +176,7 @@ class RIRDeleteView(generic.ObjectDeleteView):
queryset = RIR.objects.all()
-@register_model_view(RIR, 'bulk_import', detail=False)
+@register_model_view(RIR, 'bulk_import', path='import', detail=False)
class RIRBulkImportView(generic.BulkImportView):
queryset = RIR.objects.all()
model_form = forms.RIRImportForm
@@ -251,7 +251,7 @@ class ASNRangeDeleteView(generic.ObjectDeleteView):
queryset = ASNRange.objects.all()
-@register_model_view(ASNRange, 'bulk_import', detail=False)
+@register_model_view(ASNRange, 'bulk_import', path='import', detail=False)
class ASNRangeBulkImportView(generic.BulkImportView):
queryset = ASNRange.objects.all()
model_form = forms.ASNRangeImportForm
@@ -316,7 +316,7 @@ class ASNDeleteView(generic.ObjectDeleteView):
queryset = ASN.objects.all()
-@register_model_view(ASN, 'bulk_import', detail=False)
+@register_model_view(ASN, 'bulk_import', path='import', detail=False)
class ASNBulkImportView(generic.BulkImportView):
queryset = ASN.objects.all()
model_form = forms.ASNImportForm
@@ -408,7 +408,7 @@ class AggregateDeleteView(generic.ObjectDeleteView):
queryset = Aggregate.objects.all()
-@register_model_view(Aggregate, 'bulk_import', detail=False)
+@register_model_view(Aggregate, 'bulk_import', path='import', detail=False)
class AggregateBulkImportView(generic.BulkImportView):
queryset = Aggregate.objects.all()
model_form = forms.AggregateImportForm
@@ -471,7 +471,7 @@ class RoleDeleteView(generic.ObjectDeleteView):
queryset = Role.objects.all()
-@register_model_view(Role, 'bulk_import', detail=False)
+@register_model_view(Role, 'bulk_import', path='import', detail=False)
class RoleBulkImportView(generic.BulkImportView):
queryset = Role.objects.all()
model_form = forms.RoleImportForm
@@ -657,7 +657,7 @@ class PrefixDeleteView(generic.ObjectDeleteView):
queryset = Prefix.objects.all()
-@register_model_view(Prefix, 'bulk_import', detail=False)
+@register_model_view(Prefix, 'bulk_import', path='import', detail=False)
class PrefixBulkImportView(generic.BulkImportView):
queryset = Prefix.objects.all()
model_form = forms.PrefixImportForm
@@ -746,7 +746,7 @@ class IPRangeDeleteView(generic.ObjectDeleteView):
queryset = IPRange.objects.all()
-@register_model_view(IPRange, 'bulk_import', detail=False)
+@register_model_view(IPRange, 'bulk_import', path='import', detail=False)
class IPRangeBulkImportView(generic.BulkImportView):
queryset = IPRange.objects.all()
model_form = forms.IPRangeImportForm
@@ -910,7 +910,7 @@ class IPAddressBulkCreateView(generic.BulkCreateView):
template_name = 'ipam/ipaddress_bulk_add.html'
-@register_model_view(IPAddress, 'bulk_import', detail=False)
+@register_model_view(IPAddress, 'bulk_import', path='import', detail=False)
class IPAddressBulkImportView(generic.BulkImportView):
queryset = IPAddress.objects.all()
model_form = forms.IPAddressImportForm
@@ -983,7 +983,7 @@ class VLANGroupDeleteView(generic.ObjectDeleteView):
queryset = VLANGroup.objects.all()
-@register_model_view(VLANGroup, 'bulk_import', detail=False)
+@register_model_view(VLANGroup, 'bulk_import', path='import', detail=False)
class VLANGroupBulkImportView(generic.BulkImportView):
queryset = VLANGroup.objects.all()
model_form = forms.VLANGroupImportForm
@@ -1070,7 +1070,7 @@ class VLANTranslationPolicyDeleteView(generic.ObjectDeleteView):
queryset = VLANTranslationPolicy.objects.all()
-@register_model_view(VLANTranslationPolicy, 'bulk_import', detail=False)
+@register_model_view(VLANTranslationPolicy, 'bulk_import', path='import', detail=False)
class VLANTranslationPolicyBulkImportView(generic.BulkImportView):
queryset = VLANTranslationPolicy.objects.all()
model_form = forms.VLANTranslationPolicyImportForm
@@ -1125,7 +1125,7 @@ class VLANTranslationRuleDeleteView(generic.ObjectDeleteView):
queryset = VLANTranslationRule.objects.all()
-@register_model_view(VLANTranslationRule, 'bulk_import', detail=False)
+@register_model_view(VLANTranslationRule, 'bulk_import', path='import', detail=False)
class VLANTranslationRuleBulkImportView(generic.BulkImportView):
queryset = VLANTranslationRule.objects.all()
model_form = forms.VLANTranslationRuleImportForm
@@ -1218,7 +1218,7 @@ class FHRPGroupDeleteView(generic.ObjectDeleteView):
queryset = FHRPGroup.objects.all()
-@register_model_view(FHRPGroup, 'bulk_import', detail=False)
+@register_model_view(FHRPGroup, 'bulk_import', path='import', detail=False)
class FHRPGroupBulkImportView(generic.BulkImportView):
queryset = FHRPGroup.objects.all()
model_form = forms.FHRPGroupImportForm
@@ -1344,7 +1344,7 @@ class VLANDeleteView(generic.ObjectDeleteView):
queryset = VLAN.objects.all()
-@register_model_view(VLAN, 'bulk_import', detail=False)
+@register_model_view(VLAN, 'bulk_import', path='import', detail=False)
class VLANBulkImportView(generic.BulkImportView):
queryset = VLAN.objects.all()
model_form = forms.VLANImportForm
@@ -1394,7 +1394,7 @@ class ServiceTemplateDeleteView(generic.ObjectDeleteView):
queryset = ServiceTemplate.objects.all()
-@register_model_view(ServiceTemplate, 'bulk_import', detail=False)
+@register_model_view(ServiceTemplate, 'bulk_import', path='import', detail=False)
class ServiceTemplateBulkImportView(generic.BulkImportView):
queryset = ServiceTemplate.objects.all()
model_form = forms.ServiceTemplateImportForm
@@ -1461,7 +1461,7 @@ class ServiceDeleteView(generic.ObjectDeleteView):
queryset = Service.objects.all()
-@register_model_view(Service, 'bulk_import', detail=False)
+@register_model_view(Service, 'bulk_import', path='import', detail=False)
class ServiceBulkImportView(generic.BulkImportView):
queryset = Service.objects.all()
model_form = forms.ServiceImportForm
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index bc1b20316..239b5978f 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -442,7 +442,7 @@ INSTALLED_APPS = [
'drf_spectacular',
'drf_spectacular_sidecar',
]
-if not DEBUG:
+if not DEBUG and 'collectstatic' not in sys.argv:
INSTALLED_APPS.remove('debug_toolbar')
# Middleware
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
index 5a5d060e9..4fd23e84c 100644
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/netbox/netbox/views/generic/bulk_views.py
@@ -29,6 +29,7 @@ from utilities.forms.bulk_import import BulkImportForm
from utilities.htmx import htmx_partial
from utilities.permissions import get_permission_for_model
from utilities.query import reapply_model_ordering
+from utilities.request import safe_for_redirect
from utilities.tables import get_table_configs
from utilities.views import GetReturnURLMixin, get_viewname
from .base import BaseMultiObjectView
@@ -121,7 +122,10 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# Strip the `export` param and redirect user to the filtered objects list
query_params = request.GET.copy()
query_params.pop('export')
- return redirect(f'{request.path}?{query_params.urlencode()}')
+ redirect_url = f'{request.path}?{query_params.urlencode()}'
+ if safe_for_redirect(redirect_url):
+ return redirect(redirect_url)
+ return redirect(get_viewname(self.queryset.model, 'list'))
#
# Request handlers
@@ -286,7 +290,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
logger.info(msg)
messages.success(request, msg)
- if '_addanother' in request.POST:
+ if '_addanother' in request.POST and safe_for_redirect(request.path):
return redirect(request.path)
return redirect(self.get_return_url(request))
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
index 11038aa97..0db73b7a6 100644
--- a/netbox/netbox/views/generic/object_views.py
+++ b/netbox/netbox/views/generic/object_views.py
@@ -20,6 +20,7 @@ from utilities.forms import ConfirmationForm, restrict_form_fields
from utilities.htmx import htmx_partial
from utilities.permissions import get_permission_for_model
from utilities.querydict import normalize_querydict, prepare_cloned_fields
+from utilities.request import safe_for_redirect
from utilities.tables import get_table_configs
from utilities.views import GetReturnURLMixin, get_viewname
from .base import BaseObjectView
@@ -317,6 +318,8 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
if 'return_url' in request.GET:
params['return_url'] = request.GET.get('return_url')
redirect_url += f"?{params.urlencode()}"
+ if not safe_for_redirect(redirect_url):
+ redirect_url = reverse('home')
return redirect(redirect_url)
@@ -583,7 +586,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
))
# Redirect user on success
- if '_addanother' in request.POST:
+ if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
return redirect(request.get_full_path())
else:
return redirect(self.get_return_url(request))
diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css
index e204aca0a..26d652ad0 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/styles/custom/_misc.scss b/netbox/project-static/styles/custom/_misc.scss
index f0c229e52..2ba3262f6 100644
--- a/netbox/project-static/styles/custom/_misc.scss
+++ b/netbox/project-static/styles/custom/_misc.scss
@@ -63,3 +63,27 @@ span.color-label {
.sso-icon {
height: 24px;
}
+
+.btn-white {
+ @extend .btn-light;
+}
+
+.btn-black {
+ @extend .btn-dark;
+}
+
+.btn-grey, .btn-gray {
+ @extend .btn-secondary;
+}
+
+img.plugin-icon {
+ max-width: 1.4285em;
+ height: auto;
+}
+
+body[data-bs-theme=dark] {
+ // Assuming icon is black/white line art, invert it and tone down brightness
+ img.plugin-icon {
+ filter: grayscale(100%) invert(100%) brightness(80%);
+ }
+}
diff --git a/netbox/project-static/styles/transitional/_navigation.scss b/netbox/project-static/styles/transitional/_navigation.scss
index d31f1cc82..68f509bca 100644
--- a/netbox/project-static/styles/transitional/_navigation.scss
+++ b/netbox/project-static/styles/transitional/_navigation.scss
@@ -1,11 +1,6 @@
// Navbar and light theme styling
.navbar-vertical.navbar-expand-lg {
- // Adds spacing to the bottom of the side navigation to avoid hidden nav items
- @include media-breakpoint-up(lg) {
- padding-bottom: 2rem;
- }
-
// Adjust hover color & style for menu items
.navbar-collapse {
.nav-link-icon, .nav-link-title {
diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html
index 9dcb7bded..7fce92c5c 100644
--- a/netbox/templates/base/layout.html
+++ b/netbox/templates/base/layout.html
@@ -21,7 +21,7 @@ Blocks:
{# Sidebar #}
@@ -210,7 +221,6 @@ Blocks:
{% if total_length %}
{{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} {% trans "Meters" %} /
- {{ total_length|meters_to_feet|floatformat:"-2" }} {% trans "Feet" %}
+ {{ total_length|meters_to_feet|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} {% trans "Feet" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html
index d6e4188b6..bd3c8cc4b 100644
--- a/netbox/templates/dcim/consoleport.html
+++ b/netbox/templates/dcim/consoleport.html
@@ -12,7 +12,7 @@
{% block content %}
-
+ {% trans "Console Port" %} |