diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 21dc72545..b5de9bfee 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.1.10 + placeholder: v3.2.1 validations: required: true - type: dropdown @@ -22,9 +22,9 @@ body: label: Python version description: What version of Python are you currently running? options: - - "3.7" - "3.8" - "3.9" + - "3.10" validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index f64f5ccba..138e0f9b4 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.1.10 + placeholder: v3.2.1 validations: required: true - type: dropdown diff --git a/README.md b/README.md index 42bf8b619..8429cd4b3 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ NetBox logo -:loudspeaker: The **[2022 NetBox community survey](https://forms.gle/KR8YbR8GiJ9EYXM28)** is now open! We collect this feedback and demographic data from NetBox users around the world to help shape the project's long-term development goals. Please take a few minutes to share your responses! - ![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) NetBox is an infrastructure resource modeling (IRM) tool designed to empower diff --git a/base_requirements.txt b/base_requirements.txt index 77a5bb8aa..4b814dbc7 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -68,7 +68,8 @@ gunicorn # Platform-agnostic template rendering engine # https://github.com/pallets/jinja -Jinja2 +# Pin to v3.0 for mkdocstrings +Jinja2<3.1 # Simple markup language for rendering HTML # https://github.com/Python-Markdown/markdown @@ -84,7 +85,7 @@ mkdocs-material # Introspection for embedded code # https://github.com/mkdocstrings/mkdocstrings -mkdocstrings +mkdocstrings<=0.17.0 # Library for manipulating IP prefixes and addresses # https://github.com/netaddr/netaddr diff --git a/docs/administration/authentication/microsoft-azure-ad.md b/docs/administration/authentication/microsoft-azure-ad.md new file mode 100644 index 000000000..b2de148ac --- /dev/null +++ b/docs/administration/authentication/microsoft-azure-ad.md @@ -0,0 +1,79 @@ +# Microsoft Azure AD + +This guide explains how to configure single sign-on (SSO) support for NetBox using [Microsoft Azure Active Directory (AD)](https://azure.microsoft.com/en-us/services/active-directory/) as an authentication backend. + +## Azure AD Configuration + +### 1. Create a test user (optional) + +Create a new user in AD to be used for testing. You can skip this step if you already have a suitable account created. + +### 2. Create an app registration + +Under the Azure Active Directory dashboard, navigate to **Add > App registration**. + +![Add an app registration](../../media/authentication/azure_ad_add_app_registration.png) + +Enter a name for the registration (e.g. "NetBox") and ensure that the "single tenant" option is selected. + +Under "Redirect URI", select "Web" for the platform and enter the path to your NetBox installation, ending with `/oauth/complete/azuread-oauth2/`. Note that this URI **must** begin with `https://` unless you are referencing localhost (for development purposes). + +![App registration parameters](../../media/authentication/azure_ad_app_registration.png) + +Once finished, make note of the application (client) ID; this will be used when configuring NetBox. + +![Completed app registration](../../media/authentication/azure_ad_app_registration_created.png) + +!!! tip "Multitenant authentication" + NetBox also supports multitenant authentication via Azure AD, however it requires a different backend and an additional configuration parameter. Please see the [`python-social-auth` documentation](https://python-social-auth.readthedocs.io/en/latest/backends/azuread.html#tenant-support) for details concerning multitenant authentication. + +### 3. Create a secret + +When viewing the newly-created app registration, click the "Add a certificate or secret" link under "Client credentials". Under the "Client secrets" tab, click the "New client secret" button. + +![Add a client secret](../../media/authentication/azure_ad_add_client_secret.png) + +You can optionally specify a description and select a lifetime for the secret. + +![Client secret parameters](../../media/authentication/azure_ad_client_secret.png) + +Once finished, make note of the secret value (not the secret ID); this will be used when configuring NetBox. + +![Client secret parameters](../../media/authentication/azure_ad_client_secret_created.png) + +## NetBox Configuration + +### 1. Enter configuration parameters + +Enter the following configuration parameters in `configuration.py`, substituting your own values: + +```python +REMOTE_AUTH_BACKEND = 'social_core.backends.azuread.AzureADOAuth2' +SOCIAL_AUTH_AZUREAD_OAUTH2_KEY = '{APPLICATION_ID}' +SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET = '{SECRET_VALUE}' +``` + +### 2. Restart NetBox + +Restart the NetBox services so that the new configuration takes effect. This is typically done with the command below: + +```no-highlight +sudo systemctl restart netbox +``` + +## Testing + +Log out of NetBox if already authenticated, and click the "Log In" button at top right. You should see the normal login form as well as an option to authenticate using Azure AD. Click that link. + +![NetBox Azure AD login form](../../media/authentication/netbox_azure_ad_login.png) + +You should be redirected to Microsoft's authentication portal. Enter the username/email and password of your test account to continue. You may also be prompted to grant this application access to your account. + +![NetBox Azure AD login form](../../media/authentication/azure_ad_login_portal.png) + +If successful, you will be redirected back to the NetBox UI, and will be logged in as the AD user. You can verify this by navigating to your profile (using the button at top right). + +This user account has been replicated locally to NetBox, and can now be assigned groups and permissions within the NetBox admin UI. + +!!! note "Troubleshooting" + If you are redirected to the NetBox UI after authenticating, but are _not_ logged in, double-check the configured backend and app registration. The instructions in this guide pertain only to the `azuread.AzureADOAuth2` backend using a single-tenant app registration. diff --git a/docs/administration/authentication/okta.md b/docs/administration/authentication/okta.md new file mode 100644 index 000000000..ff552d730 --- /dev/null +++ b/docs/administration/authentication/okta.md @@ -0,0 +1,70 @@ +# Okta + +This guide explains how to configure single sign-on (SSO) support for NetBox using [Okta](https://www.okta.com/) as an authentication backend. + +## Okta Configuration + +!!! tip "Okta developer account" + Okta offers free developer accounts at . + +### 1. Create a test user (optional) + +Create a new user in the Okta admin portal to be used for testing. You can skip this step if you already have a suitable account created. + +### 2. Create an app registration + +Within the Okta administration dashboard, navigate to **Applications > Applications**, and click the "Create App Integration" button. Select "OIDC" as the sign-in method, and "Web application" for the application type. + +![Create an app registration](../../media/authentication/okta_create_app_registration.png) + +On the next page, give the app integration a name (e.g. "NetBox") and specify the sign-in and sign-out URIs. These URIs should follow the formats below: + +* Sign-in URI: `https://{netbox}/oauth/complete/okta-openidconnect/` +* Sign-out URI: `https://{netbox}/oauth/disconnect/okta-openidconnect/` + +![Web app integration](../../media/authentication/okta_web_app_integration.png) + +Under "Assignments," select the controlled access setting most appropriate for your organization. Click "Save" to complete the creation. + +Once finished, note the following parameters. These will be used to configured NetBox. + +* Client ID +* Client secret +* Okta domain + +![Okta integration parameters](../../media/authentication/okta_integration_parameters.png) + +## NetBox Configuration + +### 1. Enter configuration parameters + +Enter the following configuration parameters in `configuration.py`, substituting your own values: + +```python +REMOTE_AUTH_BACKEND = 'social_core.backends.okta_openidconnect.OktaOpenIdConnect' +SOCIAL_AUTH_OKTA_OPENIDCONNECT_KEY = '{Client ID}' +SOCIAL_AUTH_OKTA_OPENIDCONNECT_SECRET = '{Client secret}' +SOCIAL_AUTH_OKTA_OPENIDCONNECT_API_URL = 'https://{Okta domain}/oauth2/' +``` + +### 2. Restart NetBox + +Restart the NetBox services so that the new configuration takes effect. This is typically done with the command below: + +```no-highlight +sudo systemctl restart netbox +``` + +## Testing + +Log out of NetBox if already authenticated, and click the "Log In" button at top right. You should see the normal login form as well as an option to authenticate using Okta. Click that link. + +![NetBox Okta login form](../../media/authentication/netbox_okta_login.png) + +You should be redirected to Okta's authentication portal. Enter the username/email and password of your test account to continue. You may also be prompted to grant this application access to your account. + +![Okta login portal](../../media/authentication/okta_login_portal.png) + +If successful, you will be redirected back to the NetBox UI, and will be logged in as the Okta user. You can verify this by navigating to your profile (using the button at top right). + +This user account has been replicated locally to NetBox, and can now be assigned groups and permissions within the NetBox admin UI. diff --git a/docs/administration/authentication.md b/docs/administration/authentication/overview.md similarity index 91% rename from docs/administration/authentication.md rename to docs/administration/authentication/overview.md index 31983be0b..b405ed09a 100644 --- a/docs/administration/authentication.md +++ b/docs/administration/authentication/overview.md @@ -4,7 +4,7 @@ Local user accounts and groups can be created in NetBox under the "Authentication and Authorization" section of the administrative user interface. This interface is available only to users with the "staff" permission enabled. -At a minimum, each user account must have a username and password set. User accounts may also denote a first name, last name, and email address. [Permissions](./permissions.md) may also be assigned to users and/or groups within the admin UI. +At a minimum, each user account must have a username and password set. User accounts may also denote a first name, last name, and email address. [Permissions](../permissions.md) may also be assigned to users and/or groups within the admin UI. ## Remote Authentication @@ -16,7 +16,7 @@ NetBox may be configured to provide user authenticate via a remote backend in ad REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend' ``` -NetBox includes an authentication backend which supports LDAP. See the [LDAP installation docs](../installation/6-ldap.md) for more detail about this backend. +NetBox includes an authentication backend which supports LDAP. See the [LDAP installation docs](../../installation/6-ldap.md) for more detail about this backend. ### HTTP Header Authentication diff --git a/docs/administration/housekeeping.md b/docs/administration/housekeeping.md index bbb03dc27..1989e41c0 100644 --- a/docs/administration/housekeeping.md +++ b/docs/administration/housekeeping.md @@ -4,6 +4,7 @@ NetBox includes a `housekeeping` management command that should be run nightly. * Clearing expired authentication sessions from the database * Deleting changelog records older than the configured [retention time](../configuration/dynamic-settings.md#changelog_retention) +* Deleting job result records older than the configured [retention time](../configuration/dynamic-settings.md#jobresult_retention) This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file. diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md index 5649eb9be..2fa046fcf 100644 --- a/docs/configuration/dynamic-settings.md +++ b/docs/configuration/dynamic-settings.md @@ -43,6 +43,18 @@ changes in the database indefinitely. --- +## JOBRESULT_RETENTION + +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. + +!!! warning + If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. + +--- + ## CUSTOM_VALIDATORS This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below: diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md index 73d29415b..2c3a7002f 100644 --- a/docs/configuration/remote-authentication.md +++ b/docs/configuration/remote-authentication.md @@ -43,7 +43,7 @@ A mapping of permissions to assign a new user account when created using remote Default: `False` -NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (`REMOTE_AUTH_DEFAULT_GROUPS` will not function if `REMOTE_AUTH_ENABLED` is enabled) +NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (`REMOTE_AUTH_DEFAULT_GROUPS` will not function if `REMOTE_AUTH_ENABLED` is disabled) --- diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 02af19726..230b003c6 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -89,6 +89,12 @@ The checkbox to commit database changes when executing a script is checked by de commit_default = False ``` +### `job_timeout` + +Set the maximum allowed runtime for the script. If not set, `RQ_DEFAULT_TIMEOUT` will be used. + +!!! info "This feature was introduced in v3.2.1" + ## Accessing Request Data Details of the current HTTP request (the one being made to execute the script) are available as the instance attribute `self.request`. This can be used to infer, for example, the user executing the script and the client IP address: diff --git a/docs/customization/reports.md b/docs/customization/reports.md index 3bf6bd8d9..ae4ceb9aa 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -85,6 +85,20 @@ As you can see, reports are completely customizable. Validation logic can be as !!! warning Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data. +## Report Attributes + +### `description` + +A human-friendly description of what your report does. + +### `job_timeout` + +Set the maximum allowed runtime for the report. If not set, `RQ_DEFAULT_TIMEOUT` will be used. + +!!! info "This feature was introduced in v3.2.1" + +## Logging + The following methods are available to log results within a report: * log(message) diff --git a/docs/index.md b/docs/index.md index eadb2088f..81c899387 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,5 @@ ![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} -:loudspeaker: The **[2022 NetBox community survey](https://forms.gle/KR8YbR8GiJ9EYXM28)** is now open! We collect this feedback and demographic data from NetBox users around the world to help shape the project's long-term development goals. Please take a few minutes to share your responses! - # What is NetBox? NetBox is an infrastructure resource modeling (IRM) application designed to empower network automation. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. NetBox is made available as open source under the Apache 2 license. It encompasses the following aspects of network management: diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 56b66c10d..014dffaf8 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -6,7 +6,7 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [releas ## Update Dependencies to Required Versions -NetBox v3.0 and later requires the following: +NetBox v3.0 and later require the following: | Dependency | Minimum Version | |------------|-----------------| @@ -67,6 +67,11 @@ sudo git checkout master sudo git pull origin master ``` +!!! info "Checking out an older release" + If you need to upgrade to an older version rather than the current stable release, you can check out any valid [git tag](https://github.com/netbox-community/netbox/tags), each of which represents a release. For example, to checkout the code for NetBox v2.11.11, do: + + sudo git checkout v2.11.11 + ## Run the Upgrade Script Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `napalm` or `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script: diff --git a/docs/media/authentication/azure_ad_add_app_registration.png b/docs/media/authentication/azure_ad_add_app_registration.png new file mode 100644 index 000000000..4cc4fc81e Binary files /dev/null and b/docs/media/authentication/azure_ad_add_app_registration.png differ diff --git a/docs/media/authentication/azure_ad_add_client_secret.png b/docs/media/authentication/azure_ad_add_client_secret.png new file mode 100644 index 000000000..93f4eb776 Binary files /dev/null and b/docs/media/authentication/azure_ad_add_client_secret.png differ diff --git a/docs/media/authentication/azure_ad_app_registration.png b/docs/media/authentication/azure_ad_app_registration.png new file mode 100644 index 000000000..eb1634e96 Binary files /dev/null and b/docs/media/authentication/azure_ad_app_registration.png differ diff --git a/docs/media/authentication/azure_ad_app_registration_created.png b/docs/media/authentication/azure_ad_app_registration_created.png new file mode 100644 index 000000000..7e14dc731 Binary files /dev/null and b/docs/media/authentication/azure_ad_app_registration_created.png differ diff --git a/docs/media/authentication/azure_ad_client_secret.png b/docs/media/authentication/azure_ad_client_secret.png new file mode 100644 index 000000000..bebae388b Binary files /dev/null and b/docs/media/authentication/azure_ad_client_secret.png differ diff --git a/docs/media/authentication/azure_ad_client_secret_created.png b/docs/media/authentication/azure_ad_client_secret_created.png new file mode 100644 index 000000000..c142f2a91 Binary files /dev/null and b/docs/media/authentication/azure_ad_client_secret_created.png differ diff --git a/docs/media/authentication/azure_ad_login_portal.png b/docs/media/authentication/azure_ad_login_portal.png new file mode 100644 index 000000000..891f64355 Binary files /dev/null and b/docs/media/authentication/azure_ad_login_portal.png differ diff --git a/docs/media/authentication/netbox_azure_ad_login.png b/docs/media/authentication/netbox_azure_ad_login.png new file mode 100644 index 000000000..a1bbe69d1 Binary files /dev/null and b/docs/media/authentication/netbox_azure_ad_login.png differ diff --git a/docs/media/authentication/netbox_okta_login.png b/docs/media/authentication/netbox_okta_login.png new file mode 100644 index 000000000..34df39cba Binary files /dev/null and b/docs/media/authentication/netbox_okta_login.png differ diff --git a/docs/media/authentication/okta_create_app_registration.png b/docs/media/authentication/okta_create_app_registration.png new file mode 100644 index 000000000..7a62d3d9e Binary files /dev/null and b/docs/media/authentication/okta_create_app_registration.png differ diff --git a/docs/media/authentication/okta_integration_parameters.png b/docs/media/authentication/okta_integration_parameters.png new file mode 100644 index 000000000..b5d7794ec Binary files /dev/null and b/docs/media/authentication/okta_integration_parameters.png differ diff --git a/docs/media/authentication/okta_login_portal.png b/docs/media/authentication/okta_login_portal.png new file mode 100644 index 000000000..48b62d3e0 Binary files /dev/null and b/docs/media/authentication/okta_login_portal.png differ diff --git a/docs/media/authentication/okta_web_app_integration.png b/docs/media/authentication/okta_web_app_integration.png new file mode 100644 index 000000000..6587127c6 Binary files /dev/null and b/docs/media/authentication/okta_web_app_integration.png differ diff --git a/docs/models/dcim/virtualchassis.md b/docs/models/dcim/virtualchassis.md index 3b6fb9d17..2466b065d 100644 --- a/docs/models/dcim/virtualchassis.md +++ b/docs/models/dcim/virtualchassis.md @@ -2,7 +2,8 @@ A virtual chassis represents a set of devices which share a common control plane. A common example of this is a stack of switches which are connected and configured to operate as a single device. A virtual chassis must be assigned a name and may be assigned a domain. -Each device in the virtual chassis is referred to as a VC member, and assigned a position and (optionally) a priority. VC member devices commonly reside within the same rack, though this is not a requirement. One of the devices may be designated as the VC master: This device will typically be assigned a name, services, and other attributes related to managing the VC. +Each device in the virtual chassis is referred to as a VC member, and assigned a position and (optionally) a priority. VC member devices commonly reside within the same rack, though this is not a requirement. One of the devices may be designated as the VC master: This device will typically be assigned a name, services, virtual interfaces, and other attributes related to managing the VC. +If a VC master is defined, interfaces from all VC members are displayed when navigating to its device interfaces view. This does not include other members interfaces declared as management-only. !!! note It's important to recognize the distinction between a virtual chassis and a chassis-based device. A virtual chassis is **not** suitable for modeling a chassis-based switch with removable line cards (such as the Juniper EX9208), as its line cards are _not_ physically autonomous devices. diff --git a/docs/models/extras/customlink.md b/docs/models/extras/customlink.md index be914e99b..16ba9d2af 100644 --- a/docs/models/extras/customlink.md +++ b/docs/models/extras/customlink.md @@ -2,7 +2,7 @@ Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS). -Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the Netbox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`. +Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`. For example, you might define a link like this: @@ -33,6 +33,10 @@ The following context data is available within the template when rendering a cus | `user` | The current user (if authenticated) | | `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user | +While most of the context variables listed above will have consistent attributes, the object will be an instance of the specific object being viewed when the link is rendered. Different models have different fields and properties, so you may need to some research to determine the attributes available for use within your template for a specific object type. + +Checking the REST API representation of an object is generally a convenient way to determine what attributes are available. You can also reference the NetBox source code directly for a comprehensive list. + ## Conditional Rendering Only links which render with non-empty text are included on the page. You can employ conditional Jinja2 logic to control the conditions under which a link gets rendered. diff --git a/docs/models/users/token.md b/docs/models/users/token.md index d0e0f8609..d98b51369 100644 --- a/docs/models/users/token.md +++ b/docs/models/users/token.md @@ -3,7 +3,7 @@ A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. !!! note - The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access. + All users can create and manage REST API tokens under the user control panel in the UI. The ability to view, add, change, or delete tokens via the REST API itself is controlled by the relevant model permissions, assigned to users and/or groups in the admin UI. These permissions should be used with great care to avoid accidentally permitting a user to create tokens for other user accounts. Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index af15ba7b6..f8bb365be 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -10,7 +10,7 @@ Minor releases are published in April, August, and December of each calendar yea This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release. -#### [Version 3.2](./version-3.2.md) (Pending Release) +#### [Version 3.2](./version-3.2.md) (April 2022) * Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333)) * Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844)) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index df9c9c7bb..27ba4e69e 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,6 +1,22 @@ # NetBox v3.1 -## v3.1.11 (FUTURE) +## v3.1.11 (2022-04-05) + +### Enhancements + +* [#8163](https://github.com/netbox-community/netbox/issues/8163) - Show bridge interface members under interface view +* [#8365](https://github.com/netbox-community/netbox/issues/8365) - Enable filtering child devices by parent device ID +* [#8785](https://github.com/netbox-community/netbox/issues/8785) - Permit wildcard values in IP address DNS names +* [#8790](https://github.com/netbox-community/netbox/issues/8790) - Include site and prefixes columns in VLAN group VLANs table +* [#8830](https://github.com/netbox-community/netbox/issues/8830) - Add Checkpoint ClusterXL protocol for FHRP groups +* [#8974](https://github.com/netbox-community/netbox/issues/8974) - Use monospace font for text areas in config revision form +* [#9012](https://github.com/netbox-community/netbox/issues/9012) - Linkify circuits count in providers list +* [#9036](https://github.com/netbox-community/netbox/issues/9036) - Add bulk edit capability for site contact fields + +### Bug Fixes + +* [#8866](https://github.com/netbox-community/netbox/issues/8866) - Prevent exception when searching for a rack position with no rack specified under device edit view +* [#9009](https://github.com/netbox-community/netbox/issues/9009) - Fix device count for racks in global search results --- diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index c45436d07..acbb68fad 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,10 +1,63 @@ # NetBox v3.2 -## v3.2.0 (FUTURE) +## v3.2.2 (FUTURE) + +### Enhancements + +* [#9060](https://github.com/netbox-community/netbox/issues/9060) - Add device type filters for device bays, module bays, and inventory items +* [#9152](https://github.com/netbox-community/netbox/issues/9152) - Annotate related object type under custom field view + +### Bug Fixes + +* [#9132](https://github.com/netbox-community/netbox/issues/9132) - Limit location options by selected site when creating a wireless link +* [#9133](https://github.com/netbox-community/netbox/issues/9133) - Upgrade script should require Python 3.8 or later +* [#9151](https://github.com/netbox-community/netbox/issues/9151) - Child prefix counts not annotated on aggregates list under RIR view +* [#9156](https://github.com/netbox-community/netbox/issues/9156) - Fix loading UserConfig data from fixtures +* [#9158](https://github.com/netbox-community/netbox/issues/9158) - Do not list tags field for CSV forms which do not support tag assignment + +--- + +## v3.2.1 (2022-04-14) + +### Enhancements + +* [#5479](https://github.com/netbox-community/netbox/issues/5479) - Allow custom job timeouts for scripts & reports +* [#8543](https://github.com/netbox-community/netbox/issues/8543) - Improve filtering for wireless LAN VLAN selection +* [#8920](https://github.com/netbox-community/netbox/issues/8920) - Limit number of non-racked devices displayed +* [#8956](https://github.com/netbox-community/netbox/issues/8956) - Retain old script/report results for configured lifetime +* [#8973](https://github.com/netbox-community/netbox/issues/8973) - Display VLAN group count under site view +* [#9081](https://github.com/netbox-community/netbox/issues/9081) - Add `fhrpgroup_id` filter for IP addresses +* [#9099](https://github.com/netbox-community/netbox/issues/9099) - Enable display of installed module serial & asset tag in module bays list +* [#9110](https://github.com/netbox-community/netbox/issues/9110) - Add Neutrik proprietary power connectors +* [#9123](https://github.com/netbox-community/netbox/issues/9123) - Improve appearance of SSO login providers + +### Bug Fixes + +* [#8931](https://github.com/netbox-community/netbox/issues/8931) - Copy assigned tenant when cloning a location +* [#9055](https://github.com/netbox-community/netbox/issues/9055) - Restore ability to move inventory item to other device +* [#9057](https://github.com/netbox-community/netbox/issues/9057) - Fix missing instance counts for module types +* [#9061](https://github.com/netbox-community/netbox/issues/9061) - Fix general search for device components +* [#9065](https://github.com/netbox-community/netbox/issues/9065) - Min/max VID should not be required when filtering VLAN groups +* [#9079](https://github.com/netbox-community/netbox/issues/9079) - Fail validation when an inventory item is assigned as its own parent +* [#9096](https://github.com/netbox-community/netbox/issues/9096) - Remove duplicate filter tag when filtering by "none" +* [#9100](https://github.com/netbox-community/netbox/issues/9100) - Include position field in module type YAML export +* [#9116](https://github.com/netbox-community/netbox/issues/9116) - `assigned_to_interface` filter for IP addresses should not match FHRP group assignments +* [#9118](https://github.com/netbox-community/netbox/issues/9118) - Fix validation error when importing VM child interfaces +* [#9128](https://github.com/netbox-community/netbox/issues/9128) - Resolve component labels per module bay position when installing modules + +--- + +## v3.2.0 (2022-04-05) !!! warning "Python 3.8 or Later Required" NetBox v3.2 requires Python 3.8 or later. +!!! warning "Deletion of Legacy Data" + This release includes a database migration that will remove the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields from the site model. (These fields have been superseded by the ASN and contact models introduced in NetBox v3.1.) To protect against the accidental destruction of data, the upgrade process **will fail** if any sites still have data in any of these fields. To bypass this safeguard, set the `NETBOX_DELETE_LEGACY_DATA` environment variable when running the upgrade script, which will permit the destruction of legacy data. + +!!! tip "Migration Scripts" + A set of [migration scripts](https://github.com/netbox-community/migration-scripts) is available to assist with the migration of legacy site data. + ### Breaking Changes * Automatic redirection of legacy slug-based URL paths has been removed. URL-based slugs were changed to use numeric IDs in v2.11.0. @@ -142,15 +195,23 @@ Where it is desired to limit the range of available VLANs within a group, users * [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links * [#8307](https://github.com/netbox-community/netbox/issues/8307) - Add `data_type` indicator to REST API serializer for custom fields * [#8463](https://github.com/netbox-community/netbox/issues/8463) - Change the `created` field on all change-logged models from date to datetime +* [#8496](https://github.com/netbox-community/netbox/issues/8496) - Enable assigning multiple ASNs to a provider * [#8572](https://github.com/netbox-community/netbox/issues/8572) - Add a `pre_run()` method for reports +* [#8593](https://github.com/netbox-community/netbox/issues/8593) - Add a `link` field for contacts * [#8649](https://github.com/netbox-community/netbox/issues/8649) - Enable customization of configuration module using `NETBOX_CONFIGURATION` environment variable +* [#9006](https://github.com/netbox-community/netbox/issues/9006) - Enable custom fields, custom links, and tags for journal entries ### Bug Fixes (From Beta2) +* [#8658](https://github.com/netbox-community/netbox/issues/8658) - Fix display of assigned components under inventory item lists * [#8838](https://github.com/netbox-community/netbox/issues/8838) - Fix FieldError exception during global search * [#8845](https://github.com/netbox-community/netbox/issues/8845) - Correct default ASN formatting in table * [#8869](https://github.com/netbox-community/netbox/issues/8869) - Fix NoReverseMatch exception when displaying tag w/assignments * [#8872](https://github.com/netbox-community/netbox/issues/8872) - Enable filtering by custom object fields +* [#8970](https://github.com/netbox-community/netbox/issues/8970) - Permit nested inventory item templates on device types +* [#8976](https://github.com/netbox-community/netbox/issues/8976) - Add missing `object_type` field on CustomField REST API serializer +* [#8978](https://github.com/netbox-community/netbox/issues/8978) - Fix instantiation of front ports when provisioning a module +* [#9007](https://github.com/netbox-community/netbox/issues/9007) - Fix FieldError exception when instantiating a device type with nested inventory items ### Other Changes @@ -173,6 +234,8 @@ Where it is desired to limit the range of available VLANs within a group, users * `/api/dcim/module-types/` * `/api/ipam/service-templates/` * `/api/ipam/vlan-groups//available-vlans/` +* circuits.Provider + * Added `asns` field * circuits.ProviderNetwork * Added `service_id` field * dcim.ConsolePort @@ -200,8 +263,14 @@ Where it is desired to limit the range of available VLANs within a group, users * Added `data_type` and `object_type` fields * extras.CustomLink * Added `enabled` field +* extras.JournalEntry + * Added `custom_fields` and `tags` fields +* ipam.ASN + * Added `provider_count` field * ipam.VLANGroup * Added the `/availables-vlans/` endpoint - * Added the `min_vid` and `max_vid` fields + * Added `min_vid` and `max_vid` fields +* tenancy.Contact + * Added `link` field * virtualization.VMInterface * Added `vrf` field diff --git a/mkdocs.yml b/mkdocs.yml index 3af855d3b..f66700095 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,6 +19,7 @@ theme: icon: material/lightbulb name: Switch to Light Mode plugins: + - search - mkdocstrings: handlers: python: @@ -117,7 +118,10 @@ nav: - GraphQL API: 'plugins/development/graphql-api.md' - Background Tasks: 'plugins/development/background-tasks.md' - Administration: - - Authentication: 'administration/authentication.md' + - Authentication: + - Overview: 'administration/authentication/overview.md' + - Microsoft Azure AD: 'administration/authentication/microsoft-azure-ad.md' + - Okta: 'administration/authentication/okta.md' - Permissions: 'administration/permissions.md' - Housekeeping: 'administration/housekeeping.md' - Replicating NetBox: 'administration/replicating-netbox.md' diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 539ff9466..19570f067 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -4,7 +4,9 @@ from circuits.choices import CircuitStatusChoices from circuits.models import * from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.serializers import LinkTerminationSerializer -from netbox.api import ChoiceField +from ipam.models import ASN +from ipam.api.nested_serializers import NestedASNSerializer +from netbox.api import ChoiceField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from .nested_serializers import * @@ -16,13 +18,21 @@ from .nested_serializers import * class ProviderSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') + asns = SerializedPKRelatedField( + queryset=ASN.objects.all(), + serializer=NestedASNSerializer, + required=False, + many=True + ) + + # Related object counts circuit_count = serializers.IntegerField(read_only=True) class Meta: model = Provider fields = [ 'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', + 'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', ] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 99687fe9d..3573c05e3 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -21,7 +21,7 @@ class CircuitsRootView(APIRootView): # class ProviderViewSet(NetBoxModelViewSet): - queryset = Provider.objects.prefetch_related('tags').annotate( + queryset = Provider.objects.prefetch_related('asns', 'tags').annotate( circuit_count=count_related(Circuit, 'provider') ) serializer_class = serializers.ProviderSerializer diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 9bf2bb439..b7fa100a8 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -3,6 +3,7 @@ from django.db.models import Q from dcim.filtersets import CableTerminationFilterSet from dcim.models import Region, Site, SiteGroup +from ipam.models import ASN from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter @@ -56,6 +57,11 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): to_field_name='slug', label='Site (slug)', ) + asn_id = django_filters.ModelMultipleChoiceFilter( + field_name='asns', + queryset=ASN.objects.all(), + label='ASN (ID)', + ) class Meta: model = Provider diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 8fc76e940..6e283219a 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -1,10 +1,15 @@ from django import forms +from django.utils.translation import gettext as _ from circuits.choices import CircuitStatusChoices from circuits.models import * +from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea, StaticSelect +from utilities.forms import ( + add_blank_choice, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, + StaticSelect, +) __all__ = ( 'CircuitBulkEditForm', @@ -17,7 +22,12 @@ __all__ = ( class ProviderBulkEditForm(NetBoxModelBulkEditForm): asn = forms.IntegerField( required=False, - label='ASN' + label='ASN (legacy)' + ) + asns = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + label=_('ASNs'), + required=False ) account = forms.CharField( max_length=30, @@ -45,10 +55,10 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): model = Provider fieldsets = ( - (None, ('asn', 'account', 'portal_url', 'noc_contact', 'admin_contact')), + (None, ('asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact')), ) nullable_fields = ( - 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', ) diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 4f0d99895..ca3b003b9 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext as _ from circuits.choices import CircuitStatusChoices from circuits.models import * from dcim.models import Region, Site, SiteGroup +from ipam.models import ASN from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm, ContactModelFilterForm from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField @@ -45,7 +46,12 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): ) asn = forms.IntegerField( required=False, - label=_('ASN') + label=_('ASN (legacy)') + ) + asn_id = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + required=False, + label=_('ASNs') ) tag = TagFilterField(model) diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index c7d7f8438..8fd5fb92d 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -1,8 +1,9 @@ from django import forms +from django.utils.translation import gettext as _ from circuits.models import * from dcim.models import Region, Site, SiteGroup -from extras.models import Tag +from ipam.models import ASN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( @@ -21,17 +22,22 @@ __all__ = ( class ProviderForm(NetBoxModelForm): slug = SlugField() + asns = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + label=_('ASNs'), + required=False + ) comments = CommentField() fieldsets = ( - ('Provider', ('name', 'slug', 'asn', 'tags')), + ('Provider', ('name', 'slug', 'asn', 'asns', 'tags')), ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), ) class Meta: model = Provider fields = [ - 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', + 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asns', 'comments', 'tags', ] widgets = { 'noc_contact': SmallTextarea( diff --git a/netbox/circuits/migrations/0032_provider_service_id.py b/netbox/circuits/migrations/0032_provider_service_id.py index 91410bd96..58936d1bd 100644 --- a/netbox/circuits/migrations/0032_provider_service_id.py +++ b/netbox/circuits/migrations/0032_provider_service_id.py @@ -5,6 +5,7 @@ class Migration(migrations.Migration): dependencies = [ ('circuits', '0004_rename_cable_peer'), + ('dcim', '0145_site_remove_deprecated_fields'), ] operations = [ diff --git a/netbox/circuits/migrations/0035_provider_asns.py b/netbox/circuits/migrations/0035_provider_asns.py new file mode 100644 index 000000000..afb0da4d6 --- /dev/null +++ b/netbox/circuits/migrations/0035_provider_asns.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.3 on 2022-03-30 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0057_created_datetimefield'), + ('circuits', '0034_created_datetimefield'), + ] + + operations = [ + migrations.AddField( + model_name='provider', + name='asns', + field=models.ManyToManyField(blank=True, related_name='providers', to='ipam.asn'), + ), + ] diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index 9cf4bf5c1..4211a54a6 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -30,6 +30,11 @@ class Provider(NetBoxModel): verbose_name='ASN', help_text='32-bit autonomous system number' ) + asns = models.ManyToManyField( + to='ipam.ASN', + related_name='providers', + blank=True + ) account = models.CharField( max_length=30, blank=True, diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py index d5b4329fb..e97ade7d8 100644 --- a/netbox/circuits/tables/providers.py +++ b/netbox/circuits/tables/providers.py @@ -14,8 +14,20 @@ class ProviderTable(NetBoxTable): name = tables.Column( linkify=True ) - circuit_count = tables.Column( + asns = tables.ManyToManyColumn( + linkify_item=True, + verbose_name='ASNs' + ) + asn_count = columns.LinkedCountColumn( + accessor=tables.A('asns__count'), + viewname='ipam:asn_list', + url_params={'provider_id': 'pk'}, + verbose_name='ASN Count' + ) + circuit_count = columns.LinkedCountColumn( accessor=Accessor('count_circuits'), + viewname='circuits:circuit_list', + url_params={'provider_id': 'pk'}, verbose_name='Circuits' ) comments = columns.MarkdownColumn() @@ -29,8 +41,8 @@ class ProviderTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Provider fields = ( - 'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', - 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asn_count', + 'circuit_count', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 830c7d9ca..02b489ac4 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -3,6 +3,7 @@ from django.urls import reverse from circuits.choices import * from circuits.models import * from dcim.models import Site +from ipam.models import ASN, RIR from utilities.testing import APITestCase, APIViewTestCases @@ -18,20 +19,6 @@ class AppTest(APITestCase): class ProviderTest(APIViewTestCases.APIViewTestCase): model = Provider brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] - create_data = [ - { - 'name': 'Provider 4', - 'slug': 'provider-4', - }, - { - 'name': 'Provider 5', - 'slug': 'provider-5', - }, - { - 'name': 'Provider 6', - 'slug': 'provider-6', - }, - ] bulk_update_data = { 'asn': 1234, } @@ -39,6 +26,12 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): + rir = RIR.objects.create(name='RFC 6996', is_private=True) + asns = [ + ASN(asn=65000 + i, rir=rir) for i in range(8) + ] + ASN.objects.bulk_create(asns) + providers = ( Provider(name='Provider 1', slug='provider-1'), Provider(name='Provider 2', slug='provider-2'), @@ -46,6 +39,24 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): ) Provider.objects.bulk_create(providers) + cls.create_data = [ + { + 'name': 'Provider 4', + 'slug': 'provider-4', + 'asns': [asns[0].pk, asns[1].pk], + }, + { + 'name': 'Provider 5', + 'slug': 'provider-5', + 'asns': [asns[2].pk, asns[3].pk], + }, + { + 'name': 'Provider 6', + 'slug': 'provider-6', + 'asns': [asns[4].pk, asns[5].pk], + }, + ] + class CircuitTypeTest(APIViewTestCases.APIViewTestCase): model = CircuitType diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 20416c4e6..205236712 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -4,6 +4,7 @@ from circuits.choices import * from circuits.filtersets import * from circuits.models import * from dcim.models import Cable, Region, Site, SiteGroup +from ipam.models import ASN, RIR from tenancy.models import Tenant, TenantGroup from utilities.testing import ChangeLoggedFilterSetTests @@ -15,6 +16,14 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + rir = RIR.objects.create(name='RFC 6996', is_private=True) + asns = ( + ASN(asn=64512, rir=rir), + ASN(asn=64513, rir=rir), + ASN(asn=64514, rir=rir), + ) + ASN.objects.bulk_create(asns) + providers = ( Provider(name='Provider 1', slug='provider-1', asn=65001, account='1234'), Provider(name='Provider 2', slug='provider-2', asn=65002, account='2345'), @@ -23,6 +32,9 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): Provider(name='Provider 5', slug='provider-5', asn=65005, account='5678'), ) Provider.objects.bulk_create(providers) + providers[0].asns.set([asns[0]]) + providers[1].asns.set([asns[1]]) + providers[2].asns.set([asns[2]]) regions = ( Region(name='Test Region 1', slug='test-region-1'), @@ -70,10 +82,15 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'slug': ['provider-1', 'provider-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_asn(self): + def test_asn(self): # Legacy field params = {'asn': ['65001', '65002']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_asn_id(self): # ASN object assignment + asns = ASN.objects.all()[:2] + params = {'asn_id': [asns[0].pk, asns[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_account(self): params = {'account': ['1234', '2345']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index d8ad27d72..17c846c86 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -6,6 +6,7 @@ from django.urls import reverse from circuits.choices import * from circuits.models import * from dcim.models import Cable, Interface, Site +from ipam.models import ASN, RIR from utilities.testing import ViewTestCases, create_tags, create_test_device @@ -15,11 +16,21 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - Provider.objects.bulk_create([ + rir = RIR.objects.create(name='RFC 6996', is_private=True) + asns = [ + ASN(asn=65000 + i, rir=rir) for i in range(8) + ] + ASN.objects.bulk_create(asns) + + providers = ( Provider(name='Provider 1', slug='provider-1', asn=65001), Provider(name='Provider 2', slug='provider-2', asn=65002), Provider(name='Provider 3', slug='provider-3', asn=65003), - ]) + ) + Provider.objects.bulk_create(providers) + providers[0].asns.set([asns[0], asns[1]]) + providers[1].asns.set([asns[2], asns[3]]) + providers[2].asns.set([asns[4], asns[5]]) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -27,6 +38,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'name': 'Provider X', 'slug': 'provider-x', 'asn': 65123, + 'asns': [asns[6].pk, asns[7].pk], 'account': '1234', 'portal_url': 'http://example.com/portal', 'noc_contact': 'noc@example.com', diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d6f8b347c..813c946a3 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -134,10 +134,10 @@ class SiteSerializer(NetBoxModelSerializer): class Meta: model = Site fields = [ - 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asns', - 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', - 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', - 'rack_count', 'virtualmachine_count', 'vlan_count', + 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'asns', 'tags', + 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count', + 'virtualmachine_count', 'vlan_count', ] diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index c5f70c1b6..b0aa1c60c 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -345,6 +345,10 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_DC = 'dc-terminal' # Proprietary TYPE_SAF_D_GRID = 'saf-d-grid' + TYPE_NEUTRIK_POWERCON_20A = 'neutrik-powercon-20' + TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32' + TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1' + TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top' # Other TYPE_HARDWIRED = 'hardwired' @@ -456,6 +460,10 @@ class PowerPortTypeChoices(ChoiceSet): )), ('Proprietary', ( (TYPE_SAF_D_GRID, 'Saf-D-Grid'), + (TYPE_NEUTRIK_POWERCON_20A, 'Neutrik powerCON (20A)'), + (TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'), + (TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'), + (TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'), )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), @@ -561,6 +569,10 @@ class PowerOutletTypeChoices(ChoiceSet): # Proprietary TYPE_HDOT_CX = 'hdot-cx' TYPE_SAF_D_GRID = 'saf-d-grid' + TYPE_NEUTRIK_POWERCON_20A = 'neutrik-powercon-20a' + TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32a' + TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1' + TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top' # Other TYPE_HARDWIRED = 'hardwired' @@ -665,6 +677,10 @@ class PowerOutletTypeChoices(ChoiceSet): ('Proprietary', ( (TYPE_HDOT_CX, 'HDOT Cx'), (TYPE_SAF_D_GRID, 'Saf-D-Grid'), + (TYPE_NEUTRIK_POWERCON_20A, 'Neutrik powerCON (20A)'), + (TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'), + (TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'), + (TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'), )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 2f888390e..54f533a7f 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -435,6 +435,10 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): method='_device_bays', label='Has device bays', ) + inventory_items = django_filters.BooleanFilter( + method='_inventory_items', + label='Has inventory items', + ) class Meta: model = DeviceType @@ -479,6 +483,9 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): def _device_bays(self, queryset, name, value): return queryset.exclude(devicebaytemplates__isnull=value) + def _inventory_items(self, queryset, name, value): + return queryset.exclude(inventoryitemtemplates__isnull=value) + class ModuleTypeFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( @@ -751,6 +758,11 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter to_field_name='slug', label='Role (slug)', ) + parent_device_id = django_filters.ModelMultipleChoiceFilter( + field_name='parent_bay__device', + queryset=Device.objects.all(), + label='Parent Device (ID)', + ) platform_id = django_filters.ModelMultipleChoiceFilter( queryset=Platform.objects.all(), label='Platform (ID)', @@ -1090,8 +1102,8 @@ class PathEndpointFilterSet(django_filters.FilterSet): class ConsolePortFilterSet( - NetBoxModelFilterSet, ModularDeviceComponentFilterSet, + NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet ): @@ -1106,8 +1118,8 @@ class ConsolePortFilterSet( class ConsoleServerPortFilterSet( - NetBoxModelFilterSet, ModularDeviceComponentFilterSet, + NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet ): @@ -1122,8 +1134,8 @@ class ConsoleServerPortFilterSet( class PowerPortFilterSet( - NetBoxModelFilterSet, ModularDeviceComponentFilterSet, + NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet ): @@ -1138,8 +1150,8 @@ class PowerPortFilterSet( class PowerOutletFilterSet( - NetBoxModelFilterSet, ModularDeviceComponentFilterSet, + NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet ): @@ -1158,8 +1170,8 @@ class PowerOutletFilterSet( class InterfaceFilterSet( - NetBoxModelFilterSet, ModularDeviceComponentFilterSet, + NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet ): @@ -1286,8 +1298,8 @@ class InterfaceFilterSet( class FrontPortFilterSet( - NetBoxModelFilterSet, ModularDeviceComponentFilterSet, + NetBoxModelFilterSet, CableTerminationFilterSet ): type = django_filters.MultipleChoiceFilter( @@ -1301,8 +1313,8 @@ class FrontPortFilterSet( class RearPortFilterSet( - NetBoxModelFilterSet, ModularDeviceComponentFilterSet, + NetBoxModelFilterSet, CableTerminationFilterSet ): type = django_filters.MultipleChoiceFilter( @@ -1315,21 +1327,21 @@ class RearPortFilterSet( fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description'] -class ModuleBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): +class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): class Meta: model = ModuleBay fields = ['id', 'name', 'label', 'description'] -class DeviceBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): +class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): class Meta: model = DeviceBay fields = ['id', 'name', 'label', 'description'] -class InventoryItemFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): +class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItem.objects.all(), label='Parent inventory item (ID)', diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 92496e6b0..9e4f5e400 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -115,6 +115,18 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): label=_('ASNs'), required=False ) + contact_name = forms.CharField( + max_length=50, + required=False + ) + contact_phone = forms.CharField( + max_length=20, + required=False + ) + contact_email = forms.EmailField( + required=False, + label='Contact E-mail' + ) description = forms.CharField( max_length=100, required=False @@ -912,9 +924,33 @@ class InventoryItemTemplateBulkEditForm(BulkEditForm): # Device components # +class ComponentBulkEditForm(NetBoxModelBulkEditForm): + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + required=False, + disabled=True, + widget=forms.HiddenInput() + ) + module = forms.ModelChoiceField( + queryset=Module.objects.all(), + required=False + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit module queryset to Modules which belong to the parent Device + if 'device' in self.initial: + device = Device.objects.filter(pk=self.initial['device']).first() + self.fields['module'].queryset = Module.objects.filter(device=device) + else: + self.fields['module'].choices = () + self.fields['module'].widget.attrs['disabled'] = True + + class ConsolePortBulkEditForm( form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']), - NetBoxModelBulkEditForm + ComponentBulkEditForm ): mark_connected = forms.NullBooleanField( required=False, @@ -923,14 +959,14 @@ class ConsolePortBulkEditForm( model = ConsolePort fieldsets = ( - (None, ('type', 'label', 'speed', 'description', 'mark_connected')), + (None, ('module', 'type', 'label', 'speed', 'description', 'mark_connected')), ) - nullable_fields = ('label', 'description') + nullable_fields = ('module', 'label', 'description') class ConsoleServerPortBulkEditForm( form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']), - NetBoxModelBulkEditForm + ComponentBulkEditForm ): mark_connected = forms.NullBooleanField( required=False, @@ -939,14 +975,14 @@ class ConsoleServerPortBulkEditForm( model = ConsoleServerPort fieldsets = ( - (None, ('type', 'label', 'speed', 'description', 'mark_connected')), + (None, ('module', 'type', 'label', 'speed', 'description', 'mark_connected')), ) - nullable_fields = ('label', 'description') + nullable_fields = ('module', 'label', 'description') class PowerPortBulkEditForm( form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']), - NetBoxModelBulkEditForm + ComponentBulkEditForm ): mark_connected = forms.NullBooleanField( required=False, @@ -955,22 +991,16 @@ class PowerPortBulkEditForm( model = PowerPort fieldsets = ( - (None, ('type', 'label', 'description', 'mark_connected')), + (None, ('module', 'type', 'label', 'description', 'mark_connected')), ('Power', ('maximum_draw', 'allocated_draw')), ) - nullable_fields = ('label', 'description') + nullable_fields = ('module', 'label', 'description') class PowerOutletBulkEditForm( form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']), - NetBoxModelBulkEditForm + ComponentBulkEditForm ): - device = forms.ModelChoiceField( - queryset=Device.objects.all(), - required=False, - disabled=True, - widget=forms.HiddenInput() - ) mark_connected = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect @@ -978,10 +1008,10 @@ class PowerOutletBulkEditForm( model = PowerOutlet fieldsets = ( - (None, ('type', 'label', 'description', 'mark_connected')), + (None, ('module', 'type', 'label', 'description', 'mark_connected')), ('Power', ('feed_leg', 'power_port')), ) - nullable_fields = ('label', 'type', 'feed_leg', 'power_port', 'description') + nullable_fields = ('module', 'label', 'type', 'feed_leg', 'power_port', 'description') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1001,14 +1031,8 @@ class InterfaceBulkEditForm( 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', ]), - NetBoxModelBulkEditForm + ComponentBulkEditForm ): - device = forms.ModelChoiceField( - queryset=Device.objects.all(), - required=False, - disabled=True, - widget=forms.HiddenInput() - ) enabled = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect @@ -1059,7 +1083,7 @@ class InterfaceBulkEditForm( model = Interface fieldsets = ( - (None, ('type', 'label', 'speed', 'duplex', 'description')), + (None, ('module', 'type', 'label', 'speed', 'duplex', 'description')), ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), @@ -1067,8 +1091,9 @@ class InterfaceBulkEditForm( ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), ) nullable_fields = ( - 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', 'mode', - 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf', + 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', + 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', + 'vrf', ) def __init__(self, *args, **kwargs): @@ -1133,24 +1158,24 @@ class InterfaceBulkEditForm( class FrontPortBulkEditForm( form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']), - NetBoxModelBulkEditForm + ComponentBulkEditForm ): model = FrontPort fieldsets = ( - (None, ('type', 'label', 'color', 'description', 'mark_connected')), + (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')), ) - nullable_fields = ('label', 'description') + nullable_fields = ('module', 'label', 'description') class RearPortBulkEditForm( form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']), - NetBoxModelBulkEditForm + ComponentBulkEditForm ): model = RearPort fieldsets = ( - (None, ('type', 'label', 'color', 'description', 'mark_connected')), + (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')), ) - nullable_fields = ('label', 'description') + nullable_fields = ('module', 'label', 'description') class ModuleBayBulkEditForm( @@ -1179,6 +1204,10 @@ class InventoryItemBulkEditForm( form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']), NetBoxModelBulkEditForm ): + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False + ) role = DynamicModelChoiceField( queryset=InventoryItemRole.objects.all(), required=False @@ -1190,7 +1219,7 @@ class InventoryItemBulkEditForm( model = InventoryItem fieldsets = ( - (None, ('label', 'role', 'manufacturer', 'part_id', 'description')), + (None, ('device', 'label', 'role', 'manufacturer', 'part_id', 'description')), ) nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index d9c738cc2..b28c16fad 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -651,11 +651,11 @@ class InterfaceCSVForm(NetBoxModelCSVForm): super().__init__(data, *args, **kwargs) if data: - # Limit interface choices for parent, bridge and lag to device only - params = {} - if data.get('device'): - params[f"device__{self.fields['device'].to_field_name}"] = data.get('device') - if params: + # Limit choices for parent, bridge, and LAG interfaces to the assigned device + if device := data.get('device'): + params = { + f"device__{self.fields['device'].to_field_name}": device + } self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params) self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params) self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index d5335947a..7f30941a2 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -331,7 +331,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', - 'pass_through_ports', + 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', )), ) manufacturer_id = DynamicModelMultipleChoiceField( @@ -392,6 +392,27 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + device_bays = forms.NullBooleanField( + required=False, + label='Has device bays', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + module_bays = forms.NullBooleanField( + required=False, + label='Has module bays', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + inventory_items = forms.NullBooleanField( + required=False, + label='Has inventory items', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 6cdcb372c..fe9daf938 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -7,7 +7,6 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * from dcim.models import * -from extras.models import Tag from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm @@ -1025,10 +1024,10 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): parent = DynamicModelChoiceField( - queryset=InventoryItem.objects.all(), + queryset=InventoryItemTemplate.objects.all(), required=False, query_params={ - 'device_id': '$device' + 'devicetype_id': '$device_type' } ) role = DynamicModelChoiceField( @@ -1050,11 +1049,6 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): widget=forms.HiddenInput ) - fieldsets = ( - ('Inventory Item', ('device_type', 'parent', 'name', 'label', 'role', 'description')), - ('Hardware', ('manufacturer', 'part_id')), - ) - class Meta: model = InventoryItemTemplate fields = [ @@ -1368,6 +1362,9 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): class InventoryItemForm(NetBoxModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all() + ) parent = DynamicModelChoiceField( queryset=InventoryItem.objects.all(), required=False, @@ -1405,9 +1402,6 @@ class InventoryItemForm(NetBoxModelForm): 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'component_type', 'component_id', 'tags', ] - widgets = { - 'device': forms.HiddenInput(), - } # diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 8618a3b9d..e3e9c1179 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -1,7 +1,6 @@ from django import forms from dcim.models import * -from extras.models import Tag from netbox.forms import NetBoxModelForm from utilities.forms import ( BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, @@ -12,6 +11,7 @@ __all__ = ( 'DeviceComponentCreateForm', 'FrontPortCreateForm', 'FrontPortTemplateCreateForm', + 'InventoryItemCreateForm', 'ModularComponentTemplateCreateForm', 'ModuleBayCreateForm', 'ModuleBayTemplateCreateForm', @@ -199,6 +199,11 @@ class ModuleBayCreateForm(DeviceComponentCreateForm): field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern') +class InventoryItemCreateForm(ComponentCreateForm): + # Device is assigned by the model form + field_order = ('name_pattern', 'label_pattern') + + class VirtualChassisCreateForm(NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), diff --git a/netbox/dcim/migrations/0145_site_remove_deprecated_fields.py b/netbox/dcim/migrations/0145_site_remove_deprecated_fields.py index 29353107c..86918447d 100644 --- a/netbox/dcim/migrations/0145_site_remove_deprecated_fields.py +++ b/netbox/dcim/migrations/0145_site_remove_deprecated_fields.py @@ -1,4 +1,32 @@ +import os + from django.db import migrations +from django.db.utils import DataError + + +def check_legacy_data(apps, schema_editor): + """ + Abort the migration if any legacy site fields still contain data. + """ + Site = apps.get_model('dcim', 'Site') + + site_count = Site.objects.exclude(asn__isnull=True).count() + if site_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: + raise DataError( + f"Unable to proceed with deleting asn field from Site model: Found {site_count} sites with " + f"legacy ASN data. Please ensure all legacy site ASN data has been migrated to ASN objects " + f"before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA environment variable to bypass " + f"this safeguard and delete all legacy site ASN data." + ) + + site_count = Site.objects.exclude(contact_name='', contact_phone='', contact_email='').count() + if site_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: + raise DataError( + f"Unable to proceed with deleting contact fields from Site model: Found {site_count} sites " + f"with legacy contact data. Please ensure all legacy site contact data has been migrated to " + f"contact objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA environment " + f"variable to bypass this safeguard and delete all legacy site contact data." + ) class Migration(migrations.Migration): @@ -8,6 +36,10 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RunPython( + code=check_legacy_data, + reverse_code=migrations.RunPython.noop + ), migrations.RemoveField( model_name='site', name='asn', diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 0538704d2..647abe148 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -124,6 +124,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel): return self.name.replace('{module}', module.module_bay.position) return self.name + def resolve_label(self, module): + if module: + return self.label.replace('{module}', module.module_bay.position) + return self.label + class ConsolePortTemplate(ModularComponentTemplateModel): """ @@ -147,7 +152,7 @@ class ConsolePortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), - label=self.label, + label=self.resolve_label(kwargs.get('module')), type=self.type, **kwargs ) @@ -175,7 +180,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), - label=self.label, + label=self.resolve_label(kwargs.get('module')), type=self.type, **kwargs ) @@ -215,7 +220,7 @@ class PowerPortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), - label=self.label, + label=self.resolve_label(kwargs.get('module')), type=self.type, maximum_draw=self.maximum_draw, allocated_draw=self.allocated_draw, @@ -280,12 +285,13 @@ class PowerOutletTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): if self.power_port: - power_port = PowerPort.objects.get(name=self.power_port.name, **kwargs) + power_port_name = self.power_port.resolve_name(kwargs.get('module')) + power_port = PowerPort.objects.get(name=power_port_name, **kwargs) else: power_port = None return self.component_model( name=self.resolve_name(kwargs.get('module')), - label=self.label, + label=self.resolve_label(kwargs.get('module')), type=self.type, power_port=power_port, feed_leg=self.feed_leg, @@ -325,7 +331,7 @@ class InterfaceTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), - label=self.label, + label=self.resolve_label(kwargs.get('module')), type=self.type, mgmt_only=self.mgmt_only, **kwargs @@ -390,12 +396,13 @@ class FrontPortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): if self.rear_port: - rear_port = RearPort.objects.get(name=self.rear_port.name, **kwargs) + rear_port_name = self.rear_port.resolve_name(kwargs.get('module')) + rear_port = RearPort.objects.get(name=rear_port_name, **kwargs) else: rear_port = None return self.component_model( name=self.resolve_name(kwargs.get('module')), - label=self.label, + label=self.resolve_label(kwargs.get('module')), type=self.type, color=self.color, rear_port=rear_port, @@ -435,7 +442,7 @@ class RearPortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), - label=self.label, + label=self.resolve_label(kwargs.get('module')), type=self.type, color=self.color, positions=self.positions, @@ -549,7 +556,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): unique_together = ('device_type', 'parent', 'name') def instantiate(self, **kwargs): - parent = InventoryItemTemplate.objects.get(name=self.parent.name, **kwargs) if self.parent else None + parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None if self.component: model = self.component.component_model component = model.objects.get(name=self.component.name, **kwargs) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index a6887a768..3ed786000 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -784,6 +784,10 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo def is_lag(self): return self.type == InterfaceTypeChoices.TYPE_LAG + @property + def is_bridge(self): + return self.type == InterfaceTypeChoices.TYPE_BRIDGE + @property def link(self): return self.cable or self.wireless_link @@ -1066,3 +1070,12 @@ class InventoryItem(MPTTModel, ComponentModel): def get_absolute_url(self): return reverse('dcim:inventoryitem', kwargs={'pk': self.pk}) + + def clean(self): + super().clean() + + # An InventoryItem cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({ + "parent": "Cannot assign self as parent." + }) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index d95063601..6ed7b349f 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -257,6 +257,7 @@ class DeviceType(NetBoxModel): { 'name': c.name, 'label': c.label, + 'position': c.position, 'description': c.description, } for c in self.modulebaytemplates.all() diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 53e3bcceb..d02bd0932 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -367,7 +367,7 @@ class Location(NestedGroupModel): to='extras.ImageAttachment' ) - clone_fields = ['site', 'parent', 'description'] + clone_fields = ['site', 'parent', 'tenant', 'description'] class Meta: ordering = ['site', 'name'] diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index ffa95c119..25ad1415d 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -739,13 +739,22 @@ class ModuleBayTable(DeviceComponentTable): linkify=True, verbose_name='Installed module' ) + module_serial = tables.Column( + accessor=tables.A('installed_module__serial') + ) + module_asset_tag = tables.Column( + accessor=tables.A('installed_module__asset_tag') + ) tags = columns.TagColumn( url_name='dcim:modulebay_list' ) class Meta(DeviceComponentTable.Meta): model = ModuleBay - fields = ('pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'description', 'tags') + fields = ( + 'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag', + 'description', 'tags', + ) default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description') @@ -756,7 +765,10 @@ class DeviceModuleBayTable(ModuleBayTable): class Meta(DeviceComponentTable.Meta): model = ModuleBay - fields = ('pk', 'id', 'name', 'label', 'position', 'installed_module', 'description', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag', + 'description', 'tags', 'actions', + ) default_columns = ('pk', 'name', 'label', 'installed_module', 'description') @@ -772,7 +784,6 @@ class InventoryItemTable(DeviceComponentTable): linkify=True ) component = tables.Column( - accessor=Accessor('component'), orderable=False, linkify=True ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index e5e703ee0..f5f5ed7bf 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -241,5 +241,7 @@ class InventoryItemTemplateTable(ComponentTemplateTable): class Meta(ComponentTemplateTable.Meta): model = InventoryItemTemplate - fields = ('pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'component', 'description', 'actions') + fields = ( + 'pk', 'name', 'label', 'parent', 'role', 'manufacturer', 'part_id', 'component', 'description', 'actions', + ) empty_text = "None" diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index d4d355474..84522480f 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -86,16 +86,16 @@ class SiteTable(NetBoxTable): group = tables.Column( linkify=True ) + asns = tables.ManyToManyColumn( + linkify_item=True, + verbose_name='ASNs' + ) asn_count = columns.LinkedCountColumn( accessor=tables.A('asns__count'), viewname='ipam:asn_list', url_params={'site_id': 'pk'}, verbose_name='ASN Count' ) - asns = tables.ManyToManyColumn( - linkify_item=True, - verbose_name='ASNs' - ) tenant = TenantColumn() comments = columns.MarkdownColumn() contacts = tables.ManyToManyColumn( diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 2e2c3baf7..8480c97bf 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -698,6 +698,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1'), DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2'), )) + # Assigned DeviceType must have parent subdevice_role + inventory_item = InventoryItemTemplate(device_type=device_types[1], name='Inventory Item 1') + inventory_item.save() def test_model(self): params = {'model': ['Model 1', 'Model 2']} @@ -784,6 +787,12 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'module_bays': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_inventory_items(self): + params = {'inventory_items': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'inventory_items': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ModuleType.objects.all() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index baaa6ce7b..2622a1405 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -14,7 +14,7 @@ from django.views.generic import View from circuits.models import Circuit from extras.views import ObjectConfigContextView -from ipam.models import ASN, IPAddress, Prefix, Service, VLAN +from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic from utilities.forms import ConfirmationForm @@ -320,6 +320,10 @@ class SiteView(generic.ObjectView): 'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(), 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(), + 'vlangroup_count': VLANGroup.objects.restrict(request.user, 'view').filter( + scope_type=ContentType.objects.get_for_model(Site), + scope_id=instance.pk + ).count(), 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=instance).count(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).count(), 'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(), @@ -338,6 +342,7 @@ class SiteView(generic.ObjectView): 'device_count', cumulative=True ).restrict(request.user, 'view').filter(site=instance) + nonracked_devices = Device.objects.filter( site=instance, position__isnull=True, @@ -353,7 +358,8 @@ class SiteView(generic.ObjectView): 'stats': stats, 'locations': locations, 'asns': asns, - 'nonracked_devices': nonracked_devices, + 'nonracked_devices': nonracked_devices.order_by('-pk')[:10], + 'total_nonracked_devices_count': nonracked_devices.count(), } @@ -431,6 +437,7 @@ class LocationView(generic.ObjectView): ).filter(pk__in=location_ids).exclude(pk=instance.pk) child_locations_table = tables.LocationTable(child_locations) child_locations_table.configure(request) + nonracked_devices = Device.objects.filter( location=instance, position__isnull=True, @@ -441,7 +448,8 @@ class LocationView(generic.ObjectView): 'rack_count': rack_count, 'device_count': device_count, 'child_locations_table': child_locations_table, - 'nonracked_devices': nonracked_devices, + 'nonracked_devices': nonracked_devices.order_by('-pk')[:10], + 'total_nonracked_devices_count': nonracked_devices.count(), } @@ -960,7 +968,7 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView): class ModuleTypeListView(generic.ObjectListView): queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( - # instance_count=count_related(Module, 'module_type') + instance_count=count_related(Module, 'module_type') ) filterset = filtersets.ModuleTypeFilterSet filterset_form = forms.ModuleTypeFilterForm @@ -1066,7 +1074,7 @@ class ModuleTypeImportView(generic.ObjectImportView): class ModuleTypeBulkEditView(generic.BulkEditView): queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( - # instance_count=count_related(Module, 'module_type') + instance_count=count_related(Module, 'module_type') ) filterset = filtersets.ModuleTypeFilterSet table = tables.ModuleTypeTable @@ -1075,7 +1083,7 @@ class ModuleTypeBulkEditView(generic.BulkEditView): class ModuleTypeBulkDeleteView(generic.BulkDeleteView): queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( - # instance_count=count_related(Module, 'module_type') + instance_count=count_related(Module, 'module_type') ) filterset = filtersets.ModuleTypeFilterSet table = tables.ModuleTypeTable @@ -2077,6 +2085,14 @@ class InterfaceView(generic.ObjectView): orderable=False ) + # Get bridge interfaces + bridge_interfaces = Interface.objects.restrict(request.user, 'view').filter(bridge=instance) + bridge_interfaces_tables = tables.InterfaceTable( + bridge_interfaces, + exclude=('device', 'parent'), + orderable=False + ) + # Get child interfaces child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance) child_interfaces_tables = tables.InterfaceTable( @@ -2101,6 +2117,7 @@ class InterfaceView(generic.ObjectView): return { 'ipaddress_table': ipaddress_table, + 'bridge_interfaces_table': bridge_interfaces_tables, 'child_interfaces_table': child_interfaces_tables, 'vlan_table': vlan_table, } @@ -2504,7 +2521,7 @@ class InventoryItemEditView(generic.ObjectEditView): class InventoryItemCreateView(generic.ComponentCreateView): queryset = InventoryItem.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.InventoryItemCreateForm model_form = forms.InventoryItemForm template_name = 'dcim/inventoryitem_create.html' diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 2c98d2a81..28902c323 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -23,21 +23,24 @@ class ConfigRevisionAdmin(admin.ModelAdmin): }), ('Banners', { 'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'), + 'classes': ('monospace',), }), ('Pagination', { 'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'), }), ('Validation', { 'fields': ('CUSTOM_VALIDATORS',), + 'classes': ('monospace',), }), ('NAPALM', { 'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'), + 'classes': ('monospace',), }), ('User Preferences', { 'fields': ('DEFAULT_USER_PREFERENCES',), }), ('Miscellaneous', { - 'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'MAPS_URL'), + 'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOBRESULT_RETENTION', 'MAPS_URL'), }), ('Config Revision', { 'fields': ('comment',), diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 36b307b39..e05d4083c 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -14,7 +14,7 @@ from extras.models import * from extras.utils import FeatureQuery from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.exceptions import SerializerNotFound -from netbox.api.serializers import BaseModelSerializer, ValidatedModelSerializer +from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup from users.api.nested_serializers import NestedUserSerializer @@ -78,15 +78,19 @@ class CustomFieldSerializer(ValidatedModelSerializer): many=True ) type = ChoiceField(choices=CustomFieldTypeChoices) + object_type = ContentTypeField( + queryset=ContentType.objects.all(), + required=False + ) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) data_type = serializers.SerializerMethodField() class Meta: model = CustomField fields = [ - 'id', 'url', 'display', 'content_types', 'type', 'data_type', 'name', 'label', 'description', 'required', - 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', - 'choices', 'created', 'last_updated', + 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'description', + 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', + 'validation_regex', 'choices', 'created', 'last_updated', ] def get_data_type(self, obj): @@ -196,7 +200,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): # Journal entries # -class JournalEntrySerializer(ValidatedModelSerializer): +class JournalEntrySerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail') assigned_object_type = ContentTypeField( queryset=ContentType.objects.all() @@ -217,7 +221,7 @@ class JournalEntrySerializer(ValidatedModelSerializer): model = JournalEntry fields = [ 'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created', - 'created_by', 'kind', 'comments', + 'created_by', 'kind', 'comments', 'tags', 'custom_fields', ] def validate(self, data): diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 4f42b4c93..688f3c7ab 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -179,7 +179,7 @@ class ReportViewSet(ViewSet): for r in JobResult.objects.filter( obj_type=report_content_type, status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES - ).defer('data') + ).order_by('name', '-created').distinct('name').defer('data') } # Iterate through all available Reports. @@ -236,7 +236,8 @@ class ReportViewSet(ViewSet): run_report, report.full_name, report_content_type, - request.user + request.user, + job_timeout=report.job_timeout ) report.result = job_result @@ -270,7 +271,7 @@ class ScriptViewSet(ViewSet): for r in JobResult.objects.filter( obj_type=script_content_type, status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES - ).defer('data').order_by('created') + ).order_by('name', '-created').distinct('name').defer('data') } flat_list = [] @@ -320,7 +321,8 @@ class ScriptViewSet(ViewSet): request.user, data=data, request=copy_safe_request(request), - commit=commit + commit=commit, + job_timeout=script.job_timeout, ) script.result = job_result serializer = serializers.ScriptDetailSerializer(script, context={'request': request}) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 8f231ffb7..25477fbda 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup -from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet +from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from tenancy.models import Tenant, TenantGroup from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -134,11 +134,7 @@ class ImageAttachmentFilterSet(BaseFilterSet): return queryset.filter(name__icontains=value) -class JournalEntryFilterSet(ChangeLoggedModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class JournalEntryFilterSet(NetBoxModelFilterSet): created = django_filters.DateTimeFromToRangeFilter() assigned_object_type = ContentTypeFilter() created_by_id = django_filters.ModelMultipleChoiceFilter( diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index e3ae9b1f3..5d66c8be8 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -7,10 +7,12 @@ from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGrou from extras.choices import * from extras.models import * from extras.utils import FeatureQuery +from netbox.forms.base import NetBoxModelFilterSetForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( - add_blank_choice, APISelectMultiple, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DateTimePicker, - DynamicModelMultipleChoiceField, FilterForm, MultipleChoiceField, StaticSelect, BOOLEAN_WITH_BLANK_CHOICES, + add_blank_choice, APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeChoiceField, + ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, FilterForm, MultipleChoiceField, + StaticSelect, TagFilterField, ) from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -237,10 +239,10 @@ class LocalConfigContextFilterForm(forms.Form): ) -class JournalEntryFilterForm(FilterForm): +class JournalEntryFilterForm(NetBoxModelFilterSetForm): model = JournalEntry fieldsets = ( - (None, ('q',)), + (None, ('q', 'tag')), ('Creation', ('created_before', 'created_after', 'created_by_id')), ('Attributes', ('assigned_object_type_id', 'kind')) ) @@ -275,6 +277,7 @@ class JournalEntryFilterForm(FilterForm): required=False, widget=StaticSelect() ) + tag = TagFilterField(model) class ObjectChangeFilterForm(FilterForm): diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 071f80453..112911f42 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -5,6 +5,7 @@ from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGrou from extras.choices import * from extras.models import * from extras.utils import FeatureQuery +from netbox.forms import NetBoxModelForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, @@ -48,6 +49,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): class Meta: model = CustomField fields = '__all__' + help_texts = { + 'type': "The type of data stored in this field. For object/multi-object fields, select the related object " + "type below." + } widgets = { 'type': StaticSelect(), 'filter_logic': StaticSelect(), @@ -215,18 +220,17 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): ] -class JournalEntryForm(BootstrapMixin, forms.ModelForm): - comments = CommentField() - +class JournalEntryForm(NetBoxModelForm): kind = forms.ChoiceField( choices=add_blank_choice(JournalEntryKindChoices), required=False, widget=StaticSelect() ) + comments = CommentField() class Meta: model = JournalEntry - fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments'] + fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'tags', 'comments'] widgets = { 'assigned_object_type': forms.HiddenInput, 'assigned_object_id': forms.HiddenInput, diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index c5f34a11e..dbaa0bee2 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -1,4 +1,5 @@ from extras import filtersets, models +from extras.graphql.mixins import CustomFieldsMixin, TagsMixin from netbox.graphql.types import BaseObjectType, ObjectType __all__ = ( @@ -54,7 +55,7 @@ class ImageAttachmentType(BaseObjectType): filterset_class = filtersets.ImageAttachmentFilterSet -class JournalEntryType(ObjectType): +class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType): class Meta: model = models.JournalEntry diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py index 0607a16c2..51d50d7e1 100644 --- a/netbox/extras/management/commands/housekeeping.py +++ b/netbox/extras/management/commands/housekeeping.py @@ -9,6 +9,7 @@ from django.db import DEFAULT_DB_ALIAS from django.utils import timezone from packaging import version +from extras.models import JobResult from extras.models import ObjectChange from netbox.config import Config @@ -63,6 +64,33 @@ class Command(BaseCommand): f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})" ) + # Delete expired JobResults + if options['verbosity']: + self.stdout.write("[*] Checking for expired jobresult records") + if config.JOBRESULT_RETENTION: + cutoff = timezone.now() - timedelta(days=config.JOBRESULT_RETENTION) + if options['verbosity'] >= 2: + self.stdout.write(f"\tRetention period: {config.JOBRESULT_RETENTION} days") + self.stdout.write(f"\tCut-off time: {cutoff}") + expired_records = JobResult.objects.filter(created__lt=cutoff).count() + if expired_records: + if options['verbosity']: + self.stdout.write( + f"\tDeleting {expired_records} expired records... ", + self.style.WARNING, + ending="" + ) + self.stdout.flush() + JobResult.objects.filter(created__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS) + if options['verbosity']: + self.stdout.write("Done.", self.style.SUCCESS) + elif options['verbosity']: + self.stdout.write("\tNo expired records found.", self.style.SUCCESS) + elif options['verbosity']: + self.stdout.write( + f"\tSkipping: No retention period specified (JOBRESULT_RETENTION = {config.JOBRESULT_RETENTION})" + ) + # Check for new releases (if enabled) if options['verbosity']: self.stdout.write("[*] Checking for latest release") diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index de7c5c91b..ee166ae6a 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -35,7 +35,8 @@ class Command(BaseCommand): run_report, report.full_name, report_content_type, - None + None, + job_timeout=report.job_timeout ) # Wait on the job to finish diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index 0d1dc5cea..12188619f 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -113,13 +113,6 @@ class Command(BaseCommand): script_content_type = ContentType.objects.get(app_label='extras', model='script') - # Delete any previous terminal state results - JobResult.objects.filter( - obj_type=script_content_type, - name=script.full_name, - status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES - ).delete() - # Create the job result job_result = JobResult.objects.create( name=script.full_name, diff --git a/netbox/extras/migrations/0068_configcontext_cluster_types.py b/netbox/extras/migrations/0068_configcontext_cluster_types.py index abe90013e..3d314991d 100644 --- a/netbox/extras/migrations/0068_configcontext_cluster_types.py +++ b/netbox/extras/migrations/0068_configcontext_cluster_types.py @@ -4,6 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ + ('dcim', '0145_site_remove_deprecated_fields'), ('virtualization', '0026_vminterface_bridge'), ('extras', '0067_customfield_min_max_values'), ] diff --git a/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py b/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py new file mode 100644 index 000000000..73a3e466c --- /dev/null +++ b/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py @@ -0,0 +1,23 @@ +import django.core.serializers.json +from django.db import migrations, models +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0072_created_datetimefield'), + ] + + operations = [ + migrations.AddField( + model_name='journalentry', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='journalentry', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 5eb5a4a02..e614a1258 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -13,13 +13,16 @@ from django.urls import reverse from django.utils import timezone from django.utils.formats import date_format from rest_framework.utils.encoders import JSONEncoder +import django_rq from extras.choices import * from extras.constants import * from extras.conditions import ConditionSet from extras.utils import FeatureQuery, image_upload from netbox.models import ChangeLoggedModel -from netbox.models.features import ExportTemplatesMixin, JobResultsMixin, WebhooksMixin +from netbox.models.features import ( + CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin, +) from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 @@ -419,7 +422,7 @@ class ImageAttachment(WebhooksMixin, ChangeLoggedModel): return objectchange -class JournalEntry(WebhooksMixin, ChangeLoggedModel): +class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, WebhooksMixin, ChangeLoggedModel): """ A historical remark concerning an object; collectively, these form an object's journal. The journal is used to preserve historical context around an object, and complements NetBox's built-in change logging. For example, you @@ -548,7 +551,8 @@ class JobResult(models.Model): job_id=uuid.uuid4() ) - func.delay(*args, job_id=str(job_result.job_id), job_result=job_result, **kwargs) + queue = django_rq.get_queue("default") + queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs) return job_result diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 2eb6584c9..0a8a8d89b 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -84,15 +84,6 @@ def run_report(job_result, *args, **kwargs): job_result.save() logging.error(f"Error during execution of report {job_result.name}") - # Delete any previous terminal state results - JobResult.objects.filter( - obj_type=job_result.obj_type, - name=job_result.name, - status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES - ).exclude( - pk=job_result.pk - ).delete() - class Report(object): """ @@ -119,6 +110,7 @@ class Report(object): } """ description = None + job_timeout = None def __init__(self): diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index f80dfaefa..4eacddbeb 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -298,6 +298,10 @@ class BaseScript: def module(cls): return cls.__module__ + @classproperty + def job_timeout(self): + return getattr(self.Meta, 'job_timeout', None) + @classmethod def _get_vars(cls): vars = {} @@ -414,7 +418,6 @@ def is_variable(obj): return isinstance(obj, ScriptVariable) -@job('default') def run_script(data, request, commit=True, *args, **kwargs): """ A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It @@ -478,15 +481,6 @@ def run_script(data, request, commit=True, *args, **kwargs): else: _run_script() - # Delete any previous terminal state results - JobResult.objects.filter( - obj_type=job_result.obj_type, - name=job_result.name, - status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES - ).exclude( - pk=job_result.pk - ).delete() - def get_scripts(use_names=False): """ @@ -494,7 +488,7 @@ def get_scripts(use_names=False): defined name in place of the actual module name. """ scripts = OrderedDict() - # Iterate through all modules within the reports path. These are the user-created files in which reports are + # Iterate through all modules within the scripts path. These are the user-created files in which reports are # defined. for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]): # Remove cached module to ensure consistency with filesystem diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 964327558..a13054d56 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -12,7 +12,6 @@ __all__ = ( 'ExportTemplateTable', 'JournalEntryTable', 'ObjectChangeTable', - 'ObjectJournalTable', 'TaggedItemTable', 'TagTable', 'WebhookTable', @@ -210,25 +209,11 @@ class ObjectChangeTable(NetBoxTable): ) -class ObjectJournalTable(NetBoxTable): - """ - Used for displaying a set of JournalEntries within the context of a single object. - """ +class JournalEntryTable(NetBoxTable): created = tables.DateTimeColumn( linkify=True, format=settings.SHORT_DATETIME_FORMAT ) - kind = columns.ChoiceFieldColumn() - comments = tables.TemplateColumn( - template_code='{{ value|markdown|truncatewords_html:50 }}' - ) - - class Meta(NetBoxTable.Meta): - model = JournalEntry - fields = ('id', 'created', 'created_by', 'kind', 'comments', 'actions') - - -class JournalEntryTable(ObjectJournalTable): assigned_object_type = columns.ContentTypeColumn( verbose_name='Object type' ) @@ -237,13 +222,22 @@ class JournalEntryTable(ObjectJournalTable): orderable=False, verbose_name='Object' ) + kind = columns.ChoiceFieldColumn() comments = columns.MarkdownColumn() + comments_short = tables.TemplateColumn( + accessor=tables.A('comments'), + template_code='{{ value|markdown|truncatewords_html:50 }}', + verbose_name='Comments (Short)' + ) + tags = columns.TagColumn( + url_name='extras:journalentry_list' + ) class Meta(NetBoxTable.Meta): model = JournalEntry fields = ( 'pk', 'id', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', - 'actions', + 'comments_short', 'tags', 'actions', ) default_columns = ( 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments' diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 0a190dd49..9825d10de 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -524,7 +524,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View): for r in JobResult.objects.filter( obj_type=report_content_type, status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES - ).defer('data') + ).order_by('name', '-created').distinct('name').defer('data') } ret = [] @@ -588,7 +588,8 @@ class ReportView(ContentTypePermissionRequiredMixin, View): run_report, report.full_name, report_content_type, - request.user + request.user, + job_timeout=report.job_timeout ) return redirect('extras:report_result', job_result_pk=job_result.pk) @@ -655,7 +656,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View): for r in JobResult.objects.filter( obj_type=script_content_type, status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES - ).defer('data') + ).order_by('name', '-created').distinct('name').defer('data') } for _scripts in scripts.values(): @@ -708,6 +709,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View): commit = form.cleaned_data.pop('_commit') script_content_type = ContentType.objects.get(app_label='extras', model='script') + job_result = JobResult.enqueue_job( run_script, script.full_name, @@ -715,7 +717,8 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View): request.user, data=form.cleaned_data, request=copy_safe_request(request), - commit=commit + commit=commit, + job_timeout=script.job_timeout, ) return redirect('extras:script_result', job_result_pk=job_result.pk) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index c26575f1f..3fa1bcc7e 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -24,12 +24,13 @@ class ASNSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') tenant = NestedTenantSerializer(required=False, allow_null=True) site_count = serializers.IntegerField(read_only=True) + provider_count = serializers.IntegerField(read_only=True) class Meta: model = ASN fields = [ - 'id', 'url', 'display', 'asn', 'site_count', 'rir', 'tenant', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', + 'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'site_count', 'provider_count', 'tags', + 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index c09cffa05..dcddec580 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -8,6 +8,7 @@ from rest_framework.response import Response from rest_framework.routers import APIRootView from rest_framework.views import APIView +from circuits.models import Provider from dcim.models import Site from ipam import filtersets from ipam.models import * @@ -32,7 +33,10 @@ class IPAMRootView(APIRootView): # class ASNViewSet(NetBoxModelViewSet): - queryset = ASN.objects.prefetch_related('tenant', 'rir').annotate(site_count=count_related(Site, 'asns')) + queryset = ASN.objects.prefetch_related('tenant', 'rir').annotate( + site_count=count_related(Site, 'asns'), + provider_count=count_related(Provider, 'asns') + ) serializer_class = serializers.ASNSerializer filterset_class = filtersets.ASNFilterSet diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index f76237ec9..152d8b726 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -106,14 +106,22 @@ class FHRPGroupProtocolChoices(ChoiceSet): PROTOCOL_HSRP = 'hsrp' PROTOCOL_GLBP = 'glbp' PROTOCOL_CARP = 'carp' + PROTOCOL_CLUSTERXL = 'clusterxl' PROTOCOL_OTHER = 'other' CHOICES = ( - (PROTOCOL_VRRP2, 'VRRPv2'), - (PROTOCOL_VRRP3, 'VRRPv3'), - (PROTOCOL_HSRP, 'HSRP'), - (PROTOCOL_GLBP, 'GLBP'), - (PROTOCOL_CARP, 'CARP'), + ('Standard', ( + (PROTOCOL_VRRP2, 'VRRPv2'), + (PROTOCOL_VRRP3, 'VRRPv3'), + (PROTOCOL_CARP, 'CARP'), + )), + ('CheckPoint', ( + (PROTOCOL_CLUSTERXL, 'ClusterXL'), + )), + ('Cisco', ( + (PROTOCOL_HSRP, 'HSRP'), + (PROTOCOL_GLBP, 'GLBP'), + )), (PROTOCOL_OTHER, 'Other'), ) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 88b586bf2..53c589bb3 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -535,6 +535,11 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): queryset=VMInterface.objects.all(), label='VM interface (ID)', ) + fhrpgroup_id = django_filters.ModelMultipleChoiceFilter( + field_name='fhrpgroup', + queryset=FHRPGroup.objects.all(), + label='FHRP group (ID)', + ) assigned_to_interface = django_filters.BooleanFilter( method='_assigned_to_interface', label='Is assigned to an interface', @@ -613,7 +618,17 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): ) def _assigned_to_interface(self, queryset, name, value): - return queryset.exclude(assigned_object_id__isnull=value) + content_types = ContentType.objects.get_for_models(Interface, VMInterface).values() + if value: + return queryset.filter( + assigned_object_type__in=content_types, + assigned_object_id__isnull=False + ) + else: + return queryset.exclude( + assigned_object_type__in=content_types, + assigned_object_id__isnull=False + ) class FHRPGroupFilterSet(NetBoxModelFilterSet): diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 57f39f8c2..bbd6bb97b 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -377,12 +377,16 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm): label=_('Rack') ) min_vid = forms.IntegerField( + required=False, min_value=VLAN_VID_MIN, max_value=VLAN_VID_MAX, + label='Minimum VID' ) max_vid = forms.IntegerField( + required=False, min_value=VLAN_VID_MIN, max_value=VLAN_VID_MAX, + label='Maximum VID' ) tag = TagFilterField(model) diff --git a/netbox/ipam/migrations/0001_squashed.py b/netbox/ipam/migrations/0001_squashed.py index 17d2f8123..545fd46c6 100644 --- a/netbox/ipam/migrations/0001_squashed.py +++ b/netbox/ipam/migrations/0001_squashed.py @@ -50,7 +50,7 @@ class Migration(migrations.Migration): ('status', models.CharField(default='active', max_length=50)), ('role', models.CharField(blank=True, max_length=50)), ('assigned_object_id', models.PositiveIntegerField(blank=True, null=True)), - ('dns_name', models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, hyphens, periods, and underscores are allowed in DNS names', regex='^[0-9A-Za-z._-]+$')])), + ('dns_name', models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names', regex='^([0-9A-Za-z_-]+|\\*)(\\.[0-9A-Za-z_-]+)*\\.?$')])), ('description', models.CharField(blank=True, max_length=200)), ], options={ diff --git a/netbox/ipam/migrations/0054_vlangroup_min_max_vids.py b/netbox/ipam/migrations/0054_vlangroup_min_max_vids.py index adbe69f4c..7b901fe13 100644 --- a/netbox/ipam/migrations/0054_vlangroup_min_max_vids.py +++ b/netbox/ipam/migrations/0054_vlangroup_min_max_vids.py @@ -7,6 +7,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ + ('dcim', '0145_site_remove_deprecated_fields'), ('ipam', '0053_asn_model'), ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index 1857b7d66..ce09c482a 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -1,8 +1,9 @@ +# Ensure that VRFs are imported before IPs/prefixes so dumpdata & loaddata work correctly from .fhrp import * +from .vrfs import * from .ip import * from .services import * from .vlans import * -from .vrfs import * __all__ = ( 'ASN', diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index ac25b113b..244bcee8e 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -113,6 +113,11 @@ class ASNTable(NetBoxTable): url_params={'asn_id': 'pk'}, verbose_name='Site Count' ) + provider_count = columns.LinkedCountColumn( + viewname='circuits:provider_list', + url_params={'asn_id': 'pk'}, + verbose_name='Provider Count' + ) sites = tables.ManyToManyColumn( linkify_item=True, verbose_name='Sites' @@ -125,10 +130,10 @@ class ASNTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ASN fields = ( - 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'tenant', 'description', 'sites', 'tags', 'created', - 'last_updated', 'actions', + 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'description', 'sites', 'tags', + 'created', 'last_updated', 'actions', ) - default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'description', 'tenant') + default_columns = ('pk', 'asn', 'rir', 'site_count', 'provider_count', 'sites', 'description', 'tenant') # diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index eaf84ee16..4bb72dce2 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -771,6 +771,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): ) VMInterface.objects.bulk_create(vminterfaces) + fhrp_groups = ( + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=101), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=102), + ) + FHRPGroup.objects.bulk_create(fhrp_groups) + tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 2', slug='tenant-group-2'), @@ -791,18 +797,22 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], assigned_object=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], assigned_object=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], assigned_object=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), + IPAddress(address='10.0.0.5/24', tenant=None, vrf=None, assigned_object=fhrp_groups[0], status=IPAddressStatusChoices.STATUS_ACTIVE), IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE), IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a', description='foobar2'), IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], assigned_object=vminterfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], assigned_object=vminterfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], assigned_object=vminterfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), + IPAddress(address='2001:db8::5/64', tenant=None, vrf=None, assigned_object=fhrp_groups[1], status=IPAddressStatusChoices.STATUS_ACTIVE), IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE), ) IPAddress.objects.bulk_create(ipaddresses) def test_family(self): + params = {'family': '4'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'family': '6'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_dns_name(self): params = {'dns_name': ['ipaddress-a', 'ipaddress-b']} @@ -814,9 +824,9 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): def test_parent(self): params = {'parent': '10.0.0.0/24'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'parent': '2001:db8::/64'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_filter_address(self): # Check IPv4 and IPv6, with and without a mask @@ -835,7 +845,7 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): def test_mask_length(self): params = {'mask_length': '24'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) def test_vrf(self): vrfs = VRF.objects.all()[:2] @@ -872,11 +882,16 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'vminterface': ['Interface 1', 'Interface 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_fhrpgroup(self): + fhrp_groups = FHRPGroup.objects.all()[:2] + params = {'fhrpgroup_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_assigned_to_interface(self): params = {'assigned_to_interface': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'assigned_to_interface': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_status(self): params = {'status': [PrefixStatusChoices.STATUS_DEPRECATED, PrefixStatusChoices.STATUS_RESERVED]} diff --git a/netbox/ipam/validators.py b/netbox/ipam/validators.py index 879e20e6a..50faea8b8 100644 --- a/netbox/ipam/validators.py +++ b/netbox/ipam/validators.py @@ -24,7 +24,7 @@ class MinPrefixLengthValidator(BaseValidator): DNSValidator = RegexValidator( - regex='^[0-9A-Za-z._-]+$', - message='Only alphanumeric characters, hyphens, periods, and underscores are allowed in DNS names', + regex=r'^([0-9A-Za-z_-]+|\*)(\.[0-9A-Za-z_-]+)*\.?$', + message='Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names', code='invalid' ) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 9faa35ac6..57a682c94 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -4,6 +4,8 @@ from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from circuits.models import Provider +from circuits.tables import ProviderTable from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from dcim.tables import SiteTable @@ -156,8 +158,8 @@ class RIRView(generic.ObjectView): queryset = RIR.objects.all() def get_extra_context(self, request, instance): - aggregates = Aggregate.objects.restrict(request.user, 'view').filter( - rir=instance + aggregates = Aggregate.objects.restrict(request.user, 'view').filter(rir=instance).annotate( + child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ()) ) aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization')) aggregates_table.configure(request) @@ -206,6 +208,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView): class ASNListView(generic.ObjectListView): queryset = ASN.objects.annotate( site_count=count_related(Site, 'asns'), + provider_count=count_related(Provider, 'asns') ) filterset = filtersets.ASNFilterSet filterset_form = forms.ASNFilterForm @@ -216,13 +219,21 @@ class ASNView(generic.ObjectView): queryset = ASN.objects.all() def get_extra_context(self, request, instance): + # Gather assigned Sites sites = instance.sites.restrict(request.user, 'view') sites_table = SiteTable(sites) sites_table.configure(request) + # Gather assigned Providers + providers = instance.providers.restrict(request.user, 'view') + providers_table = ProviderTable(providers) + providers_table.configure(request) + return { 'sites_table': sites_table, - 'sites_count': sites.count() + 'sites_count': sites.count(), + 'providers_table': providers_table, + 'providers_count': providers.count(), } @@ -794,7 +805,7 @@ class VLANGroupView(generic.ObjectView): vlans_count = vlans.count() vlans = add_available_vlans(vlans, vlan_group=instance) - vlans_table = tables.VLANTable(vlans, exclude=('site', 'group', 'prefixes')) + vlans_table = tables.VLANTable(vlans, exclude=('group',)) if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): vlans_table.columns.show('pk') vlans_table.configure(request) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index acb04ce34..a13e8d192 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -13,8 +13,46 @@ from utilities.permissions import permission_is_exempt, resolve_permission, reso UserModel = get_user_model() +AUTH_BACKEND_ATTRS = { + # backend name: title, MDI icon name + 'amazon': ('Amazon AWS', 'aws'), + 'apple': ('Apple', 'apple'), + 'auth0': ('Auth0', None), + 'azuread-oauth2': ('Microsoft Azure AD', 'microsoft'), + 'azuread-b2c-oauth2': ('Microsoft Azure AD', 'microsoft'), + 'azuread-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'), + 'bitbucket': ('BitBucket', 'bitbucket'), + 'bitbucket-oauth2': ('BitBucket', 'bitbucket'), + 'digitalocean': ('DigitalOcean', 'digital-ocean'), + 'docker': ('Docker', 'docker'), + 'github': ('GitHub', 'docker'), + 'github-app': ('GitHub', 'github'), + 'github-org': ('GitHub', 'github'), + 'github-team': ('GitHub', 'github'), + 'github-enterprise': ('GitHub Enterprise', 'github'), + 'github-enterprise-org': ('GitHub Enterprise', 'github'), + 'github-enterprise-team': ('GitHub Enterprise', 'github'), + 'gitlab': ('GitLab', 'gitlab'), + 'google-oauth2': ('Google', 'google'), + 'google-openidconnect': ('Google', 'google'), + 'hubspot': ('HubSpot', 'hubspot'), + 'keycloak': ('Keycloak', None), + 'microsoft-graph': ('Microsoft Graph', 'microsoft'), + 'okta': ('Okta', None), + 'okta-openidconnect': ('Okta (OIDC)', None), + 'salesforce-oauth2': ('Salesforce', 'salesforce'), +} -class ObjectPermissionMixin(): + +def get_auth_backend_display(name): + """ + Return the user-friendly name and icon name for a remote authentication backend, if known. Defaults to the + raw backend name and no icon. + """ + return AUTH_BACKEND_ATTRS.get(name, (name, None)) + + +class ObjectPermissionMixin: def get_all_permissions(self, user_obj, obj=None): if not user_obj.is_active or user_obj.is_anonymous: diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index f61301949..68c96b38a 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -22,7 +22,9 @@ PARAMS = ( default='', description="Additional content to display on the login page", field_kwargs={ - 'widget': forms.Textarea(), + 'widget': forms.Textarea( + attrs={'class': 'vLargeTextField'} + ), }, ), ConfigParam( @@ -31,7 +33,9 @@ PARAMS = ( default='', description="Additional content to display at the top of every page", field_kwargs={ - 'widget': forms.Textarea(), + 'widget': forms.Textarea( + attrs={'class': 'vLargeTextField'} + ), }, ), ConfigParam( @@ -40,7 +44,9 @@ PARAMS = ( default='', description="Additional content to display at the bottom of every page", field_kwargs={ - 'widget': forms.Textarea(), + 'widget': forms.Textarea( + attrs={'class': 'vLargeTextField'} + ), }, ), @@ -109,7 +115,12 @@ PARAMS = ( label='Custom validators', default={}, description="Custom validation rules (JSON)", - field=forms.JSONField + field=forms.JSONField, + field_kwargs={ + 'widget': forms.Textarea( + attrs={'class': 'vLargeTextField'} + ), + }, ), # NAPALM @@ -137,7 +148,12 @@ PARAMS = ( label='NAPALM arguments', default={}, description="Additional arguments to pass when invoking a NAPALM driver (as JSON data)", - field=forms.JSONField + field=forms.JSONField, + field_kwargs={ + 'widget': forms.Textarea( + attrs={'class': 'vLargeTextField'} + ), + }, ), # User preferences @@ -171,6 +187,13 @@ PARAMS = ( description="Days to retain changelog history (set to zero for unlimited)", field=forms.IntegerField ), + ConfigParam( + name='JOBRESULT_RETENTION', + label='Job result retention', + default=90, + description="Days to retain job result history (set to zero for unlimited)", + field=forms.IntegerField + ), ConfigParam( name='MAPS_URL', label='Maps URL', diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index a9436dfb2..e054dc9da 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -67,7 +67,9 @@ DCIM_TYPES = OrderedDict( 'url': 'dcim:site_list', }), ('rack', { - 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role'), + 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate( + device_count=count_related(Device, 'rack') + ), 'filterset': RackFilterSet, 'table': RackTable, 'url': 'dcim:rack_list', diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index c842c6c06..0e232af1d 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -61,6 +61,8 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm): """ Base form for creating a NetBox objects from CSV data. Used for bulk importing. """ + tags = None # Temporary fix in lieu of tag import support (see #9158) + def _get_form_field(self, customfield): return customfield.to_form_field(for_csv_import=True) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e78135aa3..e83ccdc73 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -14,12 +14,19 @@ from django.core.validators import URLValidator from netbox.config import PARAMS +# Monkey patch to fix Django 4.0 support for graphene-django (see +# https://github.com/graphql-python/graphene-django/issues/1284) +# TODO: Remove this when graphene-django 2.16 becomes available +import django +from django.utils.encoding import force_str +django.utils.encoding.force_text = force_str + # # Environment setup # -VERSION = '3.2.0-beta2' +VERSION = '3.2.2-dev' # Hostname HOSTNAME = platform.node() diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index 5d388be35..fad347c36 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -19,8 +19,7 @@ from circuits.models import Circuit, Provider from dcim.models import ( Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site, ) -from extras.choices import JobResultStatusChoices -from extras.models import ObjectChange, JobResult +from extras.models import ObjectChange from extras.tables import ObjectChangeTable from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES @@ -48,13 +47,6 @@ class HomeView(View): pk__lt=F('_path__destination_id') ) - # Report Results - report_content_type = ContentType.objects.get(app_label='extras', model='report') - report_results = JobResult.objects.filter( - obj_type=report_content_type, - status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES - ).defer('data')[:10] - def build_stats(): org = ( ("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count), @@ -150,7 +142,6 @@ class HomeView(View): return render(request, self.template_name, { 'search_form': SearchForm(), 'stats': build_stats(), - 'report_results': report_results, 'changelog_table': changelog_table, 'new_release': new_release, }) diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index 73ced0d65..85e675a69 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -86,8 +86,10 @@ class ObjectJournalView(View): assigned_object_type=content_type, assigned_object_id=obj.pk ) - journalentry_table = tables.ObjectJournalTable(journalentries) + journalentry_table = tables.JournalEntryTable(journalentries, user=request.user) journalentry_table.configure(request) + journalentry_table.columns.hide('assigned_object_type') + journalentry_table.columns.hide('assigned_object') if request.user.has_perm('extras.add_journalentry'): form = forms.JournalEntryForm( diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index e73a68a98..4ebfe71cc 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -545,7 +545,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): def get(self, request): form, model_form = self.initialize_forms(request) - instance = self.alter_object(self.queryset.model, request) + instance = self.alter_object(self.queryset.model(), request) return render(request, self.template_name, { 'object': instance, @@ -557,7 +557,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): def post(self, request): logger = logging.getLogger('netbox.views.ComponentCreateView') form, model_form = self.initialize_forms(request) - instance = self.alter_object(self.queryset.model, request) + instance = self.alter_object(self.queryset.model(), request) if form.is_valid(): new_components = [] diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 2d2b406b8..1a7581a6c 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 9b6b97270..3eeaf8b3d 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts index 5cd2c0055..be8a86631 100644 --- a/netbox/project-static/src/select/api/apiSelect.ts +++ b/netbox/project-static/src/select/api/apiSelect.ts @@ -557,9 +557,12 @@ export class APISelect { private async handleSearch(event: Event) { const { value: q } = event.target as HTMLInputElement; const url = queryString.stringifyUrl({ url: this.queryUrl, query: { q } }); - await this.fetchOptions(url, 'merge'); - this.slim.data.search(q); - this.slim.render(); + if (!url.includes(`{{`)) { + await this.fetchOptions(url, 'merge'); + this.slim.data.search(q); + this.slim.render(); + } + return; } /** diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 3fd275c7c..1bf63f2d5 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -16,14 +16,29 @@
-
- Provider -
+
Provider
- - + + + + + + diff --git a/netbox/templates/dcim/component_template_create.html b/netbox/templates/dcim/component_template_create.html index b3c5332b9..d164db872 100644 --- a/netbox/templates/dcim/component_template_create.html +++ b/netbox/templates/dcim/component_template_create.html @@ -2,33 +2,37 @@ {% load form_helpers %} {% block form %} -
-
- + {% if form.module_type %} +
+
+ +
-
-
-
- {% render_field replication_form.device_type %} +
+
+ {% render_field replication_form.device_type %} +
+
+ {% render_field replication_form.module_type %} +
-
- {% render_field replication_form.module_type %} -
- {% block replication_fields %} - {% render_field replication_form.name_pattern %} - {% render_field replication_form.label_pattern %} - {% endblock replication_fields %} -
+ {% else %} + {% render_field replication_form.device_type %} + {% endif %} + {% block replication_fields %} + {% render_field replication_form.name_pattern %} + {% render_field replication_form.label_pattern %} + {% endblock replication_fields %} {{ block.super }} {% endblock form %} diff --git a/netbox/templates/dcim/inc/nonracked_devices.html b/netbox/templates/dcim/inc/nonracked_devices.html index 7f4da2f24..d4cd58839 100644 --- a/netbox/templates/dcim/inc/nonracked_devices.html +++ b/netbox/templates/dcim/inc/nonracked_devices.html @@ -1,40 +1,54 @@ {% load helpers %}
-
- Non-Racked Devices -
-
-{% if nonracked_devices %} -
ASN{{ object.asn|placeholder }}ASN + {% if object.asn %} +
+ +
+ {% endif %} + {{ object.asn|placeholder }} +
ASNs + {% for asn in object.asns.all %} + {{ asn|linkify }}{% if not forloop.last %}, {% endif %} + {% empty %} + {{ ''|placeholder }} + {% endfor %} +
Account
- - - - - - - {% for device in nonracked_devices %} - - - - - {% if device.parent_bay %} - - - {% else %} - +
+ Non-Racked Devices +
+
+ {% if nonracked_devices %} +
NameRoleTypeParent Device
- {{ device }} - {{ device.device_role }}{{ device.device_type }}{{ device.parent_bay.device|linkify }}{{ device.parent_bay }}
+ + + + + + + {% for device in nonracked_devices %} + + + + + {% if device.parent_bay %} + + + {% else %} + + {% endif %} + + {% endfor %} +
NameRoleTypeParent Device
+ {{ device }} + {{ device.device_role }}{{ device.device_type }}{{ device.parent_bay.device|linkify }}{{ device.parent_bay }}
+ + {% if total_nonracked_devices_count > nonracked_devices.count %} + {% if object|meta:'verbose_name' == 'site' %} +
+ Displaying {{ nonracked_devices.count }} of {{ total_nonracked_devices_count }} devices (View full list) +
+ {% elif object|meta:'verbose_name' == 'location' %} +
+ Displaying {{ nonracked_devices.count }} of {{ total_nonracked_devices_count }} devices (View full list) +
{% endif %} - - {% endfor %} - + {% endif %} + {% else %}
None
{% endif %}
+ {% if perms.dcim.add_device %} {% if object|meta:'verbose_name' == 'rack' %}
+ {% if object.is_bridge %} +
+
+ {% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridge Interfaces" %} +
+
+ {% endif %}
{% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %} diff --git a/netbox/templates/dcim/modulebay.html b/netbox/templates/dcim/modulebay.html index a12e020e9..3ef539156 100644 --- a/netbox/templates/dcim/modulebay.html +++ b/netbox/templates/dcim/modulebay.html @@ -52,6 +52,10 @@ {% if object.installed_module %} {% with module=object.installed_module %} + + + + @@ -60,6 +64,14 @@ + + + + + + + +
Module{{ module|linkify }}
Manufacturer {{ module.module_type.manufacturer|linkify }}Module Type {{ module.module_type|linkify }}
Serial Number{{ module.serial|placeholder }}
Asset Tag{{ module.asset_tag|placeholder }}
{% endwith %} {% else %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index fe0a558eb..c15cab468 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -188,6 +188,16 @@ {% endif %} + + VLAN Groups + + {% if stats.vlangroup_count %} + {{ stats.vlangroup_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + VLANs diff --git a/netbox/templates/exceptions/import_error.html b/netbox/templates/exceptions/import_error.html index e8ee7ae2c..3e25b5369 100644 --- a/netbox/templates/exceptions/import_error.html +++ b/netbox/templates/exceptions/import_error.html @@ -1,19 +1,19 @@ {% extends '500.html' %} {% block message %} -

- A module import error occurred during this request. Common causes include the following: -

-

- Missing required packages - This installation of NetBox might be - missing one or more required Python packages. These packages are listed in requirements.txt and - local_requirements.txt, and are normally installed as part of the installation or upgrade process. - To verify installed packages, run pip freeze from the console and compare the output to the list of - required packages. -

-

- WSGI service not restarted after upgrade - If this installation - has recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This - ensures that the new code is running. -

+

+ A module import error occurred during this request. Common causes include the following: +

+

+ Missing required packages - This installation of NetBox might be + missing one or more required Python packages. These packages are listed in requirements.txt and + local_requirements.txt, and are normally installed as part of the installation or upgrade process. To + verify installed packages, run pip freeze from the console and compare the output to the list of + required packages. +

+

+ WSGI service not restarted after upgrade - If this installation has + recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This ensures that + the new code is running. +

{% endblock message %} diff --git a/netbox/templates/exceptions/permission_error.html b/netbox/templates/exceptions/permission_error.html index dcff62bf9..6108fde52 100644 --- a/netbox/templates/exceptions/permission_error.html +++ b/netbox/templates/exceptions/permission_error.html @@ -1,12 +1,12 @@ {% extends '500.html' %} {% block message %} -

- A file permission error was detected while processing this request. Common causes include the following: -

-

- Insufficient write permission to the media root - The configured - media root is {{ settings.MEDIA_ROOT }}. Ensure that the user NetBox runs as has access to write - files to all locations within this path. -

+

+ A file permission error was detected while processing this request. Common causes include the following: +

+

+ Insufficient write permission to the media root - The configured + media root is {{ settings.MEDIA_ROOT }}. Ensure that the user NetBox runs as has access to write files + to all locations within this path. +

{% endblock message %} diff --git a/netbox/templates/exceptions/programming_error.html b/netbox/templates/exceptions/programming_error.html index 3b9e84567..38175b187 100644 --- a/netbox/templates/exceptions/programming_error.html +++ b/netbox/templates/exceptions/programming_error.html @@ -1,17 +1,17 @@ {% extends '500.html' %} {% block message %} -

- A database programming error was detected while processing this request. Common causes include the following: -

-

- Database migrations missing - When upgrading to a new NetBox release, the upgrade script must - be run to apply any new database migrations. You can run migrations manually by executing - python3 manage.py migrate from the command line. -

-

- Unsupported PostgreSQL version - Ensure that PostgreSQL version 9.6 or higher is in use. You - can check this by connecting to the database using NetBox's credentials and issuing a query for - SELECT VERSION(). -

+

+ A database programming error was detected while processing this request. Common causes include the following: +

+

+ Database migrations missing - When upgrading to a new NetBox release, + the upgrade script must be run to apply any new database migrations. You can run migrations manually by executing + python3 manage.py migrate from the command line. +

+

+ Unsupported PostgreSQL version - Ensure that PostgreSQL version 10 + or later is in use. You can check this by connecting to the database using NetBox's credentials and issuing a query + for SELECT VERSION(). +

{% endblock message %} diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index 9be7a485a..e8c3df460 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -21,7 +21,10 @@ Type - {{ object.get_type_display }} + + {{ object.get_type_display }} + {% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %} + Description diff --git a/netbox/templates/extras/journalentry.html b/netbox/templates/extras/journalentry.html index 6d1d9599c..d8f8114b5 100644 --- a/netbox/templates/extras/journalentry.html +++ b/netbox/templates/extras/journalentry.html @@ -9,7 +9,7 @@ {% block content %}
-
+
Journal Entry @@ -35,8 +35,10 @@
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %}
-
+
{% include 'inc/panels/comments.html' %}
diff --git a/netbox/templates/extras/object_journal.html b/netbox/templates/extras/object_journal.html index 5f3b991fa..363b067a8 100644 --- a/netbox/templates/extras/object_journal.html +++ b/netbox/templates/extras/object_journal.html @@ -11,11 +11,7 @@

New Journal Entry

{% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} - {% render_field form.kind %} - {% render_field form.comments %} + {% render_form form %}
Cancel diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html index 6c092f598..7afe981e6 100644 --- a/netbox/templates/ipam/asn.html +++ b/netbox/templates/ipam/asn.html @@ -45,7 +45,17 @@ {% if sites_count %} {{ sites_count }} {% else %} - {{ sites_count }} + {{ ''|placeholder }} + {% endif %} + + + + Providers + + {% if providers_count %} + {{ providers_count }} + {% else %} + {{ ''|placeholder }} {% endif %} @@ -69,6 +79,13 @@ {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %}
+
+
Providers
+
+ {% render_table providers_table 'inc/table.html' %} + {% include 'inc/paginator.html' with paginator=providers_table.paginator page=providers_table.page %} +
+
{% plugin_full_width_page object %}
diff --git a/netbox/templates/login.html b/netbox/templates/login.html index 9cf882e9c..f4dd9c696 100644 --- a/netbox/templates/login.html +++ b/netbox/templates/login.html @@ -39,11 +39,13 @@
- {# TODO: Improve the design & layout #} {% if auth_backends %} -
Or use an SSO provider:
- {% for name, backend in auth_backends.items %} -

{{ name }}

+
Or use a single sign-on (SSO) provider:
+ {% for name, display in auth_backends.items %} +
+ {% if display.1 %}{% endif %} + {{ display.0 }} +
{% endfor %} {% endif %} diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index 3b97a5a20..f55e87895 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -53,6 +53,16 @@ Address {{ object.address|linebreaksbr|placeholder }} + + Link + + {% if object.link %} + {{ object.link }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + Assignments {{ assignment_count }} diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 21a75a6bf..8749dc63f 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -84,7 +84,7 @@ class ContactSerializer(NetBoxModelSerializer): class Meta: model = Contact fields = [ - 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags', + 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 03f3fdf6d..8ca4ae29c 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -63,7 +63,7 @@ class ContactFilterSet(NetBoxModelFilterSet): class Meta: model = Contact - fields = ['id', 'name', 'title', 'phone', 'email', 'address'] + fields = ['id', 'name', 'title', 'phone', 'email', 'address', 'link'] def search(self, queryset, name, value): if not value.strip(): @@ -74,6 +74,7 @@ class ContactFilterSet(NetBoxModelFilterSet): Q(phone__icontains=value) | Q(email__icontains=value) | Q(address__icontains=value) | + Q(link__icontains=value) | Q(comments__icontains=value) ) diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 270e2b4a5..4c1f03757 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -98,9 +98,12 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + link = forms.URLField( + required=False + ) model = Contact fieldsets = ( - (None, ('group', 'title', 'phone', 'email', 'address')), + (None, ('group', 'title', 'phone', 'email', 'address', 'link')), ) - nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'comments') + nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'comments') diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index 409590c28..d617a27b5 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -79,4 +79,4 @@ class ContactCSVForm(NetBoxModelCSVForm): class Meta: model = Contact - fields = ('name', 'title', 'phone', 'email', 'address', 'group', 'comments') + fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'comments') diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index 35b631d43..021e36a5b 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/models.py @@ -1,11 +1,9 @@ from django import forms -from extras.models import Tag from netbox.forms import NetBoxModelForm from tenancy.models import * from utilities.forms import ( - BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea, - StaticSelect, + BootstrapMixin, CommentField, DynamicModelChoiceField, SlugField, SmallTextarea, StaticSelect, ) __all__ = ( @@ -87,13 +85,13 @@ class ContactForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'tags')), + ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'link', 'tags')), ) class Meta: model = Contact fields = ( - 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags', + 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'comments', 'tags', ) widgets = { 'address': SmallTextarea(attrs={'rows': 3}), diff --git a/netbox/tenancy/migrations/0005_standardize_id_fields.py b/netbox/tenancy/migrations/0005_standardize_id_fields.py index 514478f17..05ea39066 100644 --- a/netbox/tenancy/migrations/0005_standardize_id_fields.py +++ b/netbox/tenancy/migrations/0005_standardize_id_fields.py @@ -4,6 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ + ('dcim', '0145_site_remove_deprecated_fields'), ('tenancy', '0004_extend_tag_support'), ] diff --git a/netbox/tenancy/migrations/0007_contact_link.py b/netbox/tenancy/migrations/0007_contact_link.py new file mode 100644 index 000000000..43b7495e5 --- /dev/null +++ b/netbox/tenancy/migrations/0007_contact_link.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import utilities.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0006_created_datetimefield'), + ] + + operations = [ + migrations.AddField( + model_name='contact', + name='link', + field=models.URLField(blank=True), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 50b75ada7..75ec9f69c 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -105,6 +105,9 @@ class Contact(NetBoxModel): max_length=200, blank=True ) + link = models.URLField( + blank=True + ) comments = models.TextField( blank=True ) diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index cc37efd44..17abc5a5b 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -65,7 +65,7 @@ class ContactTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Contact fields = ( - 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags', + 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'link', 'comments', 'assignment_count', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') diff --git a/netbox/users/migrations/0002_standardize_id_fields.py b/netbox/users/migrations/0002_standardize_id_fields.py index 60191d916..212ede6b1 100644 --- a/netbox/users/migrations/0002_standardize_id_fields.py +++ b/netbox/users/migrations/0002_standardize_id_fields.py @@ -4,6 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ + ('dcim', '0145_site_remove_deprecated_fields'), ('users', '0001_squashed_0011'), ] diff --git a/netbox/users/models.py b/netbox/users/models.py index e51f0099f..df53df83e 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -178,11 +178,11 @@ class UserConfig(models.Model): @receiver(post_save, sender=User) -def create_userconfig(instance, created, **kwargs): +def create_userconfig(instance, created, raw=False, **kwargs): """ - Automatically create a new UserConfig when a new User is created. + Automatically create a new UserConfig when a new User is created. Skip this if importing a user from a fixture. """ - if created: + if created and not raw: config = get_config() UserConfig(user=instance, data=config.DEFAULT_USER_PREFERENCES).save() diff --git a/netbox/users/views.py b/netbox/users/views.py index 04c0c5155..6a923e77e 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -16,6 +16,7 @@ from social_core.backends.utils import load_backends from extras.models import ObjectChange from extras.tables import ObjectChangeTable +from netbox.authentication import get_auth_backend_display from netbox.config import get_config from utilities.forms import ConfirmationForm from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm @@ -43,9 +44,13 @@ class LoginView(View): logger = logging.getLogger('netbox.auth.login') return self.redirect_to_next(request, logger) + auth_backends = { + name: get_auth_backend_display(name) for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys() + } + return render(request, self.template_name, { 'form': form, - 'auth_backends': load_backends(settings.AUTHENTICATION_BACKENDS), + 'auth_backends': auth_backends, }) def post(self, request): diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index 62f6837ec..c5b5bafb9 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -9,6 +9,7 @@ class ChoiceSetMeta(type): # Extend static choices with any configured choices if key := attrs.get('key'): + assert type(attrs['CHOICES']) is list, f"{name} has a key defined but CHOICES is not a list" app = attrs['__module__'].split('.', 1)[0] replace_key = f'{app}.{key}' extend_key = f'{replace_key}+' if replace_key else None @@ -47,7 +48,7 @@ class ChoiceSetMeta(type): class ChoiceSet(metaclass=ChoiceSetMeta): """ - Holds an interable of choice tuples suitable for passing to a Django model or form field. Choices can be defined + Holds an iterable of choice tuples suitable for passing to a Django model or form field. Choices can be defined statically within the class as CHOICES and/or gleaned from the FIELD_CHOICES configuration parameter. """ CHOICES = list() diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 6d45d524d..9a4b011e0 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -144,11 +144,11 @@ def get_selected_values(form, field_name): label for value, label in choices if str(value) in filter_data or None in filter_data ] - if hasattr(field, 'null_option'): - # If the field has a `null_option` attribute set and it is selected, - # add it to the field's grouped choices. - if field.null_option is not None and None in filter_data: - values.append(field.null_option) + # If the field has a `null_option` attribute set and it is selected, + # add it to the field's grouped choices. + if getattr(field, 'null_option', None) and None in filter_data: + values.remove(None) + values.insert(0, field.null_option) return values diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index b0315dd95..eab6fc9e7 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -136,6 +136,18 @@ class VMInterfaceCSVForm(NetBoxModelCSVForm): 'vrf', ) + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + # Limit interface choices for parent & bridge interfaces to the assigned VM + if virtual_machine := data.get('virtual_machine'): + params = { + f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": virtual_machine + } + self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params) + self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params) + def clean_enabled(self): # Make sure enabled is True when it's not included in the uploaded data if 'enabled' not in self.data: diff --git a/netbox/virtualization/migrations/0027_standardize_id_fields.py b/netbox/virtualization/migrations/0027_standardize_id_fields.py index 01d7e8af1..8c5ea8d70 100644 --- a/netbox/virtualization/migrations/0027_standardize_id_fields.py +++ b/netbox/virtualization/migrations/0027_standardize_id_fields.py @@ -4,6 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ + ('dcim', '0145_site_remove_deprecated_fields'), ('virtualization', '0026_vminterface_bridge'), ] diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 64c9e96ef..d1012ba59 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -1,8 +1,7 @@ -from dcim.models import Device, Interface, Location, Site -from extras.models import Tag -from ipam.models import VLAN +from dcim.models import Device, Interface, Location, Region, Site, SiteGroup +from ipam.models import VLAN, VLANGroup from netbox.forms import NetBoxModelForm -from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect +from utilities.forms import DynamicModelChoiceField, SlugField, StaticSelect from wireless.models import * __all__ = ( @@ -31,22 +30,63 @@ class WirelessLANForm(NetBoxModelForm): queryset=WirelessLANGroup.objects.all(), required=False ) + + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + null_option='None', + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group', + null_option='None', + query_params={ + 'site': '$site' + }, + initial_params={ + 'vlans': '$vlan' + } + ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - label='VLAN' + label='VLAN', + query_params={ + 'site_id': '$site', + 'group_id': '$vlan_group', + } ) fieldsets = ( ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), - ('VLAN', ('vlan',)), + ('VLAN', ('region', 'site_group', 'site', 'vlan_group', 'vlan',)), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) class Meta: model = WirelessLAN fields = [ - 'ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + 'ssid', 'group', 'description', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'auth_type', + 'auth_cipher', 'auth_psk', 'tags', ] widgets = { 'auth_type': StaticSelect, @@ -65,6 +105,9 @@ class WirelessLinkForm(NetBoxModelForm): ) location_a = DynamicModelChoiceField( queryset=Location.objects.all(), + query_params={ + 'site_id': '$site_a', + }, required=False, label='Location', initial_params={ @@ -102,6 +145,9 @@ class WirelessLinkForm(NetBoxModelForm): ) location_b = DynamicModelChoiceField( queryset=Location.objects.all(), + query_params={ + 'site_id': '$site_b', + }, required=False, label='Location', initial_params={ diff --git a/netbox/wireless/migrations/0002_standardize_id_fields.py b/netbox/wireless/migrations/0002_standardize_id_fields.py index 9e0b202c2..d386d84fc 100644 --- a/netbox/wireless/migrations/0002_standardize_id_fields.py +++ b/netbox/wireless/migrations/0002_standardize_id_fields.py @@ -4,6 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ + ('dcim', '0145_site_remove_deprecated_fields'), ('wireless', '0001_wireless'), ] diff --git a/requirements.txt b/requirements.txt index ee24e0a6c..35867410b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==4.0.3 +Django==4.0.4 django-cors-headers==3.11.0 django-debug-toolbar==3.2.4 django-filter==21.1 @@ -13,23 +13,22 @@ django-taggit==2.1.0 django-timezone-field==5.0 djangorestframework==3.13.1 drf-yasg[validation]==1.20.0 -# Installing from branch pending v2.16 release -git+https://github.com/graphql-python/graphene-django.git@v2 +graphene-django==2.15.0 gunicorn==20.1.0 Jinja2==3.0.3 Markdown==3.3.6 markdown-include==0.6.0 -mkdocs-material==8.2.7 +mkdocs-material==8.2.9 mkdocstrings==0.17.0 netaddr==0.8.0 -Pillow==9.0.1 +Pillow==9.1.0 psycopg2-binary==2.9.3 PyYAML==6.0 social-auth-app-django==5.0.0 social-auth-core==4.2.0 svgwrite==1.4.2 -tablib==3.2.0 -tzdata==2021.5 +tablib==3.2.1 +tzdata==2022.1 # Workaround for #7401 jsonschema==3.2.0 diff --git a/upgrade.sh b/upgrade.sh index 301225958..61e6106cd 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -3,23 +3,23 @@ # its most recent release. # This script will invoke Python with the value of the PYTHON environment -# variable (if set), or fall back to "python3". Note that NetBox v3.0+ requires -# Python 3.7 or later. +# variable (if set), or fall back to "python3". Note that NetBox v3.2+ requires +# Python 3.8 or later. cd "$(dirname "$0")" VIRTUALENV="$(pwd -P)/venv" PYTHON="${PYTHON:-python3}" # Validate the minimum required Python version -COMMAND="${PYTHON} -c 'import sys; exit(1 if sys.version_info < (3, 7) else 0)'" +COMMAND="${PYTHON} -c 'import sys; exit(1 if sys.version_info < (3, 8) else 0)'" PYTHON_VERSION=$(eval "${PYTHON} -V") eval $COMMAND || { echo "--------------------------------------------------------------------" echo "ERROR: Unsupported Python version: ${PYTHON_VERSION}. NetBox requires" - echo "Python 3.7 or later. To specify an alternate Python executable, set" + echo "Python 3.8 or later. To specify an alternate Python executable, set" echo "the PYTHON environment variable. For example:" echo "" - echo " sudo PYTHON=/usr/bin/python3.7 ./upgrade.sh" + echo " sudo PYTHON=/usr/bin/python3.8 ./upgrade.sh" echo "" echo "To show your current Python version: ${PYTHON} -V" echo "--------------------------------------------------------------------"