Merge branch 'netbox-community:develop' into develop

This commit is contained in:
PieterL75 2022-04-19 14:18:08 +02:00 committed by GitHub
commit b7c11cc31b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
140 changed files with 1341 additions and 419 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.1.10 placeholder: v3.2.1
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
@ -22,9 +22,9 @@ body:
label: Python version label: Python version
description: What version of Python are you currently running? description: What version of Python are you currently running?
options: options:
- "3.7"
- "3.8" - "3.8"
- "3.9" - "3.9"
- "3.10"
validations: validations:
required: true required: true
- type: textarea - type: textarea

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.1.10 placeholder: v3.2.1
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -2,8 +2,6 @@
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" /> <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
</div> </div>
: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) ![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 NetBox is an infrastructure resource modeling (IRM) tool designed to empower

View File

@ -68,7 +68,8 @@ gunicorn
# Platform-agnostic template rendering engine # Platform-agnostic template rendering engine
# https://github.com/pallets/jinja # https://github.com/pallets/jinja
Jinja2 # Pin to v3.0 for mkdocstrings
Jinja2<3.1
# Simple markup language for rendering HTML # Simple markup language for rendering HTML
# https://github.com/Python-Markdown/markdown # https://github.com/Python-Markdown/markdown
@ -84,7 +85,7 @@ mkdocs-material
# Introspection for embedded code # Introspection for embedded code
# https://github.com/mkdocstrings/mkdocstrings # https://github.com/mkdocstrings/mkdocstrings
mkdocstrings mkdocstrings<=0.17.0
# Library for manipulating IP prefixes and addresses # Library for manipulating IP prefixes and addresses
# https://github.com/netaddr/netaddr # https://github.com/netaddr/netaddr

View File

@ -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.

View File

@ -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 <https://developer.okta.com/>.
### 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.

View File

@ -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. 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 ## 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' 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 ### HTTP Header Authentication

View File

@ -4,6 +4,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
* Clearing expired authentication sessions from the database * Clearing expired authentication sessions from the database
* Deleting changelog records older than the configured [retention time](../configuration/dynamic-settings.md#changelog_retention) * 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. 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.

View File

@ -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 ## 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: 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:

View File

@ -43,7 +43,7 @@ A mapping of permissions to assign a new user account when created using remote
Default: `False` 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)
--- ---

View File

@ -89,6 +89,12 @@ The checkbox to commit database changes when executing a script is checked by de
commit_default = False 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 ## 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: 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:

View File

@ -85,6 +85,20 @@ As you can see, reports are completely customizable. Validation logic can be as
!!! warning !!! 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. 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: The following methods are available to log results within a report:
* log(message) * log(message)

View File

@ -1,7 +1,5 @@
![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} ![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? # 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: 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:

View File

@ -6,7 +6,7 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [releas
## Update Dependencies to Required Versions ## Update Dependencies to Required Versions
NetBox v3.0 and later requires the following: NetBox v3.0 and later require the following:
| Dependency | Minimum Version | | Dependency | Minimum Version |
|------------|-----------------| |------------|-----------------|
@ -67,6 +67,11 @@ sudo git checkout master
sudo git pull origin 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 ## 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: 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@ -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. 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 !!! 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. 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.

View File

@ -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 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: 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) | | `user` | The current user (if authenticated) |
| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user | | `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 ## 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. 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.

View File

@ -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. 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 !!! 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. 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.

View File

@ -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. 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)) * Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333))
* Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844)) * Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844))

View File

@ -1,6 +1,22 @@
# NetBox v3.1 # 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
--- ---

View File

@ -1,10 +1,63 @@
# NetBox v3.2 # 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" !!! warning "Python 3.8 or Later Required"
NetBox v3.2 requires Python 3.8 or later. 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 ### 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. * 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 * [#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 * [#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 * [#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 * [#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 * [#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) ### 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 * [#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 * [#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 * [#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 * [#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 ### 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/dcim/module-types/`
* `/api/ipam/service-templates/` * `/api/ipam/service-templates/`
* `/api/ipam/vlan-groups/<id>/available-vlans/` * `/api/ipam/vlan-groups/<id>/available-vlans/`
* circuits.Provider
* Added `asns` field
* circuits.ProviderNetwork * circuits.ProviderNetwork
* Added `service_id` field * Added `service_id` field
* dcim.ConsolePort * 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 * Added `data_type` and `object_type` fields
* extras.CustomLink * extras.CustomLink
* Added `enabled` field * Added `enabled` field
* extras.JournalEntry
* Added `custom_fields` and `tags` fields
* ipam.ASN
* Added `provider_count` field
* ipam.VLANGroup * ipam.VLANGroup
* Added the `/availables-vlans/` endpoint * 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 * virtualization.VMInterface
* Added `vrf` field * Added `vrf` field

View File

@ -19,6 +19,7 @@ theme:
icon: material/lightbulb icon: material/lightbulb
name: Switch to Light Mode name: Switch to Light Mode
plugins: plugins:
- search
- mkdocstrings: - mkdocstrings:
handlers: handlers:
python: python:
@ -117,7 +118,10 @@ nav:
- GraphQL API: 'plugins/development/graphql-api.md' - GraphQL API: 'plugins/development/graphql-api.md'
- Background Tasks: 'plugins/development/background-tasks.md' - Background Tasks: 'plugins/development/background-tasks.md'
- Administration: - 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' - Permissions: 'administration/permissions.md'
- Housekeeping: 'administration/housekeeping.md' - Housekeeping: 'administration/housekeeping.md'
- Replicating NetBox: 'administration/replicating-netbox.md' - Replicating NetBox: 'administration/replicating-netbox.md'

View File

@ -4,7 +4,9 @@ from circuits.choices import CircuitStatusChoices
from circuits.models import * from circuits.models import *
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.serializers import LinkTerminationSerializer 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 netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from .nested_serializers import * from .nested_serializers import *
@ -16,13 +18,21 @@ from .nested_serializers import *
class ProviderSerializer(NetBoxModelSerializer): class ProviderSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') 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) circuit_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Provider model = Provider
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', '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',
] ]

View File

@ -21,7 +21,7 @@ class CircuitsRootView(APIRootView):
# #
class ProviderViewSet(NetBoxModelViewSet): class ProviderViewSet(NetBoxModelViewSet):
queryset = Provider.objects.prefetch_related('tags').annotate( queryset = Provider.objects.prefetch_related('asns', 'tags').annotate(
circuit_count=count_related(Circuit, 'provider') circuit_count=count_related(Circuit, 'provider')
) )
serializer_class = serializers.ProviderSerializer serializer_class = serializers.ProviderSerializer

View File

@ -3,6 +3,7 @@ from django.db.models import Q
from dcim.filtersets import CableTerminationFilterSet from dcim.filtersets import CableTerminationFilterSet
from dcim.models import Region, Site, SiteGroup from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN
from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter from utilities.filters import TreeNodeMultipleChoiceFilter
@ -56,6 +57,11 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
asn_id = django_filters.ModelMultipleChoiceFilter(
field_name='asns',
queryset=ASN.objects.all(),
label='ASN (ID)',
)
class Meta: class Meta:
model = Provider model = Provider

View File

@ -1,10 +1,15 @@
from django import forms from django import forms
from django.utils.translation import gettext as _
from circuits.choices import CircuitStatusChoices from circuits.choices import CircuitStatusChoices
from circuits.models import * from circuits.models import *
from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant 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__ = ( __all__ = (
'CircuitBulkEditForm', 'CircuitBulkEditForm',
@ -17,7 +22,12 @@ __all__ = (
class ProviderBulkEditForm(NetBoxModelBulkEditForm): class ProviderBulkEditForm(NetBoxModelBulkEditForm):
asn = forms.IntegerField( asn = forms.IntegerField(
required=False, required=False,
label='ASN' label='ASN (legacy)'
)
asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('ASNs'),
required=False
) )
account = forms.CharField( account = forms.CharField(
max_length=30, max_length=30,
@ -45,10 +55,10 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
model = Provider model = Provider
fieldsets = ( fieldsets = (
(None, ('asn', 'account', 'portal_url', 'noc_contact', 'admin_contact')), (None, ('asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact')),
) )
nullable_fields = ( nullable_fields = (
'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
) )

View File

@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
from circuits.choices import CircuitStatusChoices from circuits.choices import CircuitStatusChoices
from circuits.models import * from circuits.models import *
from dcim.models import Region, Site, SiteGroup from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
@ -45,7 +46,12 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
) )
asn = forms.IntegerField( asn = forms.IntegerField(
required=False, required=False,
label=_('ASN') label=_('ASN (legacy)')
)
asn_id = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
required=False,
label=_('ASNs')
) )
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -1,8 +1,9 @@
from django import forms from django import forms
from django.utils.translation import gettext as _
from circuits.models import * from circuits.models import *
from dcim.models import Region, Site, SiteGroup from dcim.models import Region, Site, SiteGroup
from extras.models import Tag from ipam.models import ASN
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from utilities.forms import ( from utilities.forms import (
@ -21,17 +22,22 @@ __all__ = (
class ProviderForm(NetBoxModelForm): class ProviderForm(NetBoxModelForm):
slug = SlugField() slug = SlugField()
asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('ASNs'),
required=False
)
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
('Provider', ('name', 'slug', 'asn', 'tags')), ('Provider', ('name', 'slug', 'asn', 'asns', 'tags')),
('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')),
) )
class Meta: class Meta:
model = Provider model = Provider
fields = [ 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 = { widgets = {
'noc_contact': SmallTextarea( 'noc_contact': SmallTextarea(

View File

@ -5,6 +5,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('circuits', '0004_rename_cable_peer'), ('circuits', '0004_rename_cable_peer'),
('dcim', '0145_site_remove_deprecated_fields'),
] ]
operations = [ operations = [

View File

@ -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'),
),
]

View File

@ -30,6 +30,11 @@ class Provider(NetBoxModel):
verbose_name='ASN', verbose_name='ASN',
help_text='32-bit autonomous system number' help_text='32-bit autonomous system number'
) )
asns = models.ManyToManyField(
to='ipam.ASN',
related_name='providers',
blank=True
)
account = models.CharField( account = models.CharField(
max_length=30, max_length=30,
blank=True, blank=True,

View File

@ -14,8 +14,20 @@ class ProviderTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True 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'), accessor=Accessor('count_circuits'),
viewname='circuits:circuit_list',
url_params={'provider_id': 'pk'},
verbose_name='Circuits' verbose_name='Circuits'
) )
comments = columns.MarkdownColumn() comments = columns.MarkdownColumn()
@ -29,8 +41,8 @@ class ProviderTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Provider model = Provider
fields = ( fields = (
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', 'pk', 'id', 'name', 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asn_count',
'comments', 'contacts', 'tags', 'created', 'last_updated', 'circuit_count', 'comments', 'contacts', 'tags', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')

View File

@ -3,6 +3,7 @@ from django.urls import reverse
from circuits.choices import * from circuits.choices import *
from circuits.models import * from circuits.models import *
from dcim.models import Site from dcim.models import Site
from ipam.models import ASN, RIR
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
@ -18,20 +19,6 @@ class AppTest(APITestCase):
class ProviderTest(APIViewTestCases.APIViewTestCase): class ProviderTest(APIViewTestCases.APIViewTestCase):
model = Provider model = Provider
brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] 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 = { bulk_update_data = {
'asn': 1234, 'asn': 1234,
} }
@ -39,6 +26,12 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): 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 = ( providers = (
Provider(name='Provider 1', slug='provider-1'), Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'), Provider(name='Provider 2', slug='provider-2'),
@ -46,6 +39,24 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
) )
Provider.objects.bulk_create(providers) 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): class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
model = CircuitType model = CircuitType

View File

@ -4,6 +4,7 @@ from circuits.choices import *
from circuits.filtersets import * from circuits.filtersets import *
from circuits.models import * from circuits.models import *
from dcim.models import Cable, Region, Site, SiteGroup from dcim.models import Cable, Region, Site, SiteGroup
from ipam.models import ASN, RIR
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.testing import ChangeLoggedFilterSetTests from utilities.testing import ChangeLoggedFilterSetTests
@ -15,6 +16,14 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod @classmethod
def setUpTestData(cls): 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 = ( providers = (
Provider(name='Provider 1', slug='provider-1', asn=65001, account='1234'), Provider(name='Provider 1', slug='provider-1', asn=65001, account='1234'),
Provider(name='Provider 2', slug='provider-2', asn=65002, account='2345'), 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(name='Provider 5', slug='provider-5', asn=65005, account='5678'),
) )
Provider.objects.bulk_create(providers) Provider.objects.bulk_create(providers)
providers[0].asns.set([asns[0]])
providers[1].asns.set([asns[1]])
providers[2].asns.set([asns[2]])
regions = ( regions = (
Region(name='Test Region 1', slug='test-region-1'), Region(name='Test Region 1', slug='test-region-1'),
@ -70,10 +82,15 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['provider-1', 'provider-2']} params = {'slug': ['provider-1', 'provider-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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']} params = {'asn': ['65001', '65002']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): def test_account(self):
params = {'account': ['1234', '2345']} params = {'account': ['1234', '2345']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -6,6 +6,7 @@ from django.urls import reverse
from circuits.choices import * from circuits.choices import *
from circuits.models import * from circuits.models import *
from dcim.models import Cable, Interface, Site from dcim.models import Cable, Interface, Site
from ipam.models import ASN, RIR
from utilities.testing import ViewTestCases, create_tags, create_test_device from utilities.testing import ViewTestCases, create_tags, create_test_device
@ -15,11 +16,21 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): 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 1', slug='provider-1', asn=65001),
Provider(name='Provider 2', slug='provider-2', asn=65002), Provider(name='Provider 2', slug='provider-2', asn=65002),
Provider(name='Provider 3', slug='provider-3', asn=65003), 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') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -27,6 +38,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'name': 'Provider X', 'name': 'Provider X',
'slug': 'provider-x', 'slug': 'provider-x',
'asn': 65123, 'asn': 65123,
'asns': [asns[6].pk, asns[7].pk],
'account': '1234', 'account': '1234',
'portal_url': 'http://example.com/portal', 'portal_url': 'http://example.com/portal',
'noc_contact': 'noc@example.com', 'noc_contact': 'noc@example.com',

View File

@ -134,10 +134,10 @@ class SiteSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = Site model = Site
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asns', 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone',
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'asns', 'tags',
'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count',
'rack_count', 'virtualmachine_count', 'vlan_count', 'virtualmachine_count', 'vlan_count',
] ]

View File

@ -345,6 +345,10 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_DC = 'dc-terminal' TYPE_DC = 'dc-terminal'
# Proprietary # Proprietary
TYPE_SAF_D_GRID = 'saf-d-grid' 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 # Other
TYPE_HARDWIRED = 'hardwired' TYPE_HARDWIRED = 'hardwired'
@ -456,6 +460,10 @@ class PowerPortTypeChoices(ChoiceSet):
)), )),
('Proprietary', ( ('Proprietary', (
(TYPE_SAF_D_GRID, 'Saf-D-Grid'), (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', ( ('Other', (
(TYPE_HARDWIRED, 'Hardwired'), (TYPE_HARDWIRED, 'Hardwired'),
@ -561,6 +569,10 @@ class PowerOutletTypeChoices(ChoiceSet):
# Proprietary # Proprietary
TYPE_HDOT_CX = 'hdot-cx' TYPE_HDOT_CX = 'hdot-cx'
TYPE_SAF_D_GRID = 'saf-d-grid' 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 # Other
TYPE_HARDWIRED = 'hardwired' TYPE_HARDWIRED = 'hardwired'
@ -665,6 +677,10 @@ class PowerOutletTypeChoices(ChoiceSet):
('Proprietary', ( ('Proprietary', (
(TYPE_HDOT_CX, 'HDOT Cx'), (TYPE_HDOT_CX, 'HDOT Cx'),
(TYPE_SAF_D_GRID, 'Saf-D-Grid'), (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', ( ('Other', (
(TYPE_HARDWIRED, 'Hardwired'), (TYPE_HARDWIRED, 'Hardwired'),

View File

@ -435,6 +435,10 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
method='_device_bays', method='_device_bays',
label='Has device bays', label='Has device bays',
) )
inventory_items = django_filters.BooleanFilter(
method='_inventory_items',
label='Has inventory items',
)
class Meta: class Meta:
model = DeviceType model = DeviceType
@ -479,6 +483,9 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
def _device_bays(self, queryset, name, value): def _device_bays(self, queryset, name, value):
return queryset.exclude(devicebaytemplates__isnull=value) return queryset.exclude(devicebaytemplates__isnull=value)
def _inventory_items(self, queryset, name, value):
return queryset.exclude(inventoryitemtemplates__isnull=value)
class ModuleTypeFilterSet(NetBoxModelFilterSet): class ModuleTypeFilterSet(NetBoxModelFilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter( manufacturer_id = django_filters.ModelMultipleChoiceFilter(
@ -751,6 +758,11 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
to_field_name='slug', to_field_name='slug',
label='Role (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( platform_id = django_filters.ModelMultipleChoiceFilter(
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
label='Platform (ID)', label='Platform (ID)',
@ -1090,8 +1102,8 @@ class PathEndpointFilterSet(django_filters.FilterSet):
class ConsolePortFilterSet( class ConsolePortFilterSet(
NetBoxModelFilterSet,
ModularDeviceComponentFilterSet, ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet, CableTerminationFilterSet,
PathEndpointFilterSet PathEndpointFilterSet
): ):
@ -1106,8 +1118,8 @@ class ConsolePortFilterSet(
class ConsoleServerPortFilterSet( class ConsoleServerPortFilterSet(
NetBoxModelFilterSet,
ModularDeviceComponentFilterSet, ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet, CableTerminationFilterSet,
PathEndpointFilterSet PathEndpointFilterSet
): ):
@ -1122,8 +1134,8 @@ class ConsoleServerPortFilterSet(
class PowerPortFilterSet( class PowerPortFilterSet(
NetBoxModelFilterSet,
ModularDeviceComponentFilterSet, ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet, CableTerminationFilterSet,
PathEndpointFilterSet PathEndpointFilterSet
): ):
@ -1138,8 +1150,8 @@ class PowerPortFilterSet(
class PowerOutletFilterSet( class PowerOutletFilterSet(
NetBoxModelFilterSet,
ModularDeviceComponentFilterSet, ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet, CableTerminationFilterSet,
PathEndpointFilterSet PathEndpointFilterSet
): ):
@ -1158,8 +1170,8 @@ class PowerOutletFilterSet(
class InterfaceFilterSet( class InterfaceFilterSet(
NetBoxModelFilterSet,
ModularDeviceComponentFilterSet, ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet, CableTerminationFilterSet,
PathEndpointFilterSet PathEndpointFilterSet
): ):
@ -1286,8 +1298,8 @@ class InterfaceFilterSet(
class FrontPortFilterSet( class FrontPortFilterSet(
NetBoxModelFilterSet,
ModularDeviceComponentFilterSet, ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet CableTerminationFilterSet
): ):
type = django_filters.MultipleChoiceFilter( type = django_filters.MultipleChoiceFilter(
@ -1301,8 +1313,8 @@ class FrontPortFilterSet(
class RearPortFilterSet( class RearPortFilterSet(
NetBoxModelFilterSet,
ModularDeviceComponentFilterSet, ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet CableTerminationFilterSet
): ):
type = django_filters.MultipleChoiceFilter( type = django_filters.MultipleChoiceFilter(
@ -1315,21 +1327,21 @@ class RearPortFilterSet(
fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description'] fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description']
class ModuleBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
class Meta: class Meta:
model = ModuleBay model = ModuleBay
fields = ['id', 'name', 'label', 'description'] fields = ['id', 'name', 'label', 'description']
class DeviceBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
class Meta: class Meta:
model = DeviceBay model = DeviceBay
fields = ['id', 'name', 'label', 'description'] fields = ['id', 'name', 'label', 'description']
class InventoryItemFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter( parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=InventoryItem.objects.all(), queryset=InventoryItem.objects.all(),
label='Parent inventory item (ID)', label='Parent inventory item (ID)',

View File

@ -115,6 +115,18 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
label=_('ASNs'), label=_('ASNs'),
required=False 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( description = forms.CharField(
max_length=100, max_length=100,
required=False required=False
@ -912,9 +924,33 @@ class InventoryItemTemplateBulkEditForm(BulkEditForm):
# Device components # 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( class ConsolePortBulkEditForm(
form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']), form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']),
NetBoxModelBulkEditForm ComponentBulkEditForm
): ):
mark_connected = forms.NullBooleanField( mark_connected = forms.NullBooleanField(
required=False, required=False,
@ -923,14 +959,14 @@ class ConsolePortBulkEditForm(
model = ConsolePort model = ConsolePort
fieldsets = ( 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( class ConsoleServerPortBulkEditForm(
form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']), form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']),
NetBoxModelBulkEditForm ComponentBulkEditForm
): ):
mark_connected = forms.NullBooleanField( mark_connected = forms.NullBooleanField(
required=False, required=False,
@ -939,14 +975,14 @@ class ConsoleServerPortBulkEditForm(
model = ConsoleServerPort model = ConsoleServerPort
fieldsets = ( 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( class PowerPortBulkEditForm(
form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']), form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']),
NetBoxModelBulkEditForm ComponentBulkEditForm
): ):
mark_connected = forms.NullBooleanField( mark_connected = forms.NullBooleanField(
required=False, required=False,
@ -955,22 +991,16 @@ class PowerPortBulkEditForm(
model = PowerPort model = PowerPort
fieldsets = ( fieldsets = (
(None, ('type', 'label', 'description', 'mark_connected')), (None, ('module', 'type', 'label', 'description', 'mark_connected')),
('Power', ('maximum_draw', 'allocated_draw')), ('Power', ('maximum_draw', 'allocated_draw')),
) )
nullable_fields = ('label', 'description') nullable_fields = ('module', 'label', 'description')
class PowerOutletBulkEditForm( class PowerOutletBulkEditForm(
form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']), 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( mark_connected = forms.NullBooleanField(
required=False, required=False,
widget=BulkEditNullBooleanSelect widget=BulkEditNullBooleanSelect
@ -978,10 +1008,10 @@ class PowerOutletBulkEditForm(
model = PowerOutlet model = PowerOutlet
fieldsets = ( fieldsets = (
(None, ('type', 'label', 'description', 'mark_connected')), (None, ('module', 'type', 'label', 'description', 'mark_connected')),
('Power', ('feed_leg', 'power_port')), ('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): def __init__(self, *args, **kwargs):
super().__init__(*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', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
'tx_power', 'tx_power',
]), ]),
NetBoxModelBulkEditForm ComponentBulkEditForm
): ):
device = forms.ModelChoiceField(
queryset=Device.objects.all(),
required=False,
disabled=True,
widget=forms.HiddenInput()
)
enabled = forms.NullBooleanField( enabled = forms.NullBooleanField(
required=False, required=False,
widget=BulkEditNullBooleanSelect widget=BulkEditNullBooleanSelect
@ -1059,7 +1083,7 @@ class InterfaceBulkEditForm(
model = Interface model = Interface
fieldsets = ( fieldsets = (
(None, ('type', 'label', 'speed', 'duplex', 'description')), (None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
('Addressing', ('vrf', 'mac_address', 'wwn')), ('Addressing', ('vrf', 'mac_address', 'wwn')),
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('Related Interfaces', ('parent', 'bridge', 'lag')), ('Related Interfaces', ('parent', 'bridge', 'lag')),
@ -1067,8 +1091,9 @@ class InterfaceBulkEditForm(
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
) )
nullable_fields = ( nullable_fields = (
'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans',
'vrf',
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -1133,24 +1158,24 @@ class InterfaceBulkEditForm(
class FrontPortBulkEditForm( class FrontPortBulkEditForm(
form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']), form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']),
NetBoxModelBulkEditForm ComponentBulkEditForm
): ):
model = FrontPort model = FrontPort
fieldsets = ( 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( class RearPortBulkEditForm(
form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']), form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']),
NetBoxModelBulkEditForm ComponentBulkEditForm
): ):
model = RearPort model = RearPort
fieldsets = ( 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( class ModuleBayBulkEditForm(
@ -1179,6 +1204,10 @@ class InventoryItemBulkEditForm(
form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']), form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']),
NetBoxModelBulkEditForm NetBoxModelBulkEditForm
): ):
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False
)
role = DynamicModelChoiceField( role = DynamicModelChoiceField(
queryset=InventoryItemRole.objects.all(), queryset=InventoryItemRole.objects.all(),
required=False required=False
@ -1190,7 +1219,7 @@ class InventoryItemBulkEditForm(
model = InventoryItem model = InventoryItem
fieldsets = ( fieldsets = (
(None, ('label', 'role', 'manufacturer', 'part_id', 'description')), (None, ('device', 'label', 'role', 'manufacturer', 'part_id', 'description')),
) )
nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description')

View File

@ -651,11 +651,11 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
super().__init__(data, *args, **kwargs) super().__init__(data, *args, **kwargs)
if data: if data:
# Limit interface choices for parent, bridge and lag to device only # Limit choices for parent, bridge, and LAG interfaces to the assigned device
params = {} if device := data.get('device'):
if data.get('device'): params = {
params[f"device__{self.fields['device'].to_field_name}"] = data.get('device') f"device__{self.fields['device'].to_field_name}": device
if params: }
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params) self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params) self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params) self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)

View File

@ -331,7 +331,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
('Components', ( ('Components', (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', '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( manufacturer_id = DynamicModelMultipleChoiceField(
@ -392,6 +392,27 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES 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) tag = TagFilterField(model)

View File

@ -7,7 +7,6 @@ from timezone_field import TimeZoneFormField
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.models import Tag
from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
@ -1025,10 +1024,10 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
queryset=InventoryItem.objects.all(), queryset=InventoryItemTemplate.objects.all(),
required=False, required=False,
query_params={ query_params={
'device_id': '$device' 'devicetype_id': '$device_type'
} }
) )
role = DynamicModelChoiceField( role = DynamicModelChoiceField(
@ -1050,11 +1049,6 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
widget=forms.HiddenInput widget=forms.HiddenInput
) )
fieldsets = (
('Inventory Item', ('device_type', 'parent', 'name', 'label', 'role', 'description')),
('Hardware', ('manufacturer', 'part_id')),
)
class Meta: class Meta:
model = InventoryItemTemplate model = InventoryItemTemplate
fields = [ fields = [
@ -1368,6 +1362,9 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
class InventoryItemForm(NetBoxModelForm): class InventoryItemForm(NetBoxModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all()
)
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
queryset=InventoryItem.objects.all(), queryset=InventoryItem.objects.all(),
required=False, required=False,
@ -1405,9 +1402,6 @@ class InventoryItemForm(NetBoxModelForm):
'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
'description', 'component_type', 'component_id', 'tags', 'description', 'component_type', 'component_id', 'tags',
] ]
widgets = {
'device': forms.HiddenInput(),
}
# #

View File

@ -1,7 +1,6 @@
from django import forms from django import forms
from dcim.models import * from dcim.models import *
from extras.models import Tag
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from utilities.forms import ( from utilities.forms import (
BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
@ -12,6 +11,7 @@ __all__ = (
'DeviceComponentCreateForm', 'DeviceComponentCreateForm',
'FrontPortCreateForm', 'FrontPortCreateForm',
'FrontPortTemplateCreateForm', 'FrontPortTemplateCreateForm',
'InventoryItemCreateForm',
'ModularComponentTemplateCreateForm', 'ModularComponentTemplateCreateForm',
'ModuleBayCreateForm', 'ModuleBayCreateForm',
'ModuleBayTemplateCreateForm', 'ModuleBayTemplateCreateForm',
@ -199,6 +199,11 @@ class ModuleBayCreateForm(DeviceComponentCreateForm):
field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern') 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): class VirtualChassisCreateForm(NetBoxModelForm):
region = DynamicModelChoiceField( region = DynamicModelChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),

View File

@ -1,4 +1,32 @@
import os
from django.db import migrations 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): class Migration(migrations.Migration):
@ -8,6 +36,10 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RunPython(
code=check_legacy_data,
reverse_code=migrations.RunPython.noop
),
migrations.RemoveField( migrations.RemoveField(
model_name='site', model_name='site',
name='asn', name='asn',

View File

@ -124,6 +124,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
return self.name.replace('{module}', module.module_bay.position) return self.name.replace('{module}', module.module_bay.position)
return self.name 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): class ConsolePortTemplate(ModularComponentTemplateModel):
""" """
@ -147,7 +152,7 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
label=self.label, label=self.resolve_label(kwargs.get('module')),
type=self.type, type=self.type,
**kwargs **kwargs
) )
@ -175,7 +180,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
label=self.label, label=self.resolve_label(kwargs.get('module')),
type=self.type, type=self.type,
**kwargs **kwargs
) )
@ -215,7 +220,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
label=self.label, label=self.resolve_label(kwargs.get('module')),
type=self.type, type=self.type,
maximum_draw=self.maximum_draw, maximum_draw=self.maximum_draw,
allocated_draw=self.allocated_draw, allocated_draw=self.allocated_draw,
@ -280,12 +285,13 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
if self.power_port: 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: else:
power_port = None power_port = None
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
label=self.label, label=self.resolve_label(kwargs.get('module')),
type=self.type, type=self.type,
power_port=power_port, power_port=power_port,
feed_leg=self.feed_leg, feed_leg=self.feed_leg,
@ -325,7 +331,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
label=self.label, label=self.resolve_label(kwargs.get('module')),
type=self.type, type=self.type,
mgmt_only=self.mgmt_only, mgmt_only=self.mgmt_only,
**kwargs **kwargs
@ -390,12 +396,13 @@ class FrontPortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
if self.rear_port: 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: else:
rear_port = None rear_port = None
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
label=self.label, label=self.resolve_label(kwargs.get('module')),
type=self.type, type=self.type,
color=self.color, color=self.color,
rear_port=rear_port, rear_port=rear_port,
@ -435,7 +442,7 @@ class RearPortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
label=self.label, label=self.resolve_label(kwargs.get('module')),
type=self.type, type=self.type,
color=self.color, color=self.color,
positions=self.positions, positions=self.positions,
@ -549,7 +556,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
unique_together = ('device_type', 'parent', 'name') unique_together = ('device_type', 'parent', 'name')
def instantiate(self, **kwargs): 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: if self.component:
model = self.component.component_model model = self.component.component_model
component = model.objects.get(name=self.component.name, **kwargs) component = model.objects.get(name=self.component.name, **kwargs)

View File

@ -784,6 +784,10 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
def is_lag(self): def is_lag(self):
return self.type == InterfaceTypeChoices.TYPE_LAG return self.type == InterfaceTypeChoices.TYPE_LAG
@property
def is_bridge(self):
return self.type == InterfaceTypeChoices.TYPE_BRIDGE
@property @property
def link(self): def link(self):
return self.cable or self.wireless_link return self.cable or self.wireless_link
@ -1066,3 +1070,12 @@ class InventoryItem(MPTTModel, ComponentModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:inventoryitem', kwargs={'pk': self.pk}) 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."
})

View File

@ -257,6 +257,7 @@ class DeviceType(NetBoxModel):
{ {
'name': c.name, 'name': c.name,
'label': c.label, 'label': c.label,
'position': c.position,
'description': c.description, 'description': c.description,
} }
for c in self.modulebaytemplates.all() for c in self.modulebaytemplates.all()

View File

@ -367,7 +367,7 @@ class Location(NestedGroupModel):
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )
clone_fields = ['site', 'parent', 'description'] clone_fields = ['site', 'parent', 'tenant', 'description']
class Meta: class Meta:
ordering = ['site', 'name'] ordering = ['site', 'name']

View File

@ -739,13 +739,22 @@ class ModuleBayTable(DeviceComponentTable):
linkify=True, linkify=True,
verbose_name='Installed module' 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( tags = columns.TagColumn(
url_name='dcim:modulebay_list' url_name='dcim:modulebay_list'
) )
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = ModuleBay 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') default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description')
@ -756,7 +765,10 @@ class DeviceModuleBayTable(ModuleBayTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = ModuleBay 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') default_columns = ('pk', 'name', 'label', 'installed_module', 'description')
@ -772,7 +784,6 @@ class InventoryItemTable(DeviceComponentTable):
linkify=True linkify=True
) )
component = tables.Column( component = tables.Column(
accessor=Accessor('component'),
orderable=False, orderable=False,
linkify=True linkify=True
) )

View File

@ -241,5 +241,7 @@ class InventoryItemTemplateTable(ComponentTemplateTable):
class Meta(ComponentTemplateTable.Meta): class Meta(ComponentTemplateTable.Meta):
model = InventoryItemTemplate 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" empty_text = "None"

View File

@ -86,16 +86,16 @@ class SiteTable(NetBoxTable):
group = tables.Column( group = tables.Column(
linkify=True linkify=True
) )
asns = tables.ManyToManyColumn(
linkify_item=True,
verbose_name='ASNs'
)
asn_count = columns.LinkedCountColumn( asn_count = columns.LinkedCountColumn(
accessor=tables.A('asns__count'), accessor=tables.A('asns__count'),
viewname='ipam:asn_list', viewname='ipam:asn_list',
url_params={'site_id': 'pk'}, url_params={'site_id': 'pk'},
verbose_name='ASN Count' verbose_name='ASN Count'
) )
asns = tables.ManyToManyColumn(
linkify_item=True,
verbose_name='ASNs'
)
tenant = TenantColumn() tenant = TenantColumn()
comments = columns.MarkdownColumn() comments = columns.MarkdownColumn()
contacts = tables.ManyToManyColumn( contacts = tables.ManyToManyColumn(

View File

@ -698,6 +698,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1'), DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1'),
DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2'), 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): def test_model(self):
params = {'model': ['Model 1', 'Model 2']} params = {'model': ['Model 1', 'Model 2']}
@ -784,6 +787,12 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'module_bays': 'false'} params = {'module_bays': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) 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): class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ModuleType.objects.all() queryset = ModuleType.objects.all()

View File

@ -14,7 +14,7 @@ from django.views.generic import View
from circuits.models import Circuit from circuits.models import Circuit
from extras.views import ObjectConfigContextView 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 ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm 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(), 'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(),
'device_count': Device.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(), '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(), '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(), '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(), 'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(),
@ -338,6 +342,7 @@ class SiteView(generic.ObjectView):
'device_count', 'device_count',
cumulative=True cumulative=True
).restrict(request.user, 'view').filter(site=instance) ).restrict(request.user, 'view').filter(site=instance)
nonracked_devices = Device.objects.filter( nonracked_devices = Device.objects.filter(
site=instance, site=instance,
position__isnull=True, position__isnull=True,
@ -353,7 +358,8 @@ class SiteView(generic.ObjectView):
'stats': stats, 'stats': stats,
'locations': locations, 'locations': locations,
'asns': asns, '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) ).filter(pk__in=location_ids).exclude(pk=instance.pk)
child_locations_table = tables.LocationTable(child_locations) child_locations_table = tables.LocationTable(child_locations)
child_locations_table.configure(request) child_locations_table.configure(request)
nonracked_devices = Device.objects.filter( nonracked_devices = Device.objects.filter(
location=instance, location=instance,
position__isnull=True, position__isnull=True,
@ -441,7 +448,8 @@ class LocationView(generic.ObjectView):
'rack_count': rack_count, 'rack_count': rack_count,
'device_count': device_count, 'device_count': device_count,
'child_locations_table': child_locations_table, '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): class ModuleTypeListView(generic.ObjectListView):
queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( 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 = filtersets.ModuleTypeFilterSet
filterset_form = forms.ModuleTypeFilterForm filterset_form = forms.ModuleTypeFilterForm
@ -1066,7 +1074,7 @@ class ModuleTypeImportView(generic.ObjectImportView):
class ModuleTypeBulkEditView(generic.BulkEditView): class ModuleTypeBulkEditView(generic.BulkEditView):
queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( 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 = filtersets.ModuleTypeFilterSet
table = tables.ModuleTypeTable table = tables.ModuleTypeTable
@ -1075,7 +1083,7 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
class ModuleTypeBulkDeleteView(generic.BulkDeleteView): class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( 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 = filtersets.ModuleTypeFilterSet
table = tables.ModuleTypeTable table = tables.ModuleTypeTable
@ -2077,6 +2085,14 @@ class InterfaceView(generic.ObjectView):
orderable=False 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 # Get child interfaces
child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance) child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
child_interfaces_tables = tables.InterfaceTable( child_interfaces_tables = tables.InterfaceTable(
@ -2101,6 +2117,7 @@ class InterfaceView(generic.ObjectView):
return { return {
'ipaddress_table': ipaddress_table, 'ipaddress_table': ipaddress_table,
'bridge_interfaces_table': bridge_interfaces_tables,
'child_interfaces_table': child_interfaces_tables, 'child_interfaces_table': child_interfaces_tables,
'vlan_table': vlan_table, 'vlan_table': vlan_table,
} }
@ -2504,7 +2521,7 @@ class InventoryItemEditView(generic.ObjectEditView):
class InventoryItemCreateView(generic.ComponentCreateView): class InventoryItemCreateView(generic.ComponentCreateView):
queryset = InventoryItem.objects.all() queryset = InventoryItem.objects.all()
form = forms.DeviceComponentCreateForm form = forms.InventoryItemCreateForm
model_form = forms.InventoryItemForm model_form = forms.InventoryItemForm
template_name = 'dcim/inventoryitem_create.html' template_name = 'dcim/inventoryitem_create.html'

View File

@ -23,21 +23,24 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
}), }),
('Banners', { ('Banners', {
'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'), 'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'),
'classes': ('monospace',),
}), }),
('Pagination', { ('Pagination', {
'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'), 'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'),
}), }),
('Validation', { ('Validation', {
'fields': ('CUSTOM_VALIDATORS',), 'fields': ('CUSTOM_VALIDATORS',),
'classes': ('monospace',),
}), }),
('NAPALM', { ('NAPALM', {
'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'), 'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'),
'classes': ('monospace',),
}), }),
('User Preferences', { ('User Preferences', {
'fields': ('DEFAULT_USER_PREFERENCES',), 'fields': ('DEFAULT_USER_PREFERENCES',),
}), }),
('Miscellaneous', { ('Miscellaneous', {
'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'MAPS_URL'), 'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOBRESULT_RETENTION', 'MAPS_URL'),
}), }),
('Config Revision', { ('Config Revision', {
'fields': ('comment',), 'fields': ('comment',),

View File

@ -14,7 +14,7 @@ from extras.models import *
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.exceptions import SerializerNotFound 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.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from users.api.nested_serializers import NestedUserSerializer from users.api.nested_serializers import NestedUserSerializer
@ -78,15 +78,19 @@ class CustomFieldSerializer(ValidatedModelSerializer):
many=True many=True
) )
type = ChoiceField(choices=CustomFieldTypeChoices) type = ChoiceField(choices=CustomFieldTypeChoices)
object_type = ContentTypeField(
queryset=ContentType.objects.all(),
required=False
)
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
data_type = serializers.SerializerMethodField() data_type = serializers.SerializerMethodField()
class Meta: class Meta:
model = CustomField model = CustomField
fields = [ fields = [
'id', 'url', 'display', 'content_types', 'type', 'data_type', 'name', 'label', 'description', 'required', 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'description',
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum',
'choices', 'created', 'last_updated', 'validation_regex', 'choices', 'created', 'last_updated',
] ]
def get_data_type(self, obj): def get_data_type(self, obj):
@ -196,7 +200,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
# Journal entries # Journal entries
# #
class JournalEntrySerializer(ValidatedModelSerializer): class JournalEntrySerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
assigned_object_type = ContentTypeField( assigned_object_type = ContentTypeField(
queryset=ContentType.objects.all() queryset=ContentType.objects.all()
@ -217,7 +221,7 @@ class JournalEntrySerializer(ValidatedModelSerializer):
model = JournalEntry model = JournalEntry
fields = [ fields = [
'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created', '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): def validate(self, data):

View File

@ -179,7 +179,7 @@ class ReportViewSet(ViewSet):
for r in JobResult.objects.filter( for r in JobResult.objects.filter(
obj_type=report_content_type, obj_type=report_content_type,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).defer('data') ).order_by('name', '-created').distinct('name').defer('data')
} }
# Iterate through all available Reports. # Iterate through all available Reports.
@ -236,7 +236,8 @@ class ReportViewSet(ViewSet):
run_report, run_report,
report.full_name, report.full_name,
report_content_type, report_content_type,
request.user request.user,
job_timeout=report.job_timeout
) )
report.result = job_result report.result = job_result
@ -270,7 +271,7 @@ class ScriptViewSet(ViewSet):
for r in JobResult.objects.filter( for r in JobResult.objects.filter(
obj_type=script_content_type, obj_type=script_content_type,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).defer('data').order_by('created') ).order_by('name', '-created').distinct('name').defer('data')
} }
flat_list = [] flat_list = []
@ -320,7 +321,8 @@ class ScriptViewSet(ViewSet):
request.user, request.user,
data=data, data=data,
request=copy_safe_request(request), request=copy_safe_request(request),
commit=commit commit=commit,
job_timeout=script.job_timeout,
) )
script.result = job_result script.result = job_result
serializer = serializers.ScriptDetailSerializer(script, context={'request': request}) serializer = serializers.ScriptDetailSerializer(script, context={'request': request})

View File

@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup 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 tenancy.models import Tenant, TenantGroup
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -134,11 +134,7 @@ class ImageAttachmentFilterSet(BaseFilterSet):
return queryset.filter(name__icontains=value) return queryset.filter(name__icontains=value)
class JournalEntryFilterSet(ChangeLoggedModelFilterSet): class JournalEntryFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
created = django_filters.DateTimeFromToRangeFilter() created = django_filters.DateTimeFromToRangeFilter()
assigned_object_type = ContentTypeFilter() assigned_object_type = ContentTypeFilter()
created_by_id = django_filters.ModelMultipleChoiceFilter( created_by_id = django_filters.ModelMultipleChoiceFilter(

View File

@ -7,10 +7,12 @@ from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGrou
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
from netbox.forms.base import NetBoxModelFilterSetForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
add_blank_choice, APISelectMultiple, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DateTimePicker, add_blank_choice, APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeChoiceField,
DynamicModelMultipleChoiceField, FilterForm, MultipleChoiceField, StaticSelect, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, FilterForm, MultipleChoiceField,
StaticSelect, TagFilterField,
) )
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -237,10 +239,10 @@ class LocalConfigContextFilterForm(forms.Form):
) )
class JournalEntryFilterForm(FilterForm): class JournalEntryFilterForm(NetBoxModelFilterSetForm):
model = JournalEntry model = JournalEntry
fieldsets = ( fieldsets = (
(None, ('q',)), (None, ('q', 'tag')),
('Creation', ('created_before', 'created_after', 'created_by_id')), ('Creation', ('created_before', 'created_after', 'created_by_id')),
('Attributes', ('assigned_object_type_id', 'kind')) ('Attributes', ('assigned_object_type_id', 'kind'))
) )
@ -275,6 +277,7 @@ class JournalEntryFilterForm(FilterForm):
required=False, required=False,
widget=StaticSelect() widget=StaticSelect()
) )
tag = TagFilterField(model)
class ObjectChangeFilterForm(FilterForm): class ObjectChangeFilterForm(FilterForm):

View File

@ -5,6 +5,7 @@ from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGrou
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
@ -48,6 +49,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = CustomField model = CustomField
fields = '__all__' 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 = { widgets = {
'type': StaticSelect(), 'type': StaticSelect(),
'filter_logic': StaticSelect(), 'filter_logic': StaticSelect(),
@ -215,18 +220,17 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
] ]
class JournalEntryForm(BootstrapMixin, forms.ModelForm): class JournalEntryForm(NetBoxModelForm):
comments = CommentField()
kind = forms.ChoiceField( kind = forms.ChoiceField(
choices=add_blank_choice(JournalEntryKindChoices), choices=add_blank_choice(JournalEntryKindChoices),
required=False, required=False,
widget=StaticSelect() widget=StaticSelect()
) )
comments = CommentField()
class Meta: class Meta:
model = JournalEntry model = JournalEntry
fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments'] fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'tags', 'comments']
widgets = { widgets = {
'assigned_object_type': forms.HiddenInput, 'assigned_object_type': forms.HiddenInput,
'assigned_object_id': forms.HiddenInput, 'assigned_object_id': forms.HiddenInput,

View File

@ -1,4 +1,5 @@
from extras import filtersets, models from extras import filtersets, models
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
from netbox.graphql.types import BaseObjectType, ObjectType from netbox.graphql.types import BaseObjectType, ObjectType
__all__ = ( __all__ = (
@ -54,7 +55,7 @@ class ImageAttachmentType(BaseObjectType):
filterset_class = filtersets.ImageAttachmentFilterSet filterset_class = filtersets.ImageAttachmentFilterSet
class JournalEntryType(ObjectType): class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType):
class Meta: class Meta:
model = models.JournalEntry model = models.JournalEntry

View File

@ -9,6 +9,7 @@ from django.db import DEFAULT_DB_ALIAS
from django.utils import timezone from django.utils import timezone
from packaging import version from packaging import version
from extras.models import JobResult
from extras.models import ObjectChange from extras.models import ObjectChange
from netbox.config import Config from netbox.config import Config
@ -63,6 +64,33 @@ class Command(BaseCommand):
f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})" 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) # Check for new releases (if enabled)
if options['verbosity']: if options['verbosity']:
self.stdout.write("[*] Checking for latest release") self.stdout.write("[*] Checking for latest release")

View File

@ -35,7 +35,8 @@ class Command(BaseCommand):
run_report, run_report,
report.full_name, report.full_name,
report_content_type, report_content_type,
None None,
job_timeout=report.job_timeout
) )
# Wait on the job to finish # Wait on the job to finish

View File

@ -113,13 +113,6 @@ class Command(BaseCommand):
script_content_type = ContentType.objects.get(app_label='extras', model='script') 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 # Create the job result
job_result = JobResult.objects.create( job_result = JobResult.objects.create(
name=script.full_name, name=script.full_name,

View File

@ -4,6 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0145_site_remove_deprecated_fields'),
('virtualization', '0026_vminterface_bridge'), ('virtualization', '0026_vminterface_bridge'),
('extras', '0067_customfield_min_max_values'), ('extras', '0067_customfield_min_max_values'),
] ]

View File

@ -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'),
),
]

View File

@ -13,13 +13,16 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.formats import date_format from django.utils.formats import date_format
from rest_framework.utils.encoders import JSONEncoder from rest_framework.utils.encoders import JSONEncoder
import django_rq
from extras.choices import * from extras.choices import *
from extras.constants import * from extras.constants import *
from extras.conditions import ConditionSet from extras.conditions import ConditionSet
from extras.utils import FeatureQuery, image_upload from extras.utils import FeatureQuery, image_upload
from netbox.models import ChangeLoggedModel 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.querysets import RestrictedQuerySet
from utilities.utils import render_jinja2 from utilities.utils import render_jinja2
@ -419,7 +422,7 @@ class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
return objectchange 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 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 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() 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 return job_result

View File

@ -84,15 +84,6 @@ def run_report(job_result, *args, **kwargs):
job_result.save() job_result.save()
logging.error(f"Error during execution of report {job_result.name}") 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): class Report(object):
""" """
@ -119,6 +110,7 @@ class Report(object):
} }
""" """
description = None description = None
job_timeout = None
def __init__(self): def __init__(self):

View File

@ -298,6 +298,10 @@ class BaseScript:
def module(cls): def module(cls):
return cls.__module__ return cls.__module__
@classproperty
def job_timeout(self):
return getattr(self.Meta, 'job_timeout', None)
@classmethod @classmethod
def _get_vars(cls): def _get_vars(cls):
vars = {} vars = {}
@ -414,7 +418,6 @@ def is_variable(obj):
return isinstance(obj, ScriptVariable) return isinstance(obj, ScriptVariable)
@job('default')
def run_script(data, request, commit=True, *args, **kwargs): 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 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: else:
_run_script() _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): 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. defined name in place of the actual module name.
""" """
scripts = OrderedDict() 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. # defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]): for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
# Remove cached module to ensure consistency with filesystem # Remove cached module to ensure consistency with filesystem

View File

@ -12,7 +12,6 @@ __all__ = (
'ExportTemplateTable', 'ExportTemplateTable',
'JournalEntryTable', 'JournalEntryTable',
'ObjectChangeTable', 'ObjectChangeTable',
'ObjectJournalTable',
'TaggedItemTable', 'TaggedItemTable',
'TagTable', 'TagTable',
'WebhookTable', 'WebhookTable',
@ -210,25 +209,11 @@ class ObjectChangeTable(NetBoxTable):
) )
class ObjectJournalTable(NetBoxTable): class JournalEntryTable(NetBoxTable):
"""
Used for displaying a set of JournalEntries within the context of a single object.
"""
created = tables.DateTimeColumn( created = tables.DateTimeColumn(
linkify=True, linkify=True,
format=settings.SHORT_DATETIME_FORMAT 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( assigned_object_type = columns.ContentTypeColumn(
verbose_name='Object type' verbose_name='Object type'
) )
@ -237,13 +222,22 @@ class JournalEntryTable(ObjectJournalTable):
orderable=False, orderable=False,
verbose_name='Object' verbose_name='Object'
) )
kind = columns.ChoiceFieldColumn()
comments = columns.MarkdownColumn() 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): class Meta(NetBoxTable.Meta):
model = JournalEntry model = JournalEntry
fields = ( fields = (
'pk', 'id', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', 'pk', 'id', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments',
'actions', 'comments_short', 'tags', 'actions',
) )
default_columns = ( default_columns = (
'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments' 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments'

View File

@ -524,7 +524,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
for r in JobResult.objects.filter( for r in JobResult.objects.filter(
obj_type=report_content_type, obj_type=report_content_type,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).defer('data') ).order_by('name', '-created').distinct('name').defer('data')
} }
ret = [] ret = []
@ -588,7 +588,8 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
run_report, run_report,
report.full_name, report.full_name,
report_content_type, report_content_type,
request.user request.user,
job_timeout=report.job_timeout
) )
return redirect('extras:report_result', job_result_pk=job_result.pk) 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( for r in JobResult.objects.filter(
obj_type=script_content_type, obj_type=script_content_type,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).defer('data') ).order_by('name', '-created').distinct('name').defer('data')
} }
for _scripts in scripts.values(): for _scripts in scripts.values():
@ -708,6 +709,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
commit = form.cleaned_data.pop('_commit') commit = form.cleaned_data.pop('_commit')
script_content_type = ContentType.objects.get(app_label='extras', model='script') script_content_type = ContentType.objects.get(app_label='extras', model='script')
job_result = JobResult.enqueue_job( job_result = JobResult.enqueue_job(
run_script, run_script,
script.full_name, script.full_name,
@ -715,7 +717,8 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
request.user, request.user,
data=form.cleaned_data, data=form.cleaned_data,
request=copy_safe_request(request), 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) return redirect('extras:script_result', job_result_pk=job_result.pk)

View File

@ -24,12 +24,13 @@ class ASNSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
site_count = serializers.IntegerField(read_only=True) site_count = serializers.IntegerField(read_only=True)
provider_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = ASN model = ASN
fields = [ fields = [
'id', 'url', 'display', 'asn', 'site_count', 'rir', 'tenant', 'description', 'tags', 'custom_fields', 'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'site_count', 'provider_count', 'tags',
'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]

View File

@ -8,6 +8,7 @@ from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.views import APIView from rest_framework.views import APIView
from circuits.models import Provider
from dcim.models import Site from dcim.models import Site
from ipam import filtersets from ipam import filtersets
from ipam.models import * from ipam.models import *
@ -32,7 +33,10 @@ class IPAMRootView(APIRootView):
# #
class ASNViewSet(NetBoxModelViewSet): 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 serializer_class = serializers.ASNSerializer
filterset_class = filtersets.ASNFilterSet filterset_class = filtersets.ASNFilterSet

View File

@ -106,14 +106,22 @@ class FHRPGroupProtocolChoices(ChoiceSet):
PROTOCOL_HSRP = 'hsrp' PROTOCOL_HSRP = 'hsrp'
PROTOCOL_GLBP = 'glbp' PROTOCOL_GLBP = 'glbp'
PROTOCOL_CARP = 'carp' PROTOCOL_CARP = 'carp'
PROTOCOL_CLUSTERXL = 'clusterxl'
PROTOCOL_OTHER = 'other' PROTOCOL_OTHER = 'other'
CHOICES = ( CHOICES = (
(PROTOCOL_VRRP2, 'VRRPv2'), ('Standard', (
(PROTOCOL_VRRP3, 'VRRPv3'), (PROTOCOL_VRRP2, 'VRRPv2'),
(PROTOCOL_HSRP, 'HSRP'), (PROTOCOL_VRRP3, 'VRRPv3'),
(PROTOCOL_GLBP, 'GLBP'), (PROTOCOL_CARP, 'CARP'),
(PROTOCOL_CARP, 'CARP'), )),
('CheckPoint', (
(PROTOCOL_CLUSTERXL, 'ClusterXL'),
)),
('Cisco', (
(PROTOCOL_HSRP, 'HSRP'),
(PROTOCOL_GLBP, 'GLBP'),
)),
(PROTOCOL_OTHER, 'Other'), (PROTOCOL_OTHER, 'Other'),
) )

View File

@ -535,6 +535,11 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
queryset=VMInterface.objects.all(), queryset=VMInterface.objects.all(),
label='VM interface (ID)', 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( assigned_to_interface = django_filters.BooleanFilter(
method='_assigned_to_interface', method='_assigned_to_interface',
label='Is assigned to an interface', label='Is assigned to an interface',
@ -613,7 +618,17 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
) )
def _assigned_to_interface(self, queryset, name, value): 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): class FHRPGroupFilterSet(NetBoxModelFilterSet):

View File

@ -377,12 +377,16 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
label=_('Rack') label=_('Rack')
) )
min_vid = forms.IntegerField( min_vid = forms.IntegerField(
required=False,
min_value=VLAN_VID_MIN, min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX, max_value=VLAN_VID_MAX,
label='Minimum VID'
) )
max_vid = forms.IntegerField( max_vid = forms.IntegerField(
required=False,
min_value=VLAN_VID_MIN, min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX, max_value=VLAN_VID_MAX,
label='Maximum VID'
) )
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -50,7 +50,7 @@ class Migration(migrations.Migration):
('status', models.CharField(default='active', max_length=50)), ('status', models.CharField(default='active', max_length=50)),
('role', models.CharField(blank=True, max_length=50)), ('role', models.CharField(blank=True, max_length=50)),
('assigned_object_id', models.PositiveIntegerField(blank=True, null=True)), ('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)), ('description', models.CharField(blank=True, max_length=200)),
], ],
options={ options={

View File

@ -7,6 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0145_site_remove_deprecated_fields'),
('ipam', '0053_asn_model'), ('ipam', '0053_asn_model'),
] ]

View File

@ -1,8 +1,9 @@
# Ensure that VRFs are imported before IPs/prefixes so dumpdata & loaddata work correctly
from .fhrp import * from .fhrp import *
from .vrfs import *
from .ip import * from .ip import *
from .services import * from .services import *
from .vlans import * from .vlans import *
from .vrfs import *
__all__ = ( __all__ = (
'ASN', 'ASN',

View File

@ -113,6 +113,11 @@ class ASNTable(NetBoxTable):
url_params={'asn_id': 'pk'}, url_params={'asn_id': 'pk'},
verbose_name='Site Count' verbose_name='Site Count'
) )
provider_count = columns.LinkedCountColumn(
viewname='circuits:provider_list',
url_params={'asn_id': 'pk'},
verbose_name='Provider Count'
)
sites = tables.ManyToManyColumn( sites = tables.ManyToManyColumn(
linkify_item=True, linkify_item=True,
verbose_name='Sites' verbose_name='Sites'
@ -125,10 +130,10 @@ class ASNTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = ASN model = ASN
fields = ( fields = (
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'tenant', 'description', 'sites', 'tags', 'created', 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'description', 'sites', 'tags',
'last_updated', 'actions', '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')
# #

View File

@ -771,6 +771,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
VMInterface.objects.bulk_create(vminterfaces) 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 = ( tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'), 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.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.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.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='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::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::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::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::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(address='2001:db8::1/65', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
) )
IPAddress.objects.bulk_create(ipaddresses) IPAddress.objects.bulk_create(ipaddresses)
def test_family(self): def test_family(self):
params = {'family': '4'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'family': '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): def test_dns_name(self):
params = {'dns_name': ['ipaddress-a', 'ipaddress-b']} params = {'dns_name': ['ipaddress-a', 'ipaddress-b']}
@ -814,9 +824,9 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_parent(self): def test_parent(self):
params = {'parent': '10.0.0.0/24'} 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'} 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): def test_filter_address(self):
# Check IPv4 and IPv6, with and without a mask # Check IPv4 and IPv6, with and without a mask
@ -835,7 +845,7 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_mask_length(self): def test_mask_length(self):
params = {'mask_length': '24'} 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): def test_vrf(self):
vrfs = VRF.objects.all()[:2] vrfs = VRF.objects.all()[:2]
@ -872,11 +882,16 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'vminterface': ['Interface 1', 'Interface 2']} params = {'vminterface': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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): def test_assigned_to_interface(self):
params = {'assigned_to_interface': 'true'} params = {'assigned_to_interface': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'assigned_to_interface': 'false'} 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): def test_status(self):
params = {'status': [PrefixStatusChoices.STATUS_DEPRECATED, PrefixStatusChoices.STATUS_RESERVED]} params = {'status': [PrefixStatusChoices.STATUS_DEPRECATED, PrefixStatusChoices.STATUS_RESERVED]}

View File

@ -24,7 +24,7 @@ class MinPrefixLengthValidator(BaseValidator):
DNSValidator = RegexValidator( DNSValidator = RegexValidator(
regex='^[0-9A-Za-z._-]+$', regex=r'^([0-9A-Za-z_-]+|\*)(\.[0-9A-Za-z_-]+)*\.?$',
message='Only alphanumeric characters, hyphens, periods, and underscores are allowed in DNS names', message='Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names',
code='invalid' code='invalid'
) )

View File

@ -4,6 +4,8 @@ from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from circuits.models import Provider
from circuits.tables import ProviderTable
from dcim.filtersets import InterfaceFilterSet from dcim.filtersets import InterfaceFilterSet
from dcim.models import Interface, Site from dcim.models import Interface, Site
from dcim.tables import SiteTable from dcim.tables import SiteTable
@ -156,8 +158,8 @@ class RIRView(generic.ObjectView):
queryset = RIR.objects.all() queryset = RIR.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
aggregates = Aggregate.objects.restrict(request.user, 'view').filter( aggregates = Aggregate.objects.restrict(request.user, 'view').filter(rir=instance).annotate(
rir=instance 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 = tables.AggregateTable(aggregates, exclude=('rir', 'utilization'))
aggregates_table.configure(request) aggregates_table.configure(request)
@ -206,6 +208,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
class ASNListView(generic.ObjectListView): class ASNListView(generic.ObjectListView):
queryset = ASN.objects.annotate( queryset = ASN.objects.annotate(
site_count=count_related(Site, 'asns'), site_count=count_related(Site, 'asns'),
provider_count=count_related(Provider, 'asns')
) )
filterset = filtersets.ASNFilterSet filterset = filtersets.ASNFilterSet
filterset_form = forms.ASNFilterForm filterset_form = forms.ASNFilterForm
@ -216,13 +219,21 @@ class ASNView(generic.ObjectView):
queryset = ASN.objects.all() queryset = ASN.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
# Gather assigned Sites
sites = instance.sites.restrict(request.user, 'view') sites = instance.sites.restrict(request.user, 'view')
sites_table = SiteTable(sites) sites_table = SiteTable(sites)
sites_table.configure(request) sites_table.configure(request)
# Gather assigned Providers
providers = instance.providers.restrict(request.user, 'view')
providers_table = ProviderTable(providers)
providers_table.configure(request)
return { return {
'sites_table': sites_table, '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_count = vlans.count()
vlans = add_available_vlans(vlans, vlan_group=instance) 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'): if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
vlans_table.columns.show('pk') vlans_table.columns.show('pk')
vlans_table.configure(request) vlans_table.configure(request)

View File

@ -13,8 +13,46 @@ from utilities.permissions import permission_is_exempt, resolve_permission, reso
UserModel = get_user_model() 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): def get_all_permissions(self, user_obj, obj=None):
if not user_obj.is_active or user_obj.is_anonymous: if not user_obj.is_active or user_obj.is_anonymous:

View File

@ -22,7 +22,9 @@ PARAMS = (
default='', default='',
description="Additional content to display on the login page", description="Additional content to display on the login page",
field_kwargs={ field_kwargs={
'widget': forms.Textarea(), 'widget': forms.Textarea(
attrs={'class': 'vLargeTextField'}
),
}, },
), ),
ConfigParam( ConfigParam(
@ -31,7 +33,9 @@ PARAMS = (
default='', default='',
description="Additional content to display at the top of every page", description="Additional content to display at the top of every page",
field_kwargs={ field_kwargs={
'widget': forms.Textarea(), 'widget': forms.Textarea(
attrs={'class': 'vLargeTextField'}
),
}, },
), ),
ConfigParam( ConfigParam(
@ -40,7 +44,9 @@ PARAMS = (
default='', default='',
description="Additional content to display at the bottom of every page", description="Additional content to display at the bottom of every page",
field_kwargs={ field_kwargs={
'widget': forms.Textarea(), 'widget': forms.Textarea(
attrs={'class': 'vLargeTextField'}
),
}, },
), ),
@ -109,7 +115,12 @@ PARAMS = (
label='Custom validators', label='Custom validators',
default={}, default={},
description="Custom validation rules (JSON)", description="Custom validation rules (JSON)",
field=forms.JSONField field=forms.JSONField,
field_kwargs={
'widget': forms.Textarea(
attrs={'class': 'vLargeTextField'}
),
},
), ),
# NAPALM # NAPALM
@ -137,7 +148,12 @@ PARAMS = (
label='NAPALM arguments', label='NAPALM arguments',
default={}, default={},
description="Additional arguments to pass when invoking a NAPALM driver (as JSON data)", 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 # User preferences
@ -171,6 +187,13 @@ PARAMS = (
description="Days to retain changelog history (set to zero for unlimited)", description="Days to retain changelog history (set to zero for unlimited)",
field=forms.IntegerField 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( ConfigParam(
name='MAPS_URL', name='MAPS_URL',
label='Maps URL', label='Maps URL',

View File

@ -67,7 +67,9 @@ DCIM_TYPES = OrderedDict(
'url': 'dcim:site_list', 'url': 'dcim:site_list',
}), }),
('rack', { ('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, 'filterset': RackFilterSet,
'table': RackTable, 'table': RackTable,
'url': 'dcim:rack_list', 'url': 'dcim:rack_list',

View File

@ -61,6 +61,8 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm):
""" """
Base form for creating a NetBox objects from CSV data. Used for bulk importing. 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): def _get_form_field(self, customfield):
return customfield.to_form_field(for_csv_import=True) return customfield.to_form_field(for_csv_import=True)

View File

@ -14,12 +14,19 @@ from django.core.validators import URLValidator
from netbox.config import PARAMS 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 # Environment setup
# #
VERSION = '3.2.0-beta2' VERSION = '3.2.2-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()

View File

@ -19,8 +19,7 @@ from circuits.models import Circuit, Provider
from dcim.models import ( from dcim.models import (
Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site, Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site,
) )
from extras.choices import JobResultStatusChoices from extras.models import ObjectChange
from extras.models import ObjectChange, JobResult
from extras.tables import ObjectChangeTable from extras.tables import ObjectChangeTable
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
@ -48,13 +47,6 @@ class HomeView(View):
pk__lt=F('_path__destination_id') 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(): def build_stats():
org = ( org = (
("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count), ("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count),
@ -150,7 +142,6 @@ class HomeView(View):
return render(request, self.template_name, { return render(request, self.template_name, {
'search_form': SearchForm(), 'search_form': SearchForm(),
'stats': build_stats(), 'stats': build_stats(),
'report_results': report_results,
'changelog_table': changelog_table, 'changelog_table': changelog_table,
'new_release': new_release, 'new_release': new_release,
}) })

Some files were not shown because too many files have changed in this diff Show More