Merge branch 'netbox-community:develop' into develop
4
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.1.10
|
||||
placeholder: v3.2.1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
@ -22,9 +22,9 @@ body:
|
||||
label: Python version
|
||||
description: What version of Python are you currently running?
|
||||
options:
|
||||
- "3.7"
|
||||
- "3.8"
|
||||
- "3.9"
|
||||
- "3.10"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.1.10
|
||||
placeholder: v3.2.1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
@ -2,8 +2,6 @@
|
||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||
</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!
|
||||
|
||||

|
||||
|
||||
NetBox is an infrastructure resource modeling (IRM) tool designed to empower
|
||||
|
@ -68,7 +68,8 @@ gunicorn
|
||||
|
||||
# Platform-agnostic template rendering engine
|
||||
# https://github.com/pallets/jinja
|
||||
Jinja2
|
||||
# Pin to v3.0 for mkdocstrings
|
||||
Jinja2<3.1
|
||||
|
||||
# Simple markup language for rendering HTML
|
||||
# https://github.com/Python-Markdown/markdown
|
||||
@ -84,7 +85,7 @@ mkdocs-material
|
||||
|
||||
# Introspection for embedded code
|
||||
# https://github.com/mkdocstrings/mkdocstrings
|
||||
mkdocstrings
|
||||
mkdocstrings<=0.17.0
|
||||
|
||||
# Library for manipulating IP prefixes and addresses
|
||||
# https://github.com/netaddr/netaddr
|
||||
|
79
docs/administration/authentication/microsoft-azure-ad.md
Normal 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**.
|
||||
|
||||

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

|
||||
|
||||
Once finished, make note of the application (client) ID; this will be used when configuring NetBox.
|
||||
|
||||

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

|
||||
|
||||
You can optionally specify a description and select a lifetime for the secret.
|
||||
|
||||

|
||||
|
||||
Once finished, make note of the secret value (not the secret ID); this will be used when configuring NetBox.
|
||||
|
||||

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

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

|
||||
|
||||
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.
|
70
docs/administration/authentication/okta.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
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/`
|
||||
|
||||

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

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

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

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