diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 6c13631d9..66ead4f47 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -13,11 +13,8 @@ body:
- type: input
attributes:
label: NetBox version
- description: >
- What version of NetBox are you currently running? (If you don't have access to the most
- recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
- before opening a bug report to see if your issue has already been addressed.)
- placeholder: v3.0.9
+ description: What version of NetBox are you currently running?
+ placeholder: v3.1.0
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index a6fc342be..dcc0b1a5f 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yaml
@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
- placeholder: v3.0.9
+ placeholder: v3.1.0
validations:
required: true
- type: dropdown
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7a3b1f002..a3627a2b1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -76,14 +76,10 @@ free to add a comment with any additional justification for the feature.
(However, note that comments with no substance other than a "+1" will be
deleted. Please use GitHub's reactions feature to indicate your support.)
-* Due to a large backlog of feature requests, we are not currently accepting
-any proposals which substantially extend NetBox's functionality beyond its
-current feature set. This includes the introduction of any new views or models
-which have not already been proposed in an existing feature request.
-
-* Before filing a new feature request, consider raising your idea on the
-mailing list first. Feedback you receive there will help validate and shape the
-proposed feature before filing a formal issue.
+* Before filing a new feature request, consider raising your idea in a
+[GitHub discussion](https://github.com/netbox-community/netbox/discussions)
+first. Feedback you receive there will help validate and shape the proposed
+feature before filing a formal issue.
* Good feature requests are very narrowly defined. Be sure to thoroughly
describe the functionality and data model(s) being proposed. The more effort
diff --git a/base_requirements.txt b/base_requirements.txt
index 11ddac634..7295607f3 100644
--- a/base_requirements.txt
+++ b/base_requirements.txt
@@ -102,6 +102,14 @@ PyYAML
# https://github.com/andymccurdy/redis-py
redis
+# Social authentication framework
+# https://github.com/python-social-auth/social-core
+social-auth-core[all]
+
+# Django app for social-auth-core
+# https://github.com/python-social-auth/social-app-django
+social-auth-app-django
+
# SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite
svgwrite
diff --git a/docs/additional-features/napalm.md b/docs/additional-features/napalm.md
index 2efaa839e..2387bc8b7 100644
--- a/docs/additional-features/napalm.md
+++ b/docs/additional-features/napalm.md
@@ -29,7 +29,7 @@ GET /api/dcim/devices/1/napalm/?method=get_environment
## Authentication
-By default, the [`NAPALM_USERNAME`](../configuration/optional-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/optional-settings.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
+By default, the [`NAPALM_USERNAME`](../configuration/dynamic-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/dynamic-settings.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
```
$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
diff --git a/docs/administration/authentication.md b/docs/administration/authentication.md
new file mode 100644
index 000000000..31983be0b
--- /dev/null
+++ b/docs/administration/authentication.md
@@ -0,0 +1,37 @@
+# Authentication
+
+## Local Authentication
+
+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.
+
+## Remote Authentication
+
+NetBox may be configured to provide user authenticate via a remote backend in addition to local authentication. This is done by setting the `REMOTE_AUTH_BACKEND` configuration parameter to a suitable backend class. NetBox provides several options for remote authentication.
+
+### LDAP Authentication
+
+```python
+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.
+
+### HTTP Header Authentication
+
+```python
+REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
+```
+
+Another option for remote authentication in NetBox is to enable HTTP header-based user assignment. The front end HTTP server (e.g. nginx or Apache) performs client authentication as a process external to NetBox, and passes information about the authenticated user via HTTP headers. By default, the user is assigned via the `REMOTE_USER` header, but this can be customized via the `REMOTE_AUTH_HEADER` configuration parameter.
+
+### Single Sign-On (SSO)
+
+```python
+REMOTE_AUTH_BACKEND = 'social_core.backends.google.GoogleOAuth2'
+```
+
+NetBox supports single sign-on authentication via the [python-social-auth](https://github.com/python-social-auth) library. To enable SSO, specify the path to the desired authentication backend within the `social_core` Python package. Please see the complete list of [supported authentication backends](https://github.com/python-social-auth/social-core/tree/master/social_core/backends) for the available options.
+
+Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter.
diff --git a/docs/administration/housekeeping.md b/docs/administration/housekeeping.md
index 9a3444ca0..6f231798d 100644
--- a/docs/administration/housekeeping.md
+++ b/docs/administration/housekeeping.md
@@ -8,7 +8,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
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.
```shell
-ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
+sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
!!! note
diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md
index 97b691c1d..f859266af 100644
--- a/docs/administration/permissions.md
+++ b/docs/administration/permissions.md
@@ -1,6 +1,6 @@
# Permissions
-NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
+NetBox v2.9 introduced a new object-based permissions framework, which replaces Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
{!models/users/objectpermission.md!}
diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md
new file mode 100644
index 000000000..a222272c2
--- /dev/null
+++ b/docs/configuration/dynamic-settings.md
@@ -0,0 +1,180 @@
+# Dynamic Configuration Settings
+
+These configuration parameters are primarily controlled via NetBox's admin interface (under Admin > Extras > Configuration Revisions). These setting may also be overridden in `configuration.py`; this will prevent them from being modified via the UI.
+
+---
+
+## ALLOWED_URL_SCHEMES
+
+Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
+
+A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all of the default values as well (excluding those schemes which are not desirable).
+
+---
+
+## BANNER_TOP
+
+## BANNER_BOTTOM
+
+Setting these variables will display custom content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
+
+```python
+BANNER_TOP = 'Your banner text'
+BANNER_BOTTOM = BANNER_TOP
+```
+
+---
+
+## BANNER_LOGIN
+
+This defines custom content to be displayed on the login page above the login form. HTML is allowed.
+
+---
+
+## CHANGELOG_RETENTION
+
+Default: 90
+
+The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain
+changes in the database indefinitely.
+
+!!! warning
+ If enabling indefinite changelog 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:
+
+```python
+CUSTOM_VALIDATORS = {
+ "dcim.site": [
+ {
+ "name": {
+ "min_length": 5,
+ "max_length": 30
+ }
+ },
+ "my_plugin.validators.Validator1"
+ ],
+ "dim.device": [
+ "my_plugin.validators.Validator1"
+ ]
+}
+```
+
+---
+
+## ENFORCE_GLOBAL_UNIQUE
+
+Default: False
+
+By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to True.
+
+---
+
+## GRAPHQL_ENABLED
+
+Default: True
+
+Setting this to False will disable the GraphQL API.
+
+---
+
+## MAINTENANCE_MODE
+
+Default: False
+
+Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
+
+---
+
+## MAPS_URL
+
+Default: `https://maps.google.com/?q=` (Google Maps)
+
+This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it.
+
+---
+
+## MAX_PAGE_SIZE
+
+Default: 1000
+
+A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`.
+
+---
+
+## NAPALM_USERNAME
+
+## NAPALM_PASSWORD
+
+NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../additional-features/napalm.md), if installed. Both parameters are optional.
+
+!!! note
+ If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed.
+
+---
+
+## NAPALM_ARGS
+
+A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
+
+```python
+NAPALM_ARGS = {
+ 'api_key': '472071a93b60a1bd1fafb401d9f8ef41',
+ 'port': 2222,
+}
+```
+
+Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument:
+
+```python
+NAPALM_USERNAME = 'username'
+NAPALM_PASSWORD = 'MySecretPassword'
+NAPALM_ARGS = {
+ 'secret': NAPALM_PASSWORD,
+ # Include any additional args here
+}
+```
+
+---
+
+## NAPALM_TIMEOUT
+
+Default: 30 seconds
+
+The amount of time (in seconds) to wait for NAPALM to connect to a device.
+
+---
+
+## PAGINATE_COUNT
+
+Default: 50
+
+The default maximum number of objects to display per page within each list of objects.
+
+---
+
+## PREFER_IPV4
+
+Default: False
+
+When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead.
+
+---
+
+## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
+
+Default: 22
+
+Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`.
+
+---
+
+## RACK_ELEVATION_DEFAULT_UNIT_WIDTH
+
+Default: 220
+
+Default width (in pixels) of a unit within a rack elevation.
diff --git a/docs/configuration/index.md b/docs/configuration/index.md
index 8b0c4121a..95ed3fc37 100644
--- a/docs/configuration/index.md
+++ b/docs/configuration/index.md
@@ -1,18 +1,22 @@
# NetBox Configuration
-NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py`. An example configuration is provided as `configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file.
+NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py`. An example configuration is provided as `configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. While NetBox has many configuration settings, only a few of them must be defined at the time of installation: these are defined under "required settings" below.
-While NetBox has many configuration settings, only a few of them must be defined at the time of installation.
+Some configuration parameters may alternatively be defined either in `configuration.py` or within the administrative section of the user interface. Settings which are "hard-coded" in the configuration file take precedence over those defined via the UI.
## Configuration Parameters
* [Required settings](required-settings.md)
* [Optional settings](optional-settings.md)
+* [Dynamic settings](dynamic-settings.md)
+* [Remote authentication settings](remote-authentication.md)
## Changing the Configuration
-Configuration settings may be changed at any time. However, the WSGI service (e.g. Gunicorn) must be restarted before the changes will take effect:
+The configuration file may be modified at any time. However, the WSGI service (e.g. Gunicorn) must be restarted before the changes will take effect:
```no-highlight
$ sudo systemctl restart netbox
```
+
+Configuration parameters which are set via the admin UI (those listed under "dynamic settings") take effect immediately.
diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md
index 88dd80918..d8d79b6ec 100644
--- a/docs/configuration/optional-settings.md
+++ b/docs/configuration/optional-settings.md
@@ -13,33 +13,6 @@ ADMINS = [
---
-## ALLOWED_URL_SCHEMES
-
-Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
-
-A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all of the default values as well (excluding those schemes which are not desirable).
-
----
-
-## BANNER_TOP
-
-## BANNER_BOTTOM
-
-Setting these variables will display custom content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
-
-```python
-BANNER_TOP = 'Your banner text'
-BANNER_BOTTOM = BANNER_TOP
-```
-
----
-
-## BANNER_LOGIN
-
-This defines custom content to be displayed on the login page above the login form. HTML is allowed.
-
----
-
## BASE_PATH
Default: None
@@ -52,18 +25,6 @@ BASE_PATH = 'netbox/'
---
-## CHANGELOG_RETENTION
-
-Default: 90
-
-The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain
-changes in the database indefinitely.
-
-!!! warning
- If enabling indefinite changelog retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
-
----
-
## CORS_ORIGIN_ALLOW_ALL
Default: False
@@ -88,22 +49,6 @@ CORS_ORIGIN_WHITELIST = [
---
-## 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:
-
-```python
-CUSTOM_VALIDATORS = {
- 'dcim.site': (
- Validator1,
- Validator2,
- Validator3
- )
-}
-```
-
----
-
## DEBUG
Default: False
@@ -168,14 +113,6 @@ Email is sent from NetBox only for critical events or if configured for [logging
---
-## ENFORCE_GLOBAL_UNIQUE
-
-Default: False
-
-By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to True.
-
----
-
## EXEMPT_VIEW_PERMISSIONS
Default: Empty list
@@ -203,14 +140,6 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
---
-## GRAPHQL_ENABLED
-
-Default: True
-
-Setting this to False will disable the GraphQL API.
-
----
-
## HTTP_PROXIES
Default: None
@@ -299,30 +228,6 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
---
-## MAINTENANCE_MODE
-
-Default: False
-
-Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
-
----
-
-## MAPS_URL
-
-Default: `https://maps.google.com/?q=` (Google Maps)
-
-This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it.
-
----
-
-## MAX_PAGE_SIZE
-
-Default: 1000
-
-A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`.
-
----
-
## MEDIA_ROOT
Default: $INSTALL_ROOT/netbox/media/
@@ -339,57 +244,6 @@ Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Pr
---
-## NAPALM_USERNAME
-
-## NAPALM_PASSWORD
-
-NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../additional-features/napalm.md), if installed. Both parameters are optional.
-
-!!! note
- If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed.
-
----
-
-## NAPALM_ARGS
-
-A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
-
-```python
-NAPALM_ARGS = {
- 'api_key': '472071a93b60a1bd1fafb401d9f8ef41',
- 'port': 2222,
-}
-```
-
-Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument:
-
-```python
-NAPALM_USERNAME = 'username'
-NAPALM_PASSWORD = 'MySecretPassword'
-NAPALM_ARGS = {
- 'secret': NAPALM_PASSWORD,
- # Include any additional args here
-}
-```
-
----
-
-## NAPALM_TIMEOUT
-
-Default: 30 seconds
-
-The amount of time (in seconds) to wait for NAPALM to connect to a device.
-
----
-
-## PAGINATE_COUNT
-
-Default: 50
-
-The default maximum number of objects to display per page within each list of objects.
-
----
-
## PLUGINS
Default: Empty
@@ -423,137 +277,6 @@ Note that a plugin must be listed in `PLUGINS` for its configuration to take eff
---
-## PREFER_IPV4
-
-Default: False
-
-When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead.
-
----
-
-## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
-
-Default: 22
-
-Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`.
-
----
-
-## RACK_ELEVATION_DEFAULT_UNIT_WIDTH
-
-Default: 220
-
-Default width (in pixels) of a unit within a rack elevation.
-
----
-
-## REMOTE_AUTH_AUTO_CREATE_USER
-
-Default: `False`
-
-If true, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.)
-
----
-
-## REMOTE_AUTH_BACKEND
-
-Default: `'netbox.authentication.RemoteUserBackend'`
-
-This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins.
-
-* `netbox.authentication.RemoteUserBackend`
-* `netbox.authentication.LDAPBackend`
-
----
-
-## REMOTE_AUTH_DEFAULT_GROUPS
-
-Default: `[]` (Empty list)
-
-The list of groups to assign a new user account when created using remote authentication. (Requires `REMOTE_AUTH_ENABLED`.)
-
----
-
-## REMOTE_AUTH_DEFAULT_PERMISSIONS
-
-Default: `{}` (Empty dictionary)
-
-A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.)
-
----
-
-## REMOTE_AUTH_ENABLED
-
-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_GROUP_SYNC_ENABLED
-
-Default: `False`
-
-NetBox can be configured to sync remote user groups 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.) (Requires `REMOTE_AUTH_ENABLED`.)
-
----
-
-## REMOTE_AUTH_HEADER
-
-Default: `'HTTP_REMOTE_USER'`
-
-When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User` it needs to be set to `HTTP_X_REMOTE_USER`. (Requires `REMOTE_AUTH_ENABLED`.)
-
----
-
-## REMOTE_AUTH_GROUP_HEADER
-
-Default: `'HTTP_REMOTE_USER_GROUP'`
-
-When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
-
----
-
-## REMOTE_AUTH_SUPERUSER_GROUPS
-
-Default: `[]` (Empty list)
-
-The list of groups that promote an remote User to Superuser on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
-
----
-
-## REMOTE_AUTH_SUPERUSERS
-
-Default: `[]` (Empty list)
-
-The list of users that get promoted to Superuser on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
-
----
-
-## REMOTE_AUTH_STAFF_GROUPS
-
-Default: `[]` (Empty list)
-
-The list of groups that promote an remote User to Staff on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
-
----
-
-## REMOTE_AUTH_STAFF_USERS
-
-Default: `[]` (Empty list)
-
-The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
-
----
-
-## REMOTE_AUTH_GROUP_SEPARATOR
-
-Default: `|` (Pipe)
-
-The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
-
----
-
## RELEASE_CHECK_URL
Default: None (disabled)
diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md
new file mode 100644
index 000000000..c00da8b67
--- /dev/null
+++ b/docs/configuration/remote-authentication.md
@@ -0,0 +1,110 @@
+# Remote Authentication Settings
+
+The configuration parameters listed here control remote authentication for NetBox. Note that `REMOTE_AUTH_ENABLED` must be true in order for these settings to take effect.
+
+---
+
+## REMOTE_AUTH_AUTO_CREATE_USER
+
+Default: `False`
+
+If true, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.)
+
+---
+
+## REMOTE_AUTH_BACKEND
+
+Default: `'netbox.authentication.RemoteUserBackend'`
+
+This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins.
+
+* `netbox.authentication.RemoteUserBackend`
+* `netbox.authentication.LDAPBackend`
+
+---
+
+## REMOTE_AUTH_DEFAULT_GROUPS
+
+Default: `[]` (Empty list)
+
+The list of groups to assign a new user account when created using remote authentication. (Requires `REMOTE_AUTH_ENABLED`.)
+
+---
+
+## REMOTE_AUTH_DEFAULT_PERMISSIONS
+
+Default: `{}` (Empty dictionary)
+
+A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.)
+
+---
+
+## REMOTE_AUTH_ENABLED
+
+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_GROUP_SYNC_ENABLED
+
+Default: `False`
+
+NetBox can be configured to sync remote user groups 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.) (Requires `REMOTE_AUTH_ENABLED`.)
+
+---
+
+## REMOTE_AUTH_HEADER
+
+Default: `'HTTP_REMOTE_USER'`
+
+When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User` it needs to be set to `HTTP_X_REMOTE_USER`. (Requires `REMOTE_AUTH_ENABLED`.)
+
+---
+
+## REMOTE_AUTH_GROUP_HEADER
+
+Default: `'HTTP_REMOTE_USER_GROUP'`
+
+When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
+
+---
+
+## REMOTE_AUTH_SUPERUSER_GROUPS
+
+Default: `[]` (Empty list)
+
+The list of groups that promote an remote User to Superuser on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
+
+---
+
+## REMOTE_AUTH_SUPERUSERS
+
+Default: `[]` (Empty list)
+
+The list of users that get promoted to Superuser on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
+
+---
+
+## REMOTE_AUTH_STAFF_GROUPS
+
+Default: `[]` (Empty list)
+
+The list of groups that promote an remote User to Staff on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
+
+---
+
+## REMOTE_AUTH_STAFF_USERS
+
+Default: `[]` (Empty list)
+
+The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
+
+---
+
+## REMOTE_AUTH_GROUP_SEPARATOR
+
+Default: `|` (Pipe)
+
+The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md
index 3158fc73a..a62d14fef 100644
--- a/docs/configuration/required-settings.md
+++ b/docs/configuration/required-settings.md
@@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*']
## DATABASE
-NetBox requires access to a PostgreSQL 9.6 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
+NetBox requires access to a PostgreSQL 10 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
* `NAME` - Database name
* `USER` - PostgreSQL username
diff --git a/docs/core-functionality/contacts.md b/docs/core-functionality/contacts.md
new file mode 100644
index 000000000..76a005fc0
--- /dev/null
+++ b/docs/core-functionality/contacts.md
@@ -0,0 +1,5 @@
+# Contacts
+
+{!models/tenancy/contact.md!}
+{!models/tenancy/contactgroup.md!}
+{!models/tenancy/contactrole.md!}
diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md
index 67e3612b9..982ee3071 100644
--- a/docs/core-functionality/devices.md
+++ b/docs/core-functionality/devices.md
@@ -27,3 +27,13 @@ Device components represent discrete objects within a device which are used to t
---
{!models/dcim/cable.md!}
+
+In the example below, three individual cables comprise a path between devices A and D:
+
+
+
+Traced from Interface 1 on Device A, NetBox will show the following path:
+
+* Cable 1: Interface 1 to Front Port 1
+* Cable 2: Rear Port 1 to Rear Port 2
+* Cable 3: Front Port 2 to Interface 2
diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md
index c1e77069e..9fa5e0eb4 100644
--- a/docs/core-functionality/ipam.md
+++ b/docs/core-functionality/ipam.md
@@ -17,3 +17,11 @@
{!models/ipam/vrf.md!}
{!models/ipam/routetarget.md!}
+
+---
+
+{!models/ipam/fhrpgroup.md!}
+
+---
+
+{!models/ipam/asn.md!}
diff --git a/docs/core-functionality/wireless.md b/docs/core-functionality/wireless.md
new file mode 100644
index 000000000..57133f756
--- /dev/null
+++ b/docs/core-functionality/wireless.md
@@ -0,0 +1,8 @@
+# Wireless Networks
+
+{!models/wireless/wirelesslan.md!}
+{!models/wireless/wirelesslangroup.md!}
+
+---
+
+{!models/wireless/wirelesslink.md!}
diff --git a/docs/customization/custom-validation.md b/docs/customization/custom-validation.md
index 720e8e487..bfa1fc1b1 100644
--- a/docs/customization/custom-validation.md
+++ b/docs/customization/custom-validation.md
@@ -1,22 +1,18 @@
# Custom Validation
-NetBox validates every object prior to it being written to the database to ensure data integrity. This validation includes things like checking for proper formatting and that references to related objects are valid. However, you may wish to supplement this validation with some rules of your own. For example, perhaps you require that every site's name conforms to a specific pattern. This can be done using NetBox's `CustomValidator` class.
+NetBox validates every object prior to it being written to the database to ensure data integrity. This validation includes things like checking for proper formatting and that references to related objects are valid. However, you may wish to supplement this validation with some rules of your own. For example, perhaps you require that every site's name conforms to a specific pattern. This can be done using custom validation rules.
-## CustomValidator
+## Custom Validation Rules
-### Validation Rules
+Custom validation rules are expressed as a mapping of model attributes to a set of rules to which that attribute must conform. For example:
-A custom validator can be instantiated by passing a mapping of attributes to a set of rules to which that attribute must conform. For example:
-
-```python
-from extras.validators import CustomValidator
-
-CustomValidator({
- 'name': {
- 'min_length': 5,
- 'max_length': 30,
- }
-})
+```json
+{
+ "name": {
+ "min_length": 5,
+ "max_length": 30
+ }
+}
```
This defines a custom validator which checks that the length of the `name` attribute for an object is at least five characters long, and no longer than 30 characters. This validation is executed _after_ NetBox has performed its own internal validation.
@@ -38,12 +34,13 @@ The `min` and `max` types should be defined for numeric values, whereas `min_len
### Custom Validation Logic
-There may be instances where the provided validation types are insufficient. The `CustomValidator` class can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected.
+There may be instances where the provided validation types are insufficient. NetBox provides a `CustomValidator` class which can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected.
```python
from extras.validators import CustomValidator
class MyValidator(CustomValidator):
+
def validate(self, instance):
if instance.status == 'active' and not instance.description:
self.fail("Active sites must have a description set!", field='status')
@@ -53,34 +50,69 @@ The `fail()` method may optionally specify a field with which to associate the s
## Assigning Custom Validators
-Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/optional-settings.md#custom_validators) configuration parameter, as such:
+Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/optional-settings.md#custom_validators) configuration parameter. There are three manners by which custom validation rules can be defined:
+
+1. Plain JSON mapping (no custom logic)
+2. Dotted path to a custom validator class
+3. Direct reference to a custom validator class
+
+### Plain Data
+
+For cases where custom logic is not needed, it is sufficient to pass validation rules as plain JSON-compatible objects. This approach typically affords the most portability for your configuration. For instance:
+
+```python
+CUSTOM_VALIDATORS = {
+ "dcim.site": [
+ {
+ "name": {
+ "min_length": 5,
+ "max_length": 30,
+ }
+ }
+ ],
+ "dcim.device": [
+ {
+ "platform": {
+ "required": True,
+ }
+ }
+ ]
+}
+```
+
+### Dotted Path
+
+In instances where a custom validator class is needed, it can be referenced by its Python path (relative to NetBox's working directory):
```python
+CUSTOM_VALIDATORS = {
+ 'dcim.site': (
+ 'my_validators.Validator1',
+ 'my_validators.Validator2',
+ ),
+ 'dcim.device': (
+ 'my_validators.Validator3',
+ )
+}
+```
+
+### Direct Class Reference
+
+This approach requires each class being instantiated to be imported directly within the Python configuration file.
+
+```python
+from my_validators import Validator1, Validator2, Validator3
+
CUSTOM_VALIDATORS = {
'dcim.site': (
Validator1,
Validator2,
- Validator3
+ ),
+ 'dcim.device': (
+ Validator3,
)
}
```
!!! note
Even if defining only a single validator, it must be passed as an iterable.
-
-When it is not necessary to define a custom `validate()` method, you may opt to pass a `CustomValidator` instance directly:
-
-```python
-from extras.validators import CustomValidator
-
-CUSTOM_VALIDATORS = {
- 'dcim.site': (
- CustomValidator({
- 'name': {
- 'min_length': 5,
- 'max_length': 30,
- }
- }),
- )
-}
-```
diff --git a/docs/development/models.md b/docs/development/models.md
index 93a10fff6..62dd016f3 100644
--- a/docs/development/models.md
+++ b/docs/development/models.md
@@ -19,8 +19,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
| Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting |
| ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- |
| Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | |
-| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | | | |
-| Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | | | :material-check: |
+| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | |
+| Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | :material-check: |
| Component | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | |
| Component Template | :material-check: | :material-check: | :material-check: | | | | |
@@ -41,15 +41,20 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [dcim.Site](../models/dcim/site.md)
* [dcim.VirtualChassis](../models/dcim/virtualchassis.md)
* [ipam.Aggregate](../models/ipam/aggregate.md)
+* [ipam.ASN](../models/ipam/asn.md)
+* [ipam.FHRPGroup](../models/ipam/fhrpgroup.md)
* [ipam.IPAddress](../models/ipam/ipaddress.md)
* [ipam.Prefix](../models/ipam/prefix.md)
* [ipam.RouteTarget](../models/ipam/routetarget.md)
* [ipam.Service](../models/ipam/service.md)
* [ipam.VLAN](../models/ipam/vlan.md)
* [ipam.VRF](../models/ipam/vrf.md)
+* [tenancy.Contact](../models/tenancy/contact.md)
* [tenancy.Tenant](../models/tenancy/tenant.md)
* [virtualization.Cluster](../models/virtualization/cluster.md)
* [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md)
+* [wireless.WirelessLAN](../models/wireless/wirelesslan.md)
+* [wireless.WirelessLink](../models/wireless/wirelesslink.md)
### Organizational Models
@@ -61,6 +66,7 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [ipam.RIR](../models/ipam/rir.md)
* [ipam.Role](../models/ipam/role.md)
* [ipam.VLANGroup](../models/ipam/vlangroup.md)
+* [tenancy.ContactRole](../models/tenancy/contactrole.md)
* [virtualization.ClusterGroup](../models/virtualization/clustergroup.md)
* [virtualization.ClusterType](../models/virtualization/clustertype.md)
@@ -69,7 +75,9 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [dcim.Location](../models/dcim/location.md) (formerly RackGroup)
* [dcim.Region](../models/dcim/region.md)
* [dcim.SiteGroup](../models/dcim/sitegroup.md)
+* [tenancy.ContactGroup](../models/tenancy/contactgroup.md)
* [tenancy.TenantGroup](../models/tenancy/tenantgroup.md)
+* [wireless.WirelessLANGroup](../models/wireless/wirelesslangroup.md)
### Component Models
diff --git a/docs/index.md b/docs/index.md
index c25c5be16..7abbd9310 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -48,7 +48,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP service | nginx or Apache |
| WSGI service | gunicorn or uWSGI |
| Application | Django/Python |
-| Database | PostgreSQL 9.6+ |
+| Database | PostgreSQL 10+ |
| Task queuing | Redis/django-rq |
| Live device access | NAPALM |
diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md
index 4d49d8f43..a6aa27b1b 100644
--- a/docs/installation/1-postgresql.md
+++ b/docs/installation/1-postgresql.md
@@ -2,8 +2,8 @@
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
-!!! warning
- NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** currently supported.
+!!! warning "PostgreSQL 10 or later required"
+ NetBox requires PostgreSQL 10 or later. Please note that MySQL and other relational databases are **not** supported.
## Installation
@@ -35,6 +35,12 @@ sudo systemctl start postgresql
sudo systemctl enable postgresql
```
+Before continuing, verify that you have installed PostgreSQL 10 or later:
+
+```no-highlight
+psql -V
+```
+
## Database Creation
At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. Start by invoking the PostgreSQL shell as the system Postgres user.
@@ -51,7 +57,7 @@ CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox;
```
-!!! danger
+!!! danger "Use a strong password"
**Do not use the password from the example.** Choose a strong, random password to ensure secure database authentication for your NetBox installation.
Once complete, enter `\q` to exit the PostgreSQL shell.
diff --git a/docs/installation/2-redis.md b/docs/installation/2-redis.md
index 14dda60f1..fcdfa9ceb 100644
--- a/docs/installation/2-redis.md
+++ b/docs/installation/2-redis.md
@@ -4,7 +4,7 @@
[Redis](https://redis.io/) is an in-memory key-value store which NetBox employs for caching and queuing. This section entails the installation and configuration of a local Redis instance. If you already have a Redis service in place, skip to [the next section](3-netbox.md).
-!!! note
+!!! warning "Redis v4.0 or later required"
NetBox v2.9.0 and later require Redis v4.0 or higher. If your distribution does not offer a recent enough release, you will need to build Redis from source. Please see [the Redis installation documentation](https://github.com/redis/redis) for further details.
=== "Ubuntu"
@@ -21,6 +21,12 @@
sudo systemctl enable redis
```
+Before continuing, verify that your installed version of Redis is at least v4.0:
+
+```no-highlight
+redis-server -v
+```
+
You may wish to modify the Redis configuration at `/etc/redis.conf` or `/etc/redis/redis.conf`, however in most cases the default configuration is sufficient.
## Verify Service Status
diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md
index bf1b27895..b1e1e832e 100644
--- a/docs/installation/3-netbox.md
+++ b/docs/installation/3-netbox.md
@@ -6,8 +6,8 @@ This section of the documentation discusses installing and configuring the NetBo
Begin by installing all system packages required by NetBox and its dependencies.
-!!! note
- NetBox v3.0 and later require Python 3.7, 3.8, or 3.9.
+!!! warning "Python 3.7 or later required"
+ NetBox v3.0 and v3.1 require Python 3.7, 3.8, or 3.9. It is recommended to install at least Python v3.8, as this will become the minimum supported Python version in NetBox v3.2.
=== "Ubuntu"
@@ -26,10 +26,10 @@ Begin by installing all system packages required by NetBox and its dependencies.
sudo yum install -y gcc libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
```
-Before continuing with either platform, update pip (Python's package management tool) to its latest release:
+Before continuing, check that your installed Python version is at least 3.7:
```no-highlight
-sudo pip3 install --upgrade pip
+python3 -V
```
## Download NetBox
@@ -94,7 +94,7 @@ Resolving deltas: 100% (148/148), done.
```
!!! note
- Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `feature` branch tracks progress on the next major release.
+ Installation via git also allows you to easily try out different versions of NetBox. To check out a [specific NetBox release](https://github.com/netbox-community/netbox/releases), use the `git checkout` command with the desired release tag. For example, `git checkout v3.0.8`.
## Create the NetBox System User
@@ -195,7 +195,7 @@ A simple Python script named `generate_secret_key.py` is provided in the parent
python3 ../generate_secret_key.py
```
-!!! warning
+!!! warning "SECRET_KEY values must match"
In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
When you have finished modifying the configuration, remember to save the file.
@@ -234,7 +234,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
sudo /opt/netbox/upgrade.sh
```
-Note that **Python 3.7 or later is required** for NetBox v3.0 and later releases. If the default Python installation on your server does not meet this requirement, you'll need to install Python 3.7 or later separately, and pass the path to the support installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.)
+Note that **Python 3.7 or later is required** for NetBox v3.0 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.)
```no-highlight
sudo PYTHON=/usr/bin/python3.7 /opt/netbox/upgrade.sh
@@ -267,7 +267,7 @@ NetBox includes a `housekeeping` management command that handles some recurring
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
```shell
-ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
+sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
@@ -302,7 +302,7 @@ Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on
firewall-cmd --zone=public --add-port=8000/tcp
```
-!!! danger
+!!! danger "Not for production use"
The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.**
!!! warning
diff --git a/docs/installation/index.md b/docs/installation/index.md
index ccfb8821d..74b51da7f 100644
--- a/docs/installation/index.md
+++ b/docs/installation/index.md
@@ -20,7 +20,7 @@ The video below demonstrates the installation of NetBox v3.0 on Ubuntu 20.04 for
| Dependency | Minimum Version |
|------------|-----------------|
| Python | 3.7 |
-| PostgreSQL | 9.6 |
+| PostgreSQL | 10 |
| Redis | 4.0 |
Below is a simplified overview of the NetBox application stack for reference:
diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md
index d59aa50a2..d7f7156c8 100644
--- a/docs/installation/upgrading.md
+++ b/docs/installation/upgrading.md
@@ -11,7 +11,7 @@ NetBox v3.0 and later requires the following:
| Dependency | Minimum Version |
|------------|-----------------|
| Python | 3.7 |
-| PostgreSQL | 9.6 |
+| PostgreSQL | 10 |
| Redis | 4.0 |
## Install the Latest Release
@@ -114,7 +114,7 @@ sudo systemctl restart netbox netbox-rq
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
```shell
-ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
+sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
diff --git a/docs/models/dcim/cable.md b/docs/models/dcim/cable.md
index 87ec68e03..43c0abfab 100644
--- a/docs/models/dcim/cable.md
+++ b/docs/models/dcim/cable.md
@@ -22,13 +22,3 @@ Each cable may be assigned a type, label, length, and color. Each cable is also
## Tracing Cables
A cable may be traced from either of its endpoints by clicking the "trace" button. (A REST API endpoint also provides this functionality.) NetBox will follow the path of connected cables from this termination across the directly connected cable to the far-end termination. If the cable connects to a pass-through port, and the peer port has another cable connected, NetBox will continue following the cable path until it encounters a non-pass-through or unconnected termination point. The entire path will be displayed to the user.
-
-In the example below, three individual cables comprise a path between devices A and D:
-
-
-
-Traced from Interface 1 on Device A, NetBox will show the following path:
-
-* Cable 1: Interface 1 to Front Port 1
-* Cable 2: Rear Port 1 to Rear Port 2
-* Cable 3: Front Port 2 to Interface 2
diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md
index a7e00dbc6..b919465c8 100644
--- a/docs/models/dcim/devicetype.md
+++ b/docs/models/dcim/devicetype.md
@@ -12,3 +12,5 @@ Some devices house child devices which share physical resources, like space and
!!! note
This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as inventory items within a device, with any associated interfaces or other components assigned directly to the device.
+
+A device type may optionally specify an airflow direction, such as front-to-rear, rear-to-front, or passive. Airflow direction may also be set separately per device. If it is not defined for a device at the time of its creation, it will inherit the airflow setting of its device type.
diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md
index bd9975a72..585674de1 100644
--- a/docs/models/dcim/interface.md
+++ b/docs/models/dcim/interface.md
@@ -11,6 +11,17 @@ Interfaces may be physical or virtual in nature, but only physical interfaces ma
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
+### Wireless Interfaces
+
+Wireless interfaces may additionally track the following attributes:
+
+* **Role** - AP or station
+* **Channel** - One of several standard wireless channels
+* **Channel Frequency** - The transmit frequency
+* **Channel Width** - Channel bandwidth
+
+If a predefined channel is selected, the frequency and width attributes will be assigned automatically. If no channel is selected, these attributes may be defined manually.
+
### IP Address Assignment
IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.)
diff --git a/docs/models/dcim/location.md b/docs/models/dcim/location.md
index 16df208ac..901a68acf 100644
--- a/docs/models/dcim/location.md
+++ b/docs/models/dcim/location.md
@@ -2,4 +2,5 @@
Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor.
-The name and facility ID of each rack within a location must be unique. (Racks not assigned to the same location may have identical names and/or facility IDs.)
+Each location must have a name that is unique within its parent site and location, if any.
+
diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md
index 90c9cfe6e..9465a828c 100644
--- a/docs/models/dcim/rack.md
+++ b/docs/models/dcim/rack.md
@@ -1,6 +1,6 @@
# Racks
-The rack model represents a physical two- or four-post equipment rack in which devices can be installed. Each rack must be assigned to a site, and may optionally be assigned to a location and/or tenant. Racks can also be organized by user-defined functional roles.
+The rack model represents a physical two- or four-post equipment rack in which devices can be installed. Each rack must be assigned to a site, and may optionally be assigned to a location and/or tenant. Racks can also be organized by user-defined functional roles. The name and facility ID of each rack within a location must be unique.
Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order.
diff --git a/docs/models/dcim/region.md b/docs/models/dcim/region.md
index 734467500..bac186264 100644
--- a/docs/models/dcim/region.md
+++ b/docs/models/dcim/region.md
@@ -1,3 +1,5 @@
# Regions
Sites can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned.
+
+Each region must have a name that is unique within its parent region, if any.
diff --git a/docs/models/dcim/sitegroup.md b/docs/models/dcim/sitegroup.md
index 3c1ed11bd..04ebcc1a5 100644
--- a/docs/models/dcim/sitegroup.md
+++ b/docs/models/dcim/sitegroup.md
@@ -1,3 +1,5 @@
# Site Groups
Like regions, site groups can be used to organize sites. Whereas regions are intended to provide geographic organization, site groups can be used to classify sites by role or function. Also like regions, site groups can be nested to form a hierarchy. Sites which belong to a child group are also considered to be members of any of its parent groups.
+
+Each site group must have a name that is unique within its parent group, if any.
diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md
index 52b8bab1e..0932791e7 100644
--- a/docs/models/extras/customfield.md
+++ b/docs/models/extras/customfield.md
@@ -11,10 +11,12 @@ Within the database, custom fields are stored as JSON data directly alongside ea
Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field:
* Text: Free-form text (up to 255 characters)
+* Long text: Free-form of any length; supports Markdown rendering
* Integer: A whole number (positive or negative)
* Boolean: True or false
* Date: A date in ISO 8601 format (YYYY-MM-DD)
* URL: This will be presented as a link in the web UI
+* JSON: Arbitrary data stored in JSON format
* Selection: A selection of one of several pre-defined custom choices
* Multiple selection: A selection field which supports the assignment of multiple values
diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md
index 29cc8b757..fe6a1ef36 100644
--- a/docs/models/extras/tag.md
+++ b/docs/models/extras/tag.md
@@ -15,6 +15,3 @@ The `tag` filter can be specified multiple times to match only objects which hav
```no-highlight
GET /api/dcim/devices/?tag=monitored&tag=deprecated
```
-
-!!! note
- Tags have changed substantially in NetBox v2.9. They are no longer created on-demand when editing an object, and their representation in the REST API now includes a complete depiction of the tag rather than only its label.
diff --git a/docs/models/extras/webhook.md b/docs/models/extras/webhook.md
index ee5e9d059..c71657336 100644
--- a/docs/models/extras/webhook.md
+++ b/docs/models/extras/webhook.md
@@ -17,6 +17,7 @@ A webhook is a mechanism for conveying to some external system a change that too
* **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below).
* **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.)
* **Secret** - A secret string used to prove authenticity of the request (optional). This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key.
+* **Conditions** - An optional set of conditions evaluated to determine whether the webhook fires for a given object.
* **SSL verification** - Uncheck this option to disable validation of the receiver's SSL certificate. (Disable with caution!)
* **CA file path** - The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (optional).
@@ -80,3 +81,16 @@ If no body template is specified, the request body will be populated with a JSON
}
}
```
+
+## Conditional Webhooks
+
+A webhook may include a set of conditional logic expressed in JSON used to control whether a webhook triggers for a specific object. For example, you may wish to trigger a webhook for devices only when the `status` field of an object is "active":
+
+```json
+{
+ "attr": "status",
+ "value": "active"
+}
+```
+
+For more detail, see the reference documentation for NetBox's [conditional logic](../reference/conditions.md).
diff --git a/docs/models/ipam/asn.md b/docs/models/ipam/asn.md
new file mode 100644
index 000000000..cfef1da29
--- /dev/null
+++ b/docs/models/ipam/asn.md
@@ -0,0 +1,15 @@
+# ASN
+
+ASN is short for Autonomous System Number. This identifier is used in the BGP protocol to identify which "autonomous system" a particular prefix is originating and transiting through.
+
+The AS number model within NetBox allows you to model some of this real-world relationship.
+
+Within NetBox:
+
+* AS numbers are globally unique
+* Each AS number must be associated with a RIR (ARIN, RFC 6996, etc)
+* Each AS number can be associated with many different sites
+* Each site can have many different AS numbers
+* Each AS number can be assigned to a single tenant
+
+
diff --git a/docs/models/ipam/fhrpgroup.md b/docs/models/ipam/fhrpgroup.md
new file mode 100644
index 000000000..5efbc8428
--- /dev/null
+++ b/docs/models/ipam/fhrpgroup.md
@@ -0,0 +1,16 @@
+# FHRP Group
+
+A first-hop redundancy protocol (FHRP) enables multiple physical interfaces to present a virtual IP address in a redundant manner. Example of such protocols include:
+
+* Hot Standby Router Protocol (HSRP)
+* Virtual Router Redundancy Protocol (VRRP)
+* Common Address Redundancy Protocol (CARP)
+* Gateway Load Balancing Protocol (GLBP)
+
+NetBox models these redundancy groups by protocol and group ID. Each group may optionally be assigned an authentication type and key. (Note that the authentication key is stored as a plaintext value in NetBox.) Each group may be assigned or more virtual IPv4 and/or IPv6 addresses.
+
+## FHRP Group Assignments
+
+Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority.
+
+Interfaces are assigned to FHRP groups under the interface detail view.
diff --git a/docs/models/tenancy/contact.md b/docs/models/tenancy/contact.md
new file mode 100644
index 000000000..9d81e2d85
--- /dev/null
+++ b/docs/models/tenancy/contact.md
@@ -0,0 +1,31 @@
+# Contacts
+
+A contact represent an individual or group that has been associated with an object in NetBox for administrative reasons. For example, you might assign one or more operational contacts to each site. Contacts can be arranged within nested contact groups.
+
+Each contact must include a name, which is unique to its parent group (if any). The following optional descriptors are also available:
+
+* Title
+* Phone
+* Email
+* Address
+
+## Contact Assignment
+
+Each contact can be assigned to one or more objects, allowing for the efficient reuse of contact information. When assigning a contact to an object, the user may optionally specify a role and/or priority (primary, secondary, tertiary, or inactive) to better convey the nature of the contact's relationship to the assigned object.
+
+The following models support the assignment of contacts:
+
+* circuits.Circuit
+* circuits.Provider
+* dcim.Device
+* dcim.Location
+* dcim.Manufacturer
+* dcim.PowerPanel
+* dcim.Rack
+* dcim.Region
+* dcim.Site
+* dcim.SiteGroup
+* tenancy.Tenant
+* virtualization.Cluster
+* virtualization.ClusterGroup
+* virtualization.VirtualMachine
diff --git a/docs/models/tenancy/contactgroup.md b/docs/models/tenancy/contactgroup.md
new file mode 100644
index 000000000..ea566c58a
--- /dev/null
+++ b/docs/models/tenancy/contactgroup.md
@@ -0,0 +1,3 @@
+# Contact Groups
+
+Contacts can be organized into arbitrary groups. These groups can be recursively nested for convenience. Each contact within a group must have a unique name, but other attributes can be repeated.
diff --git a/docs/models/tenancy/contactrole.md b/docs/models/tenancy/contactrole.md
new file mode 100644
index 000000000..23642ca03
--- /dev/null
+++ b/docs/models/tenancy/contactrole.md
@@ -0,0 +1,3 @@
+# Contact Roles
+
+Contacts can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for administrative, operational, or emergency contacts.
diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md
index 3311ad42d..7fc9bfc06 100644
--- a/docs/models/virtualization/cluster.md
+++ b/docs/models/virtualization/cluster.md
@@ -1,5 +1,5 @@
# Clusters
-A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant.
+A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any.
Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device.
diff --git a/docs/models/wireless/wirelesslan.md b/docs/models/wireless/wirelesslan.md
new file mode 100644
index 000000000..80a3a40b0
--- /dev/null
+++ b/docs/models/wireless/wirelesslan.md
@@ -0,0 +1,11 @@
+# Wireless LANs
+
+A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups.
+
+An interface may be attached to multiple wireless LANs, provided they are all operating on the same channel. Only wireless interfaces may be attached to wireless LANs.
+
+Each wireless LAN may have authentication attributes associated with it, including:
+
+* Authentication type
+* Cipher
+* Pre-shared key
diff --git a/docs/models/wireless/wirelesslangroup.md b/docs/models/wireless/wirelesslangroup.md
new file mode 100644
index 000000000..e477abd0b
--- /dev/null
+++ b/docs/models/wireless/wirelesslangroup.md
@@ -0,0 +1,3 @@
+# Wireless LAN Groups
+
+Wireless LAN groups can be used to organize and classify wireless LANs. These groups are hierarchical: groups can be nested within parent groups. However, each wireless LAN may assigned only to one group.
diff --git a/docs/models/wireless/wirelesslink.md b/docs/models/wireless/wirelesslink.md
new file mode 100644
index 000000000..85cdbd6d9
--- /dev/null
+++ b/docs/models/wireless/wirelesslink.md
@@ -0,0 +1,9 @@
+# Wireless Links
+
+A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model.
+
+Each wireless link may have authentication attributes associated with it, including:
+
+* Authentication type
+* Cipher
+* Pre-shared key
diff --git a/docs/reference/conditions.md b/docs/reference/conditions.md
new file mode 100644
index 000000000..40b2ccb4b
--- /dev/null
+++ b/docs/reference/conditions.md
@@ -0,0 +1,122 @@
+# Conditions
+
+Conditions are NetBox's mechanism for evaluating whether a set data meets a prescribed set of conditions. It allows the author to convey simple logic by declaring an arbitrary number of attribute-value-operation tuples nested within a hierarchy of logical AND and OR statements.
+
+## Conditions
+
+A condition is expressed as a JSON object with the following keys:
+
+| Key name | Required | Default | Description |
+|----------|----------|---------|-------------|
+| attr | Yes | - | Name of the key within the data being evaluated |
+| value | Yes | - | The reference value to which the given data will be compared |
+| op | No | `eq` | The logical operation to be performed |
+| negate | No | False | Negate (invert) the result of the condition's evaluation |
+
+### Available Operations
+
+* `eq`: Equals
+* `gt`: Greater than
+* `gte`: Greater than or equal to
+* `lt`: Less than
+* `lte`: Less than or equal to
+* `in`: Is present within a list of values
+* `contains`: Contains the specified value
+
+### Accessing Nested Keys
+
+To access nested keys, use dots to denote the path to the desired attribute. For example, assume the following data:
+
+```json
+{
+ "a": {
+ "b": {
+ "c": 123
+ }
+ }
+}
+```
+
+The following condition will evaluate as true:
+
+```json
+{
+ "attr": "a.b.c",
+ "value": 123
+}
+```
+
+### Examples
+
+`name` equals "foo":
+
+```json
+{
+ "attr": "name",
+ "value": "foo"
+}
+```
+
+`name` does not equal "foo"
+
+```json
+{
+ "attr": "name",
+ "value": "foo",
+ "negate": true
+}
+```
+
+`asn` is greater than 65000:
+
+```json
+{
+ "attr": "asn",
+ "value": 65000,
+ "op": "gt"
+}
+```
+
+`status` is not "planned" or "staging":
+
+```json
+{
+ "attr": "status",
+ "value": ["planned", "staging"],
+ "op": "in",
+ "negate": true
+}
+```
+
+## Condition Sets
+
+Multiple conditions can be combined into nested sets using AND or OR logic. This is done by declaring a JSON object with a single key (`and` or `or`) containing a list of condition objects and/or child condition sets.
+
+### Examples
+
+`status` is "active" and `primary_ip` is defined _or_ the "exempt" tag is applied.
+
+```json
+{
+ "or": [
+ {
+ "and": [
+ {
+ "attr": "status",
+ "value": "active"
+ },
+ {
+ "attr": "primary_ip",
+ "value": "",
+ "negate": true
+ }
+ ]
+ },
+ {
+ "attr": "tags",
+ "value": "exempt",
+ "op": "contains"
+ }
+ ]
+}
+```
diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md
deleted file mode 120000
index 247ba3e1d..000000000
--- a/docs/release-notes/index.md
+++ /dev/null
@@ -1 +0,0 @@
-version-3.0.md
\ No newline at end of file
diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md
new file mode 100644
index 000000000..7caa1e3ab
--- /dev/null
+++ b/docs/release-notes/index.md
@@ -0,0 +1,113 @@
+# Release Notes
+
+Listed below are the major features introduced in each NetBox release. For more detail on a specific release train, see its individual release notes page.
+
+#### [Version 3.1](./version-3.1.md) (December 2021)
+
+* Contact Objects ([#1344](https://github.com/netbox-community/netbox/issues/1344))
+* Wireless Networks ([#3979](https://github.com/netbox-community/netbox/issues/3979))
+* Dynamic Configuration Updates ([#5883](https://github.com/netbox-community/netbox/issues/5883))
+* First Hop Redundancy Protocol (FHRP) Groups ([#6235](https://github.com/netbox-community/netbox/issues/6235))
+* Conditional Webhooks ([#6238](https://github.com/netbox-community/netbox/issues/6238))
+* Interface Bridging ([#6346](https://github.com/netbox-community/netbox/issues/6346))
+* Multiple ASNs per Site ([#6732](https://github.com/netbox-community/netbox/issues/6732))
+* Single Sign-On (SSO) Authentication ([#7649](https://github.com/netbox-community/netbox/issues/7649))
+
+#### [Version 3.0](./version-3.0.md) (August 2021)
+
+* Updated User Interface ([#5893](https://github.com/netbox-community/netbox/issues/5893))
+* GraphQL API ([#2007](https://github.com/netbox-community/netbox/issues/2007))
+* IP Ranges ([#834](https://github.com/netbox-community/netbox/issues/834))
+* Custom Model Validation ([#5963](https://github.com/netbox-community/netbox/issues/5963))
+* SVG Cable Traces ([#6000](https://github.com/netbox-community/netbox/issues/6000))
+* New Views for Models Previously Under the Admin UI ([#6466](https://github.com/netbox-community/netbox/issues/6466))
+* REST API Token Provisioning ([#5264](https://github.com/netbox-community/netbox/issues/5264))
+* New Housekeeping Command ([#6590](https://github.com/netbox-community/netbox/issues/6590))
+* Custom Queue Support for Plugins ([#6651](https://github.com/netbox-community/netbox/issues/6651))
+
+#### [Version 2.11](./version-2.11.md) (April 2021)
+
+* Journaling Support ([#151](https://github.com/netbox-community/netbox/issues/151))
+* Parent Interface Assignments ([#1519](https://github.com/netbox-community/netbox/issues/1519))
+* Pre- and Post-Change Snapshots in Webhooks ([#3451](https://github.com/netbox-community/netbox/issues/3451))
+* Mark as Connected Without a Cable ([#3648](https://github.com/netbox-community/netbox/issues/3648))
+* Allow Assigning Devices to Locations ([#4971](https://github.com/netbox-community/netbox/issues/4971))
+* Dynamic Object Exports ([#4999](https://github.com/netbox-community/netbox/issues/4999))
+* Variable Scope Support for VLAN Groups ([#5284](https://github.com/netbox-community/netbox/issues/5284))
+* New Site Group Model ([#5892](https://github.com/netbox-community/netbox/issues/5892))
+* Improved Change Logging ([#5913](https://github.com/netbox-community/netbox/issues/5913))
+* Provider Network Modeling ([#5986](https://github.com/netbox-community/netbox/issues/5986))
+
+#### [Version 2.10](./version-2.10.md) (December 2020)
+
+* Route Targets ([#259](https://github.com/netbox-community/netbox/issues/259))
+* REST API Bulk Deletion ([#3436](https://github.com/netbox-community/netbox/issues/3436))
+* REST API Bulk Update ([#4882](https://github.com/netbox-community/netbox/issues/4882))
+* Reimplementation of Custom Fields ([#4878](https://github.com/netbox-community/netbox/issues/4878))
+* Improved Cable Trace Performance ([#4900](https://github.com/netbox-community/netbox/issues/4900))
+
+#### [Version 2.9](./version-2.9.md) (August 2020)
+
+* Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554))
+* Background Execution of Scripts & Reports ([#2006](https://github.com/netbox-community/netbox/issues/2006))
+* Named Virtual Chassis ([#2018](https://github.com/netbox-community/netbox/issues/2018))
+* Changes to Tag Creation ([#3703](https://github.com/netbox-community/netbox/issues/3703))
+* Dedicated Model for VM Interfaces ([#4721](https://github.com/netbox-community/netbox/issues/4721))
+* REST API Endpoints for Users and Groups ([#4877](https://github.com/netbox-community/netbox/issues/4877))
+
+#### [Version 2.8](./version-2.8.md) (April 2020)
+
+* Remote Authentication Support ([#2328](https://github.com/netbox-community/netbox/issues/2328))
+* Plugins ([#3351](https://github.com/netbox-community/netbox/issues/3351))
+
+#### [Version 2.7](./version-2.7.md) (January 2020)
+
+* Enhanced Device Type Import ([#451](https://github.com/netbox-community/netbox/issues/451))
+* Bulk Import of Device Components ([#822](https://github.com/netbox-community/netbox/issues/822))
+* External File Storage ([#1814](https://github.com/netbox-community/netbox/issues/1814))
+* Rack Elevations Rendered via SVG ([#2248](https://github.com/netbox-community/netbox/issues/2248))
+
+#### [Version 2.6](./version-2.6.md) (June 2019)
+
+* Power Panels and Feeds ([#54](https://github.com/netbox-community/netbox/issues/54))
+* Caching ([#2647](https://github.com/netbox-community/netbox/issues/2647))
+* View Permissions ([#323](https://github.com/netbox-community/netbox/issues/323))
+* Custom Links ([#969](https://github.com/netbox-community/netbox/issues/969))
+* Prometheus Metrics ([#3104](https://github.com/netbox-community/netbox/issues/3104))
+
+#### [Version 2.5](./version-2.5.md) (December 2018)
+
+* Patch Panels and Cables ([#20](https://github.com/netbox-community/netbox/issues/20))
+
+#### [Version 2.4](./version-2.4.md) (August 2018)
+
+* Webhooks ([#81](https://github.com/netbox-community/netbox/issues/81))
+* Tagging ([#132](https://github.com/netbox-community/netbox/issues/132))
+* Contextual Configuration Data ([#1349](https://github.com/netbox-community/netbox/issues/1349))
+* Change Logging ([#1898](https://github.com/netbox-community/netbox/issues/1898))
+
+#### [Version 2.3](./version-2.3.md) (February 2018)
+
+* Virtual Chassis ([#99](https://github.com/netbox-community/netbox/issues/99))
+* Interface VLAN Assignments ([#150](https://github.com/netbox-community/netbox/issues/150))
+* Bulk Object Creation via the API ([#1553](https://github.com/netbox-community/netbox/issues/1553))
+* Automatic Provisioning of Next Available Prefixes ([#1694](https://github.com/netbox-community/netbox/issues/1694))
+* Bulk Renaming of Device/VM Components ([#1781](https://github.com/netbox-community/netbox/issues/1781))
+
+#### [Version 2.2](./version-2.2.md) (October 2017)
+
+* Virtual Machines and Clusters ([#142](https://github.com/netbox-community/netbox/issues/142))
+* Custom Validation Reports ([#1511](https://github.com/netbox-community/netbox/issues/1511))
+
+#### [Version 2.1](./version-2.1.md) (July 2017)
+
+* IP Address Roles ([#819](https://github.com/netbox-community/netbox/issues/819))
+* Automatic Provisioning of Next Available IP ([#1246](https://github.com/netbox-community/netbox/issues/1246))
+* NAPALM Integration ([#1348](https://github.com/netbox-community/netbox/issues/1348))
+
+#### [Version 2.0](./version-2.0.md) (May 2017)
+
+* API 2.0 ([#113](https://github.com/netbox-community/netbox/issues/113))
+* Image Attachments ([#152](https://github.com/netbox-community/netbox/issues/152))
+* Global Search ([#159](https://github.com/netbox-community/netbox/issues/159))
+* Rack Elevations View ([#951](https://github.com/netbox-community/netbox/issues/951))
diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md
index 528b90846..51ad02395 100644
--- a/docs/release-notes/version-3.0.md
+++ b/docs/release-notes/version-3.0.md
@@ -1,6 +1,72 @@
# NetBox v3.0
-## v3.0.10 (FUTURE)
+## v3.0.12 (2021-12-06)
+
+### Enhancements
+
+* [#7751](https://github.com/netbox-community/netbox/issues/7751) - Get API user from LDAP only when `FIND_GROUP_PERMS` is enabled
+* [#7885](https://github.com/netbox-community/netbox/issues/7885) - Linkify VLAN name in VLANs table
+* [#7892](https://github.com/netbox-community/netbox/issues/7892) - Add L22-30 power port & outlet types
+* [#7932](https://github.com/netbox-community/netbox/issues/7932) - Improve performance of the "quick find" function
+* [#7941](https://github.com/netbox-community/netbox/issues/7941) - Add multi-standard ITA power outlet type
+
+### Bug Fixes
+
+* [#7823](https://github.com/netbox-community/netbox/issues/7823) - Fix issue where `return_url` is not honored when 'Save & Continue' button is present
+* [#7981](https://github.com/netbox-community/netbox/issues/7981) - Fix Markdown sanitization regex
+
+---
+
+## v3.0.11 (2021-11-24)
+
+### Enhancements
+
+* [#2101](https://github.com/netbox-community/netbox/issues/2101) - Add missing `q` filters for necessary models
+* [#7424](https://github.com/netbox-community/netbox/issues/7424) - Add virtual chassis filters for device components
+* [#7531](https://github.com/netbox-community/netbox/issues/7531) - Add Markdown support for strikethrough formatting
+* [#7542](https://github.com/netbox-community/netbox/issues/7542) - Add optional VLAN group column to prefixes table
+* [#7803](https://github.com/netbox-community/netbox/issues/7803) - Improve live reloading of custom scripts
+* [#7810](https://github.com/netbox-community/netbox/issues/7810) - Add IEEE 802.15.1 interface type
+
+### Bug Fixes
+
+* [#7399](https://github.com/netbox-community/netbox/issues/7399) - Fix excessive CPU utilization when `AUTH_LDAP_FIND_GROUP_PERMS` is enabled
+* [#7657](https://github.com/netbox-community/netbox/issues/7657) - Make change logging middleware thread-safe
+* [#7720](https://github.com/netbox-community/netbox/issues/7720) - Fix initialization of custom script MultiObjectVar field with multiple values
+* [#7729](https://github.com/netbox-community/netbox/issues/7729) - Fix permissions evaluation when displaying VLAN group VLANs table
+* [#7739](https://github.com/netbox-community/netbox/issues/7739) - Fix exception when tracing cable across circuit with no far end termination
+* [#7813](https://github.com/netbox-community/netbox/issues/7813) - Fix handling of errors during export template rendering
+* [#7851](https://github.com/netbox-community/netbox/issues/7851) - Add missing cluster name filter for virtual machines
+* [#7857](https://github.com/netbox-community/netbox/issues/7857) - Fix ordering IP addresses by assignment status
+* [#7859](https://github.com/netbox-community/netbox/issues/7859) - Fix styling of form widgets under cable connection views
+* [#7864](https://github.com/netbox-community/netbox/issues/7864) - `power_port` can be null when creating power outlets via REST API
+* [#7865](https://github.com/netbox-community/netbox/issues/7865) - REST API should support null values for console port speeds
+
+---
+
+## v3.0.10 (2021-11-12)
+
+### Enhancements
+
+* [#7740](https://github.com/netbox-community/netbox/issues/7740) - Add mini-DIN 8 console port type
+* [#7760](https://github.com/netbox-community/netbox/issues/7760) - Add `vid` filter field to VLANs list
+* [#7767](https://github.com/netbox-community/netbox/issues/7767) - Add visual aids to interfaces table for type, enabled status
+
+### Bug Fixes
+
+* [#7564](https://github.com/netbox-community/netbox/issues/7564) - Fix assignment of members to virtual chassis with initial position of zero
+* [#7701](https://github.com/netbox-community/netbox/issues/7701) - Fix conflation of assigned IP status & role in interface tables
+* [#7741](https://github.com/netbox-community/netbox/issues/7741) - Fix 404 when attaching multiple images in succession
+* [#7752](https://github.com/netbox-community/netbox/issues/7752) - Fix minimum version check under Python v3.10
+* [#7766](https://github.com/netbox-community/netbox/issues/7766) - Add missing outer dimension columns to rack table
+* [#7780](https://github.com/netbox-community/netbox/issues/7780) - Preserve multi-line values during CSV file import
+* [#7783](https://github.com/netbox-community/netbox/issues/7783) - Fix indentation of locations under site view
+* [#7788](https://github.com/netbox-community/netbox/issues/7788) - Improve XSS mitigation in Markdown renderer
+* [#7791](https://github.com/netbox-community/netbox/issues/7791) - Enable sorting device bays table by installed device status
+* [#7802](https://github.com/netbox-community/netbox/issues/7802) - Differentiate ID and VID columns in VLANs table
+* [#7808](https://github.com/netbox-community/netbox/issues/7808) - Fix reference values for content type under custom field import form
+* [#7809](https://github.com/netbox-community/netbox/issues/7809) - Add missing export template support for various models
+* [#7814](https://github.com/netbox-community/netbox/issues/7814) - Fix restriction of user & group objects in GraphQL API queries
---
@@ -400,7 +466,7 @@ Note that NetBox's `rqworker` process will _not_ service custom queues by defaul
* [#6154](https://github.com/netbox-community/netbox/issues/6154) - Allow decimal values for cable lengths
* [#6328](https://github.com/netbox-community/netbox/issues/6328) - Build and serve documentation locally
-### Bug Fixes (from v3.2-beta2)
+### Bug Fixes (from v3.0-beta2)
* [#6977](https://github.com/netbox-community/netbox/issues/6977) - Truncate global search dropdown on small screens
* [#6979](https://github.com/netbox-community/netbox/issues/6979) - Hide "create & add another" button for circuit terminations
diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md
new file mode 100644
index 000000000..b117107b6
--- /dev/null
+++ b/docs/release-notes/version-3.1.md
@@ -0,0 +1,192 @@
+# NetBox v3.1
+
+## v3.1.0 (2021-12-06)
+
+!!! warning "PostgreSQL 10 Required"
+ NetBox v3.1 requires PostgreSQL 10 or later.
+
+### Breaking Changes
+
+* The `tenant` and `tenant_id` filters for the Cable model now filter on the tenant assigned directly to each cable, rather than on the parent object of either termination.
+* The `cable_peer` and `cable_peer_type` attributes of cable termination models have been renamed to `link_peer` and `link_peer_type`, respectively, to accommodate wireless links between interfaces.
+* Exported webhooks and custom fields now reference associated content types by raw string value (e.g. "dcim.site") rather than by human-friendly name.
+* The 128GFC interface type has been corrected from `128gfc-sfp28` to `128gfc-qsfp28`.
+
+### New Features
+
+#### Contact Objects ([#1344](https://github.com/netbox-community/netbox/issues/1344))
+
+A set of new models for tracking contact information has been introduced within the tenancy app. Users may now create individual contact objects to be associated with various models within NetBox. Each contact has a name, title, email address, etc. Contacts can be arranged in hierarchical groups for ease of management.
+
+When assigning a contact to an object, the user must select a predefined role (e.g. "billing" or "technical") and may optionally indicate a priority relative to other contacts associated with the object. There is no limit on how many contacts can be assigned to an object, nor on how many objects to which a contact can be assigned.
+
+#### Wireless Networks ([#3979](https://github.com/netbox-community/netbox/issues/3979))
+
+This release introduces two new models to represent wireless networks:
+
+* Wireless LAN - A multi-access wireless segment to which any number of wireless interfaces may be attached
+* Wireless Link - A point-to-point connection between exactly two wireless interfaces
+
+Both types of connection include SSID and authentication attributes. Additionally, the interface model has been extended to include several attributes pertinent to wireless operation:
+
+* Wireless role - Access point or station
+* Channel - A predefined channel within a standardized band
+* Channel frequency & width - Customizable channel attributes (e.g. for licensed bands)
+
+#### Dynamic Configuration Updates ([#5883](https://github.com/netbox-community/netbox/issues/5883))
+
+Some parameters of NetBox's configuration are now accessible via the admin UI. These parameters can be modified by an administrator and take effect immediately upon application: There is no need to restart NetBox. Additionally, each iteration of the dynamic configuration is preserved in the database, and can be restored by an administrator at any time.
+
+Dynamic configuration parameters may also still be defined within `configuration.py`, and the settings defined here take precedence over those defined via the user interface.
+
+For a complete list of supported parameters, please see the [dynamic configuration documentation](../configuration/dynamic-settings.md).
+
+#### First Hop Redundancy Protocol (FHRP) Groups ([#6235](https://github.com/netbox-community/netbox/issues/6235))
+
+A new FHRP group model has been introduced to aid in modeling the configurations of protocols such as HSRP, VRRP, and GLBP. Each FHRP group may be assigned one or more virtual IP addresses, as well as an authentication type and key. Member device and VM interfaces may be associated with one or more FHRP groups, with each assignment receiving a numeric priority designation.
+
+#### Conditional Webhooks ([#6238](https://github.com/netbox-community/netbox/issues/6238))
+
+Webhooks now include a `conditions` field, which may be used to specify conditions under which a webhook triggers. For example, you may wish to generate outgoing requests for a device webhook only when its status is "active" or "staged". This can be done by declaring conditional logic in JSON:
+
+```json
+{
+ "attr": "status.value",
+ "op": "in",
+ "value": ["active", "staged"]
+}
+```
+
+Multiple conditions may be nested using AND/OR logic as well. For more information, please see the [conditional logic documentation](../reference/conditions.md).
+
+#### Interface Bridging ([#6346](https://github.com/netbox-community/netbox/issues/6346))
+
+A `bridge` field has been added to the interface model for devices and virtual machines. This can be set to reference another interface on the same parent device/VM to indicate a direct layer two bridging adjacency. Additionally, "bridge" has been added as an interface type. (However, interfaces of any type may be designated as bridged.)
+
+Multiple interfaces can be bridged to a single virtual interface to effect a bridge group. Alternatively, two physical interfaces can be bridged to one another, to effect an internal cross-connect.
+
+#### Multiple ASNs per Site ([#6732](https://github.com/netbox-community/netbox/issues/6732))
+
+With the introduction of the new ASN model, NetBox now supports the assignment of multiple ASNs per site. Each ASN instance must have a 32-bit AS number, and may optionally be assigned to a RIR and/or Tenant.
+
+The `asn` integer field on the site model has been preserved to maintain backward compatability until a later release.
+
+#### Single Sign-On (SSO) Authentication ([#7649](https://github.com/netbox-community/netbox/issues/7649))
+
+Support for single sign-on (SSO) authentication has been added via the [python-social-auth](https://github.com/python-social-auth) library. NetBox administrators can configure one of the [supported authentication backends](https://python-social-auth.readthedocs.io/en/latest/intro.html#auth-providers) to enable SSO authentication for users.
+
+### Enhancements
+
+* [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces
+* [#1943](https://github.com/netbox-community/netbox/issues/1943) - Relax uniqueness constraint on cluster names
+* [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices
+* [#5143](https://github.com/netbox-community/netbox/issues/5143) - Include a device's asset tag in its display value
+* [#6497](https://github.com/netbox-community/netbox/issues/6497) - Extend tag support to organizational models
+* [#6615](https://github.com/netbox-community/netbox/issues/6615) - Add filter lookups for custom fields
+* [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support
+* [#6715](https://github.com/netbox-community/netbox/issues/6715) - Add tenant assignment for cables
+* [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations
+* [#7354](https://github.com/netbox-community/netbox/issues/7354) - Relax uniqueness constraints on region, site group, and location names
+* [#7452](https://github.com/netbox-community/netbox/issues/7452) - Add `json` custom field type
+* [#7530](https://github.com/netbox-community/netbox/issues/7530) - Move device type component lists to separate views
+* [#7606](https://github.com/netbox-community/netbox/issues/7606) - Model transmit power for interfaces
+* [#7619](https://github.com/netbox-community/netbox/issues/7619) - Permit custom validation rules to be defined as plain data or dotted path to class
+* [#7761](https://github.com/netbox-community/netbox/issues/7761) - Extend cable tracing across bridged interfaces
+* [#7812](https://github.com/netbox-community/netbox/issues/7812) - Enable change logging for image attachments
+* [#7858](https://github.com/netbox-community/netbox/issues/7858) - Standardize the representation of content types across import & export functions
+
+### Bug Fixes
+
+* [#7589](https://github.com/netbox-community/netbox/issues/7589) - Correct 128GFC interface type identifier
+
+### Other Changes
+
+* [#7318](https://github.com/netbox-community/netbox/issues/7318) - Raise minimum required PostgreSQL version from 9.6 to 10
+
+### REST API Changes
+
+* Added the following endpoints for ASNs:
+ * `/api/ipam/asn/`
+* Added the following endpoints for FHRP groups:
+ * `/api/ipam/fhrp-groups/`
+ * `/api/ipam/fhrp-group-assignments/`
+* Added the following endpoints for contacts:
+ * `/api/tenancy/contact-assignments/`
+ * `/api/tenancy/contact-groups/`
+ * `/api/tenancy/contact-roles/`
+ * `/api/tenancy/contacts/`
+* Added the following endpoints for wireless networks:
+ * `/api/wireless/wireless-lans/`
+ * `/api/wireless/wireless-lan-groups/`
+ * `/api/wireless/wireless-links/`
+* Added `tags` field to the following models:
+ * circuits.CircuitType
+ * dcim.DeviceRole
+ * dcim.Location
+ * dcim.Manufacturer
+ * dcim.Platform
+ * dcim.RackRole
+ * dcim.Region
+ * dcim.SiteGroup
+ * ipam.RIR
+ * ipam.Role
+ * ipam.VLANGroup
+ * tenancy.ContactGroup
+ * tenancy.ContactRole
+ * tenancy.TenantGroup
+ * virtualization.ClusterGroup
+ * virtualization.ClusterType
+* circuits.CircuitTermination
+ * `cable_peer` has been renamed to `link_peer`
+ * `cable_peer_type` has been renamed to `link_peer_type`
+* dcim.Cable
+ * Added `tenant` field
+* dcim.ConsolePort
+ * `cable_peer` has been renamed to `link_peer`
+ * `cable_peer_type` has been renamed to `link_peer_type`
+* dcim.ConsoleServerPort
+ * `cable_peer` has been renamed to `link_peer`
+ * `cable_peer_type` has been renamed to `link_peer_type`
+* dcim.Device
+ * The `display` field now includes the device's asset tag, if set
+ * Added `airflow` field
+* dcim.DeviceType
+ * Added `airflow` field
+* dcim.FrontPort
+ * `cable_peer` has been renamed to `link_peer`
+ * `cable_peer_type` has been renamed to `link_peer_type`
+* dcim.Interface
+ * `cable_peer` has been renamed to `link_peer`
+ * `cable_peer_type` has been renamed to `link_peer_type`
+ * Added `bridge` field
+ * Added `rf_channel` field
+ * Added `rf_channel_frequency` field
+ * Added `rf_channel_width` field
+ * Added `rf_role` field
+ * Added `tx_power` field
+ * Added `wireless_link` field
+ * Added `wwn` field
+ * Added `count_fhrp_groups` read-only field
+* dcim.Location
+ * Added `tenant` field
+* dcim.PowerFeed
+ * `cable_peer` has been renamed to `link_peer`
+ * `cable_peer_type` has been renamed to `link_peer_type`
+* dcim.PowerOutlet
+ * `cable_peer` has been renamed to `link_peer`
+ * `cable_peer_type` has been renamed to `link_peer_type`
+* dcim.PowerPort
+ * `cable_peer` has been renamed to `link_peer`
+ * `cable_peer_type` has been renamed to `link_peer_type`
+* dcim.RearPort
+ * `cable_peer` has been renamed to `link_peer`
+ * `cable_peer_type` has been renamed to `link_peer_type`
+* dcim.Site
+ * Added `asns` relationship to ipam.ASN
+* extras.ImageAttachment
+ * Added the `last_updated` field
+* extras.Webhook
+ * Added the `conditions` field
+* virtualization.VMInterface
+ * Added `bridge` field
+ * Added `count_fhrp_groups` read-only field
diff --git a/docs/rest-api/overview.md b/docs/rest-api/overview.md
index 6a9235438..27a9b6a7e 100644
--- a/docs/rest-api/overview.md
+++ b/docs/rest-api/overview.md
@@ -308,7 +308,7 @@ Vary: Accept
}
```
-The default page is determined by the [`PAGINATE_COUNT`](../configuration/optional-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
+The default page is determined by the [`PAGINATE_COUNT`](../configuration/dynamic-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
```
http://netbox/api/dcim/devices/?limit=100
@@ -325,7 +325,7 @@ The response will return devices 1 through 100. The URL provided in the `next` a
}
```
-The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/optional-settings.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request.
+The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/dynamic-settings.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request.
!!! warning
Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database.
diff --git a/mkdocs.yml b/mkdocs.yml
index d12ef734f..3fb838ffd 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -51,6 +51,8 @@ nav:
- Configuring NetBox: 'configuration/index.md'
- Required Settings: 'configuration/required-settings.md'
- Optional Settings: 'configuration/optional-settings.md'
+ - Dynamic Settings: 'configuration/dynamic-settings.md'
+ - Remote Authentication: 'configuration/remote-authentication.md'
- Core Functionality:
- IP Address Management: 'core-functionality/ipam.md'
- VLAN Management: 'core-functionality/vlans.md'
@@ -60,8 +62,10 @@ nav:
- Virtualization: 'core-functionality/virtualization.md'
- Service Mapping: 'core-functionality/services.md'
- Circuits: 'core-functionality/circuits.md'
+ - Wireless: 'core-functionality/wireless.md'
- Power Tracking: 'core-functionality/power.md'
- Tenancy: 'core-functionality/tenancy.md'
+ - Contacts: 'core-functionality/contacts.md'
- Customization:
- Custom Fields: 'customization/custom-fields.md'
- Custom Validation: 'customization/custom-validation.md'
@@ -81,6 +85,7 @@ nav:
- Using Plugins: 'plugins/index.md'
- Developing Plugins: 'plugins/development.md'
- Administration:
+ - Authentication: 'administration/authentication.md'
- Permissions: 'administration/permissions.md'
- Housekeeping: 'administration/housekeeping.md'
- Replicating NetBox: 'administration/replicating-netbox.md'
@@ -91,6 +96,8 @@ nav:
- Authentication: 'rest-api/authentication.md'
- GraphQL API:
- Overview: 'graphql-api/overview.md'
+ - Reference:
+ - Conditions: 'reference/conditions.md'
- Development:
- Introduction: 'development/index.md'
- Getting Started: 'development/getting-started.md'
@@ -104,6 +111,8 @@ nav:
- Web UI: 'development/web-ui.md'
- Release Checklist: 'development/release-checklist.md'
- Release Notes:
+ - Summary: 'release-notes/index.md'
+ - Version 3.1: 'release-notes/version-3.1.md'
- Version 3.0: 'release-notes/version-3.0.md'
- Version 2.11: 'release-notes/version-2.11.md'
- Version 2.10: 'release-notes/version-2.10.md'
diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py
index ac6285610..470a0b030 100644
--- a/netbox/circuits/api/serializers.py
+++ b/netbox/circuits/api/serializers.py
@@ -3,11 +3,9 @@ from rest_framework import serializers
from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
-from dcim.api.serializers import CableTerminationSerializer
+from dcim.api.serializers import LinkTerminationSerializer
from netbox.api import ChoiceField
-from netbox.api.serializers import (
- OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
-)
+from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from .nested_serializers import *
@@ -48,14 +46,14 @@ class ProviderNetworkSerializer(PrimaryModelSerializer):
# Circuits
#
-class CircuitTypeSerializer(OrganizationalModelSerializer):
+class CircuitTypeSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
circuit_count = serializers.IntegerField(read_only=True)
class Meta:
model = CircuitType
fields = [
- 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+ 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'circuit_count',
]
@@ -90,7 +88,7 @@ class CircuitSerializer(PrimaryModelSerializer):
]
-class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSerializer):
+class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer(required=False, allow_null=True)
@@ -101,6 +99,6 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSer
model = CircuitTermination
fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
- 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type',
+ 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'_occupied',
]
diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py
index 3bceb2de0..2b3e3b122 100644
--- a/netbox/circuits/api/views.py
+++ b/netbox/circuits/api/views.py
@@ -34,7 +34,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
#
class CircuitTypeViewSet(CustomFieldModelViewSet):
- queryset = CircuitType.objects.annotate(
+ queryset = CircuitType.objects.prefetch_related('tags').annotate(
circuit_count=count_related(Circuit, 'type')
)
serializer_class = serializers.CircuitTypeSerializer
diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py
index 15bc5a8b3..fd582dd99 100644
--- a/netbox/circuits/filtersets.py
+++ b/netbox/circuits/filtersets.py
@@ -111,6 +111,7 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet):
class CircuitTypeFilterSet(OrganizationalModelFilterSet):
+ tag = TagFilter()
class Meta:
model = CircuitType
diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py
index 638426a5e..37edd3a62 100644
--- a/netbox/circuits/forms/bulk_edit.py
+++ b/netbox/circuits/forms/bulk_edit.py
@@ -4,9 +4,7 @@ from circuits.choices import CircuitStatusChoices
from circuits.models import *
from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
from tenancy.models import Tenant
-from utilities.forms import (
- add_blank_choice, BootstrapMixin, CommentField, DynamicModelChoiceField, SmallTextarea, StaticSelect,
-)
+from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea, StaticSelect
__all__ = (
'CircuitBulkEditForm',
@@ -16,7 +14,7 @@ __all__ = (
)
-class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ProviderBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Provider.objects.all(),
widget=forms.MultipleHiddenInput
@@ -55,7 +53,7 @@ class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBu
]
-class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ProviderNetworkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ProviderNetwork.objects.all(),
widget=forms.MultipleHiddenInput
@@ -79,7 +77,7 @@ class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
]
-class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class CircuitTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
widget=forms.MultipleHiddenInput
@@ -93,7 +91,7 @@ class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['description']
-class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class CircuitBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Circuit.objects.all(),
widget=forms.MultipleHiddenInput
diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py
index 63b654148..0822ff206 100644
--- a/netbox/circuits/forms/filtersets.py
+++ b/netbox/circuits/forms/filtersets.py
@@ -6,7 +6,7 @@ from circuits.models import *
from dcim.models import Region, Site, SiteGroup
from extras.forms import CustomFieldModelFilterForm
from tenancy.forms import TenancyFilterForm
-from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
+from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
__all__ = (
'CircuitFilterForm',
@@ -16,18 +16,13 @@ __all__ = (
)
-class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class ProviderFilterForm(CustomFieldModelFilterForm):
model = Provider
field_groups = [
['q', 'tag'],
['region_id', 'site_group_id', 'site_id'],
['asn'],
]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -57,17 +52,12 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
-class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class ProviderNetworkFilterForm(CustomFieldModelFilterForm):
model = ProviderNetwork
field_groups = (
('q', 'tag'),
('provider_id',),
)
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
@@ -77,19 +67,12 @@ class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
-class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class CircuitTypeFilterForm(CustomFieldModelFilterForm):
model = CircuitType
- field_groups = [
- ['q'],
- ]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
+ tag = TagFilterField(model)
-class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Circuit
field_groups = [
['q', 'tag'],
@@ -98,11 +81,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
['region_id', 'site_group_id', 'site_id'],
['tenant_group_id', 'tenant_id'],
]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
type_id = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
required=False,
diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py
index 659939293..2ea246fd0 100644
--- a/netbox/circuits/forms/models.py
+++ b/netbox/circuits/forms/models.py
@@ -19,7 +19,7 @@ __all__ = (
)
-class ProviderForm(BootstrapMixin, CustomFieldModelForm):
+class ProviderForm(CustomFieldModelForm):
slug = SlugField()
comments = CommentField()
tags = DynamicModelMultipleChoiceField(
@@ -53,7 +53,7 @@ class ProviderForm(BootstrapMixin, CustomFieldModelForm):
}
-class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
+class ProviderNetworkForm(CustomFieldModelForm):
provider = DynamicModelChoiceField(
queryset=Provider.objects.all()
)
@@ -73,17 +73,21 @@ class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
)
-class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
+class CircuitTypeForm(CustomFieldModelForm):
slug = SlugField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = CircuitType
fields = [
- 'name', 'slug', 'description',
+ 'name', 'slug', 'description', 'tags',
]
-class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+class CircuitForm(TenancyForm, CustomFieldModelForm):
provider = DynamicModelChoiceField(
queryset=Provider.objects.all()
)
diff --git a/netbox/circuits/migrations/0003_extend_tag_support.py b/netbox/circuits/migrations/0003_extend_tag_support.py
new file mode 100644
index 000000000..e5e6ee262
--- /dev/null
+++ b/netbox/circuits/migrations/0003_extend_tag_support.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.8 on 2021-10-21 14:50
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0062_clear_secrets_changelog'),
+ ('circuits', '0002_squashed_0029'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='circuittype',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ ]
diff --git a/netbox/circuits/migrations/0004_rename_cable_peer.py b/netbox/circuits/migrations/0004_rename_cable_peer.py
new file mode 100644
index 000000000..81d507eb4
--- /dev/null
+++ b/netbox/circuits/migrations/0004_rename_cable_peer.py
@@ -0,0 +1,21 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('circuits', '0003_extend_tag_support'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='circuittermination',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='circuittermination',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ ]
diff --git a/netbox/circuits/models/__init__.py b/netbox/circuits/models/__init__.py
new file mode 100644
index 000000000..7bbaf75d3
--- /dev/null
+++ b/netbox/circuits/models/__init__.py
@@ -0,0 +1,2 @@
+from .circuits import *
+from .providers import *
diff --git a/netbox/circuits/models.py b/netbox/circuits/models/circuits.py
similarity index 67%
rename from netbox/circuits/models.py
rename to netbox/circuits/models/circuits.py
index bc7dcc219..602c0f403 100644
--- a/netbox/circuits/models.py
+++ b/netbox/circuits/models/circuits.py
@@ -3,127 +3,19 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
-from dcim.fields import ASNField
-from dcim.models import CableTermination, PathEndpoint
-from extras.models import ObjectChange
+from circuits.choices import *
+from dcim.models import LinkTermination
from extras.utils import extras_features
-from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel
-from utilities.querysets import RestrictedQuerySet
-from .choices import *
-
+from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
__all__ = (
'Circuit',
'CircuitTermination',
'CircuitType',
- 'ProviderNetwork',
- 'Provider',
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class Provider(PrimaryModel):
- """
- Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
- stores information pertinent to the user's relationship with the Provider.
- """
- name = models.CharField(
- max_length=100,
- unique=True
- )
- slug = models.SlugField(
- max_length=100,
- unique=True
- )
- asn = ASNField(
- blank=True,
- null=True,
- verbose_name='ASN',
- help_text='32-bit autonomous system number'
- )
- account = models.CharField(
- max_length=30,
- blank=True,
- verbose_name='Account number'
- )
- portal_url = models.URLField(
- blank=True,
- verbose_name='Portal URL'
- )
- noc_contact = models.TextField(
- blank=True,
- verbose_name='NOC contact'
- )
- admin_contact = models.TextField(
- blank=True,
- verbose_name='Admin contact'
- )
- comments = models.TextField(
- blank=True
- )
-
- objects = RestrictedQuerySet.as_manager()
-
- clone_fields = [
- 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact',
- ]
-
- class Meta:
- ordering = ['name']
-
- def __str__(self):
- return self.name
-
- def get_absolute_url(self):
- return reverse('circuits:provider', args=[self.pk])
-
-
-#
-# Provider networks
-#
-
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class ProviderNetwork(PrimaryModel):
- """
- This represents a provider network which exists outside of NetBox, the details of which are unknown or
- unimportant to the user.
- """
- name = models.CharField(
- max_length=100
- )
- provider = models.ForeignKey(
- to='circuits.Provider',
- on_delete=models.PROTECT,
- related_name='networks'
- )
- description = models.CharField(
- max_length=200,
- blank=True
- )
- comments = models.TextField(
- blank=True
- )
-
- objects = RestrictedQuerySet.as_manager()
-
- class Meta:
- ordering = ('provider', 'name')
- constraints = (
- models.UniqueConstraint(
- fields=('provider', 'name'),
- name='circuits_providernetwork_provider_name'
- ),
- )
- unique_together = ('provider', 'name')
-
- def __str__(self):
- return self.name
-
- def get_absolute_url(self):
- return reverse('circuits:providernetwork', args=[self.pk])
-
-
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class CircuitType(OrganizationalModel):
"""
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
@@ -142,8 +34,6 @@ class CircuitType(OrganizationalModel):
blank=True,
)
- objects = RestrictedQuerySet.as_manager()
-
class Meta:
ordering = ['name']
@@ -203,6 +93,11 @@ class Circuit(PrimaryModel):
comments = models.TextField(
blank=True
)
+
+ # Generic relations
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
images = GenericRelation(
to='extras.ImageAttachment'
)
@@ -225,8 +120,6 @@ class Circuit(PrimaryModel):
null=True
)
- objects = RestrictedQuerySet.as_manager()
-
clone_fields = [
'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
]
@@ -246,7 +139,7 @@ class Circuit(PrimaryModel):
@extras_features('webhooks')
-class CircuitTermination(ChangeLoggedModel, CableTermination):
+class CircuitTermination(ChangeLoggedModel, LinkTermination):
circuit = models.ForeignKey(
to='circuits.Circuit',
on_delete=models.CASCADE,
@@ -265,7 +158,7 @@ class CircuitTermination(ChangeLoggedModel, CableTermination):
null=True
)
provider_network = models.ForeignKey(
- to=ProviderNetwork,
+ to='circuits.ProviderNetwork',
on_delete=models.PROTECT,
related_name='circuit_terminations',
blank=True,
@@ -297,8 +190,6 @@ class CircuitTermination(ChangeLoggedModel, CableTermination):
blank=True
)
- objects = RestrictedQuerySet.as_manager()
-
class Meta:
ordering = ['circuit', 'term_side']
unique_together = ['circuit', 'term_side']
diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py
new file mode 100644
index 000000000..b3a6902f9
--- /dev/null
+++ b/netbox/circuits/models/providers.py
@@ -0,0 +1,112 @@
+from django.contrib.contenttypes.fields import GenericRelation
+from django.db import models
+from django.urls import reverse
+
+from dcim.fields import ASNField
+from extras.utils import extras_features
+from netbox.models import PrimaryModel
+from utilities.querysets import RestrictedQuerySet
+
+__all__ = (
+ 'ProviderNetwork',
+ 'Provider',
+)
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
+class Provider(PrimaryModel):
+ """
+ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
+ stores information pertinent to the user's relationship with the Provider.
+ """
+ name = models.CharField(
+ max_length=100,
+ unique=True
+ )
+ slug = models.SlugField(
+ max_length=100,
+ unique=True
+ )
+ asn = ASNField(
+ blank=True,
+ null=True,
+ verbose_name='ASN',
+ help_text='32-bit autonomous system number'
+ )
+ account = models.CharField(
+ max_length=30,
+ blank=True,
+ verbose_name='Account number'
+ )
+ portal_url = models.URLField(
+ blank=True,
+ verbose_name='Portal URL'
+ )
+ noc_contact = models.TextField(
+ blank=True,
+ verbose_name='NOC contact'
+ )
+ admin_contact = models.TextField(
+ blank=True,
+ verbose_name='Admin contact'
+ )
+ comments = models.TextField(
+ blank=True
+ )
+
+ # Generic relations
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
+
+ clone_fields = [
+ 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact',
+ ]
+
+ class Meta:
+ ordering = ['name']
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('circuits:provider', args=[self.pk])
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
+class ProviderNetwork(PrimaryModel):
+ """
+ This represents a provider network which exists outside of NetBox, the details of which are unknown or
+ unimportant to the user.
+ """
+ name = models.CharField(
+ max_length=100
+ )
+ provider = models.ForeignKey(
+ to='circuits.Provider',
+ on_delete=models.PROTECT,
+ related_name='networks'
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+ comments = models.TextField(
+ blank=True
+ )
+
+ class Meta:
+ ordering = ('provider', 'name')
+ constraints = (
+ models.UniqueConstraint(
+ fields=('provider', 'name'),
+ name='circuits_providernetwork_provider_name'
+ ),
+ )
+ unique_together = ('provider', 'name')
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('circuits:providernetwork', args=[self.pk])
diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py
index 1748e6110..86a55eba5 100644
--- a/netbox/circuits/tables.py
+++ b/netbox/circuits/tables.py
@@ -82,6 +82,9 @@ class CircuitTypeTable(BaseTable):
name = tables.Column(
linkify=True
)
+ tags = TagColumn(
+ url_name='circuits:circuittype_list'
+ )
circuit_count = tables.Column(
verbose_name='Circuits'
)
@@ -89,7 +92,7 @@ class CircuitTypeTable(BaseTable):
class Meta(BaseTable.Meta):
model = CircuitType
- fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'actions')
+ fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions')
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py
index ccb4a869a..851d52ae8 100644
--- a/netbox/circuits/tests/test_views.py
+++ b/netbox/circuits/tests/test_views.py
@@ -64,10 +64,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
])
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'name': 'Circuit Type X',
'slug': 'circuit-type-x',
'description': 'A new circuit type',
+ 'tags': [t.pk for t in tags],
}
cls.csv_data = (
diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py
index 67ae9b046..1fdde78d7 100644
--- a/netbox/dcim/api/nested_serializers.py
+++ b/netbox/dcim/api/nested_serializers.py
@@ -340,7 +340,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
class Meta:
model = models.VirtualChassis
- fields = ['id', 'name', 'url', 'master', 'member_count']
+ fields = ['id', 'url', 'display', 'name', 'master', 'member_count']
#
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 8e2fa15af..45930c5f5 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -1,46 +1,47 @@
-from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
-from rest_framework.validators import UniqueTogetherValidator
from timezone_field.rest_framework import TimeZoneSerializerField
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
-from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
-from ipam.models import VLAN
+from ipam.api.nested_serializers import NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer
+from ipam.models import ASN, VLAN
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import (
- NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer,
- WritableNestedSerializer,
+ NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
)
+from netbox.config import ConfigItem
from tenancy.api.nested_serializers import NestedTenantSerializer
from users.api.nested_serializers import NestedUserSerializer
from utilities.api import get_serializer_for_model
from virtualization.api.nested_serializers import NestedClusterSerializer
+from wireless.api.nested_serializers import NestedWirelessLANSerializer, NestedWirelessLinkSerializer
+from wireless.choices import *
+from wireless.models import WirelessLAN
from .nested_serializers import *
-class CableTerminationSerializer(serializers.ModelSerializer):
- cable_peer_type = serializers.SerializerMethodField(read_only=True)
- cable_peer = serializers.SerializerMethodField(read_only=True)
+class LinkTerminationSerializer(serializers.ModelSerializer):
+ link_peer_type = serializers.SerializerMethodField(read_only=True)
+ link_peer = serializers.SerializerMethodField(read_only=True)
_occupied = serializers.SerializerMethodField(read_only=True)
- def get_cable_peer_type(self, obj):
- if obj._cable_peer is not None:
- return f'{obj._cable_peer._meta.app_label}.{obj._cable_peer._meta.model_name}'
+ def get_link_peer_type(self, obj):
+ if obj._link_peer is not None:
+ return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}'
return None
@swagger_serializer_method(serializer_or_field=serializers.DictField)
- def get_cable_peer(self, obj):
+ def get_link_peer(self, obj):
"""
- Return the appropriate serializer for the cable termination model.
+ Return the appropriate serializer for the link termination model.
"""
- if obj._cable_peer is not None:
- serializer = get_serializer_for_model(obj._cable_peer, prefix='Nested')
+ if obj._link_peer is not None:
+ serializer = get_serializer_for_model(obj._link_peer, prefix='Nested')
context = {'request': self.context['request']}
- return serializer(obj._cable_peer, context=context).data
+ return serializer(obj._link_peer, context=context).data
return None
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
@@ -82,27 +83,27 @@ class ConnectedEndpointSerializer(serializers.ModelSerializer):
class RegionSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
- parent = NestedRegionSerializer(required=False, allow_null=True)
+ parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
site_count = serializers.IntegerField(read_only=True)
class Meta:
model = Region
fields = [
- 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
- 'site_count', '_depth',
+ 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+ 'last_updated', 'site_count', '_depth',
]
class SiteGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
- parent = NestedSiteGroupSerializer(required=False, allow_null=True)
+ parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
site_count = serializers.IntegerField(read_only=True)
class Meta:
model = SiteGroup
fields = [
- 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
- 'site_count', '_depth',
+ 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+ 'last_updated', 'site_count', '_depth',
]
@@ -113,6 +114,14 @@ class SiteSerializer(PrimaryModelSerializer):
group = NestedSiteGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
time_zone = TimeZoneSerializerField(required=False)
+ asns = SerializedPKRelatedField(
+ queryset=ASN.objects.all(),
+ serializer=NestedASNSerializer,
+ required=False,
+ many=True
+ )
+
+ # Related object counts
circuit_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True)
@@ -123,7 +132,7 @@ class SiteSerializer(PrimaryModelSerializer):
class Meta:
model = Site
fields = [
- 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn',
+ 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'asns',
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
@@ -138,26 +147,27 @@ class LocationSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
site = NestedSiteSerializer()
parent = NestedLocationSerializer(required=False, allow_null=True)
+ tenant = NestedTenantSerializer(required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True)
class Meta:
model = Location
fields = [
- 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'description', 'custom_fields', 'created',
- 'last_updated', 'rack_count', 'device_count', '_depth',
+ 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields',
+ 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
]
-class RackRoleSerializer(OrganizationalModelSerializer):
+class RackRoleSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
rack_count = serializers.IntegerField(read_only=True)
class Meta:
model = RackRole
fields = [
- 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated',
- 'rack_count',
+ 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
+ 'last_updated', 'rack_count',
]
@@ -169,6 +179,8 @@ class RackSerializer(PrimaryModelSerializer):
status = ChoiceField(choices=RackStatusChoices, required=False)
role = NestedRackRoleSerializer(required=False, allow_null=True)
type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False)
+ facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label='Facility ID',
+ default=None)
width = ChoiceField(choices=RackWidthChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
device_count = serializers.IntegerField(read_only=True)
@@ -181,23 +193,6 @@ class RackSerializer(PrimaryModelSerializer):
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
]
- # Omit the UniqueTogetherValidator that would be automatically added to validate (location, facility_id). This
- # prevents facility_id from being interpreted as a required field.
- validators = [
- UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('location', 'name'))
- ]
-
- def validate(self, data):
-
- # Validate uniqueness of (location, facility_id) since we omitted the automatically-created validator from Meta.
- if data.get('facility_id', None):
- validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('location', 'facility_id'))
- validator(data, self)
-
- # Enforce model validation
- super().validate(data)
-
- return data
class RackUnitSerializer(serializers.Serializer):
@@ -243,10 +238,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
default=RackElevationDetailRenderChoices.RENDER_JSON
)
unit_width = serializers.IntegerField(
- default=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
+ default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_WIDTH')
)
unit_height = serializers.IntegerField(
- default=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
+ default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
)
legend_width = serializers.IntegerField(
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
@@ -269,7 +264,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
# Device types
#
-class ManufacturerSerializer(OrganizationalModelSerializer):
+class ManufacturerSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
devicetype_count = serializers.IntegerField(read_only=True)
inventoryitem_count = serializers.IntegerField(read_only=True)
@@ -278,7 +273,7 @@ class ManufacturerSerializer(OrganizationalModelSerializer):
class Meta:
model = Manufacturer
fields = [
- 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+ 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'devicetype_count', 'inventoryitem_count', 'platform_count',
]
@@ -287,13 +282,14 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer()
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
+ airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
device_count = serializers.IntegerField(read_only=True)
class Meta:
model = DeviceType
fields = [
'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
- 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created',
+ 'subdevice_role', 'airflow', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'device_count',
]
@@ -356,7 +352,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
required=False
)
power_port = NestedPowerPortTemplateSerializer(
- required=False
+ required=False,
+ allow_null=True
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,
@@ -425,7 +422,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
# Devices
#
-class DeviceRoleSerializer(OrganizationalModelSerializer):
+class DeviceRoleSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True)
@@ -433,12 +430,12 @@ class DeviceRoleSerializer(OrganizationalModelSerializer):
class Meta:
model = DeviceRole
fields = [
- 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created',
- 'last_updated', 'device_count', 'virtualmachine_count',
+ 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'tags', 'custom_fields',
+ 'created', 'last_updated', 'device_count', 'virtualmachine_count',
]
-class PlatformSerializer(OrganizationalModelSerializer):
+class PlatformSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True)
@@ -448,7 +445,7 @@ class PlatformSerializer(OrganizationalModelSerializer):
model = Platform
fields = [
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description',
- 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
+ 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
]
@@ -456,41 +453,31 @@ class DeviceSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = NestedDeviceTypeSerializer()
device_role = NestedDeviceRoleSerializer()
- tenant = NestedTenantSerializer(required=False, allow_null=True)
+ tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer()
location = NestedLocationSerializer(required=False, allow_null=True, default=None)
- rack = NestedRackSerializer(required=False, allow_null=True)
- face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False)
+ rack = NestedRackSerializer(required=False, allow_null=True, default=None)
+ face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='')
+ position = serializers.IntegerField(allow_null=True, label='Position (U)', min_value=1, default=None)
status = ChoiceField(choices=DeviceStatusChoices, required=False)
+ airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
primary_ip = NestedIPAddressSerializer(read_only=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer(required=False, allow_null=True)
- virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True)
+ virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
+ vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
class Meta:
model = Device
fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
- 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
- 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
- 'tags', 'custom_fields', 'created', 'last_updated',
+ 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
+ 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
+ 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
]
- validators = []
-
- def validate(self, data):
-
- # Validate uniqueness of (rack, position, face) since we omitted the automatically-created validator from Meta.
- if data.get('rack') and data.get('position') and data.get('face'):
- validator = UniqueTogetherValidator(queryset=Device.objects.all(), fields=('rack', 'position', 'face'))
- validator(data, self)
-
- # Enforce model validation
- super().validate(data)
-
- return data
@swagger_serializer_method(serializer_or_field=NestedDeviceSerializer)
def get_parent_device(self, obj):
@@ -528,7 +515,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
# Device components
#
-class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@@ -538,7 +525,7 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerial
)
speed = ChoiceField(
choices=ConsolePortSpeedChoices,
- allow_blank=True,
+ allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -547,12 +534,12 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerial
model = ConsoleServerPort
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected',
- 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+ 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
-class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@@ -562,7 +549,7 @@ class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer,
)
speed = ChoiceField(
choices=ConsolePortSpeedChoices,
- allow_blank=True,
+ allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -571,12 +558,12 @@ class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer,
model = ConsolePort
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected',
- 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+ 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
-class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@@ -585,7 +572,8 @@ class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer,
required=False
)
power_port = NestedPowerPortSerializer(
- required=False
+ required=False,
+ allow_null=True
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,
@@ -600,12 +588,12 @@ class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer,
model = PowerOutlet
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
- 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+ 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
-class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@@ -619,18 +607,21 @@ class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
model = PowerPort
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
- 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+ 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
-class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=InterfaceTypeChoices)
parent = NestedInterfaceSerializer(required=False, allow_null=True)
+ bridge = NestedInterfaceSerializer(required=False, allow_null=True)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
+ rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True)
+ rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),
@@ -639,16 +630,25 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
many=True
)
cable = NestedCableSerializer(read_only=True)
+ wireless_link = NestedWirelessLinkSerializer(read_only=True)
+ wireless_lans = SerializedPKRelatedField(
+ queryset=WirelessLAN.objects.all(),
+ serializer=NestedWirelessLANSerializer,
+ required=False,
+ many=True
+ )
count_ipaddresses = serializers.IntegerField(read_only=True)
+ count_fhrp_groups = serializers.IntegerField(read_only=True)
class Meta:
model = Interface
fields = [
- 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address',
- 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable',
- 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu',
+ 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+ 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link',
+ 'link_peer', 'link_peer_type', 'wireless_lans', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
- '_occupied',
+ 'count_fhrp_groups', '_occupied',
]
def validate(self, data):
@@ -665,7 +665,7 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
return super().validate(data)
-class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
+class RearPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices)
@@ -675,7 +675,7 @@ class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
model = RearPort
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'positions', 'description',
- 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created',
+ 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
@@ -691,7 +691,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name', 'label']
-class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
+class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices)
@@ -702,7 +702,7 @@ class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
model = FrontPort
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
- 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields',
+ 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]
@@ -727,7 +727,6 @@ class DeviceBaySerializer(PrimaryModelSerializer):
class InventoryItemSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = NestedDeviceSerializer()
- # Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
_depth = serializers.IntegerField(source='level', read_only=True)
@@ -754,15 +753,16 @@ class CableSerializer(PrimaryModelSerializer):
)
termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = serializers.SerializerMethodField(read_only=True)
- status = ChoiceField(choices=CableStatusChoices, required=False)
+ status = ChoiceField(choices=LinkStatusChoices, required=False)
+ tenant = NestedTenantSerializer(required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
class Meta:
model = Cable
fields = [
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
- 'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
- 'custom_fields',
+ 'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
+ 'tags', 'custom_fields',
]
def _get_termination(self, obj, side):
@@ -878,7 +878,7 @@ class PowerPanelSerializer(PrimaryModelSerializer):
fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count']
-class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer(
@@ -908,7 +908,7 @@ class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
model = PowerFeed
fields = [
'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
- 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type',
+ 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index 2b9d9734c..f359f0f24 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -1,7 +1,6 @@
import socket
from collections import OrderedDict
-from django.conf import settings
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
@@ -16,11 +15,12 @@ from circuits.models import Circuit
from dcim import filtersets
from dcim.models import *
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
-from ipam.models import Prefix, VLAN
+from ipam.models import Prefix, VLAN, ASN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet
+from netbox.config import get_config
from utilities.api import get_serializer_for_model
from utilities.utils import count_related, decode_dict
from virtualization.models import VirtualMachine
@@ -110,7 +110,7 @@ class RegionViewSet(CustomFieldModelViewSet):
'region',
'site_count',
cumulative=True
- )
+ ).prefetch_related('tags')
serializer_class = serializers.RegionSerializer
filterset_class = filtersets.RegionFilterSet
@@ -126,7 +126,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet):
'group',
'site_count',
cumulative=True
- )
+ ).prefetch_related('tags')
serializer_class = serializers.SiteGroupSerializer
filterset_class = filtersets.SiteGroupFilterSet
@@ -137,7 +137,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet):
class SiteViewSet(CustomFieldModelViewSet):
queryset = Site.objects.prefetch_related(
- 'region', 'tenant', 'tags'
+ 'region', 'tenant', 'asns', 'tags'
).annotate(
device_count=count_related(Device, 'site'),
rack_count=count_related(Rack, 'site'),
@@ -167,7 +167,7 @@ class LocationViewSet(CustomFieldModelViewSet):
'location',
'rack_count',
cumulative=True
- ).prefetch_related('site')
+ ).prefetch_related('site', 'tags')
serializer_class = serializers.LocationSerializer
filterset_class = filtersets.LocationFilterSet
@@ -177,7 +177,7 @@ class LocationViewSet(CustomFieldModelViewSet):
#
class RackRoleViewSet(CustomFieldModelViewSet):
- queryset = RackRole.objects.annotate(
+ queryset = RackRole.objects.prefetch_related('tags').annotate(
rack_count=count_related(Rack, 'role')
)
serializer_class = serializers.RackRoleSerializer
@@ -261,7 +261,7 @@ class RackReservationViewSet(ModelViewSet):
#
class ManufacturerViewSet(CustomFieldModelViewSet):
- queryset = Manufacturer.objects.annotate(
+ queryset = Manufacturer.objects.prefetch_related('tags').annotate(
devicetype_count=count_related(DeviceType, 'manufacturer'),
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer')
@@ -340,7 +340,7 @@ class DeviceBayTemplateViewSet(ModelViewSet):
#
class DeviceRoleViewSet(CustomFieldModelViewSet):
- queryset = DeviceRole.objects.annotate(
+ queryset = DeviceRole.objects.prefetch_related('tags').annotate(
device_count=count_related(Device, 'device_role'),
virtualmachine_count=count_related(VirtualMachine, 'role')
)
@@ -353,7 +353,7 @@ class DeviceRoleViewSet(CustomFieldModelViewSet):
#
class PlatformViewSet(CustomFieldModelViewSet):
- queryset = Platform.objects.annotate(
+ queryset = Platform.objects.prefetch_related('tags').annotate(
device_count=count_related(Device, 'platform'),
virtualmachine_count=count_related(VirtualMachine, 'platform')
)
@@ -457,9 +457,12 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
napalm_methods = request.GET.getlist('method')
response = OrderedDict([(m, None) for m in napalm_methods])
- username = settings.NAPALM_USERNAME
- password = settings.NAPALM_PASSWORD
- optional_args = settings.NAPALM_ARGS.copy()
+
+ config = get_config()
+ username = config.NAPALM_USERNAME
+ password = config.NAPALM_PASSWORD
+ timeout = config.NAPALM_TIMEOUT
+ optional_args = config.NAPALM_ARGS.copy()
if device.platform.napalm_args is not None:
optional_args.update(device.platform.napalm_args)
@@ -481,7 +484,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
hostname=host,
username=username,
password=password,
- timeout=settings.NAPALM_TIMEOUT,
+ timeout=timeout,
optional_args=optional_args
)
try:
@@ -513,7 +516,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
#
class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
- queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
+ queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags')
serializer_class = serializers.ConsolePortSerializer
filterset_class = filtersets.ConsolePortFilterSet
brief_prefetch_fields = ['device']
@@ -521,7 +524,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
queryset = ConsoleServerPort.objects.prefetch_related(
- 'device', '_path__destination', 'cable', '_cable_peer', 'tags'
+ 'device', '_path__destination', 'cable', '_link_peer', 'tags'
)
serializer_class = serializers.ConsoleServerPortSerializer
filterset_class = filtersets.ConsoleServerPortFilterSet
@@ -529,14 +532,14 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
- queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
+ queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags')
serializer_class = serializers.PowerPortSerializer
filterset_class = filtersets.PowerPortFilterSet
brief_prefetch_fields = ['device']
class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
- queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
+ queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags')
serializer_class = serializers.PowerOutletSerializer
filterset_class = filtersets.PowerOutletFilterSet
brief_prefetch_fields = ['device']
@@ -544,7 +547,8 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
queryset = Interface.objects.prefetch_related(
- 'device', 'parent', 'lag', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags'
+ 'device', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', 'wireless_lans',
+ 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', 'tags'
)
serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet
@@ -625,7 +629,7 @@ class PowerPanelViewSet(ModelViewSet):
class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet):
queryset = PowerFeed.objects.prefetch_related(
- 'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags'
+ 'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags'
)
serializer_class = serializers.PowerFeedSerializer
filterset_class = filtersets.PowerFeedFilterSet
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index 2f6228751..fcb37211f 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -174,6 +174,25 @@ class DeviceStatusChoices(ChoiceSet):
}
+class DeviceAirflowChoices(ChoiceSet):
+
+ AIRFLOW_FRONT_TO_REAR = 'front-to-rear'
+ AIRFLOW_REAR_TO_FRONT = 'rear-to-front'
+ AIRFLOW_LEFT_TO_RIGHT = 'left-to-right'
+ AIRFLOW_RIGHT_TO_LEFT = 'right-to-left'
+ AIRFLOW_SIDE_TO_REAR = 'side-to-rear'
+ AIRFLOW_PASSIVE = 'passive'
+
+ CHOICES = (
+ (AIRFLOW_FRONT_TO_REAR, 'Front to rear'),
+ (AIRFLOW_REAR_TO_FRONT, 'Rear to front'),
+ (AIRFLOW_LEFT_TO_RIGHT, 'Left to right'),
+ (AIRFLOW_RIGHT_TO_LEFT, 'Right to left'),
+ (AIRFLOW_SIDE_TO_REAR, 'Side to rear'),
+ (AIRFLOW_PASSIVE, 'Passive'),
+ )
+
+
#
# ConsolePorts
#
@@ -185,6 +204,7 @@ class ConsolePortTypeChoices(ChoiceSet):
TYPE_RJ11 = 'rj-11'
TYPE_RJ12 = 'rj-12'
TYPE_RJ45 = 'rj-45'
+ TYPE_MINI_DIN_8 = 'mini-din-8'
TYPE_USB_A = 'usb-a'
TYPE_USB_B = 'usb-b'
TYPE_USB_C = 'usb-c'
@@ -202,6 +222,7 @@ class ConsolePortTypeChoices(ChoiceSet):
(TYPE_RJ11, 'RJ-11'),
(TYPE_RJ12, 'RJ-12'),
(TYPE_RJ45, 'RJ-45'),
+ (TYPE_MINI_DIN_8, 'Mini-DIN 8'),
)),
('USB', (
(TYPE_USB_A, 'USB Type A'),
@@ -310,6 +331,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_NEMA_L1560P = 'nema-l15-60p'
TYPE_NEMA_L2120P = 'nema-l21-20p'
TYPE_NEMA_L2130P = 'nema-l21-30p'
+ TYPE_NEMA_L2230P = 'nema-l22-30p'
# California style
TYPE_CS6361C = 'cs6361c'
TYPE_CS6365C = 'cs6365c'
@@ -415,6 +437,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEMA_L1560P, 'NEMA L15-60P'),
(TYPE_NEMA_L2120P, 'NEMA L21-20P'),
(TYPE_NEMA_L2130P, 'NEMA L21-30P'),
+ (TYPE_NEMA_L2230P, 'NEMA L22-30P'),
)),
('California Style', (
(TYPE_CS6361C, 'CS6361C'),
@@ -426,7 +449,7 @@ class PowerPortTypeChoices(ChoiceSet):
)),
('International/ITA', (
(TYPE_ITA_C, 'ITA Type C (CEE 7/16)'),
- (TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
+ (TYPE_ITA_E, 'ITA Type E (CEE 7/6)'),
(TYPE_ITA_F, 'ITA Type F (CEE 7/4)'),
(TYPE_ITA_EF, 'ITA Type E/F (CEE 7/7)'),
(TYPE_ITA_G, 'ITA Type G (BS 1363)'),
@@ -531,6 +554,7 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_NEMA_L1560R = 'nema-l15-60r'
TYPE_NEMA_L2120R = 'nema-l21-20r'
TYPE_NEMA_L2130R = 'nema-l21-30r'
+ TYPE_NEMA_L2230R = 'nema-l22-30r'
# California style
TYPE_CS6360C = 'CS6360C'
TYPE_CS6364C = 'CS6364C'
@@ -550,6 +574,7 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_ITA_M = 'ita-m'
TYPE_ITA_N = 'ita-n'
TYPE_ITA_O = 'ita-o'
+ TYPE_ITA_MULTISTANDARD = 'ita-multistandard'
# USB
TYPE_USB_A = 'usb-a'
TYPE_USB_MICROB = 'usb-micro-b'
@@ -628,6 +653,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEMA_L1560R, 'NEMA L15-60R'),
(TYPE_NEMA_L2120R, 'NEMA L21-20R'),
(TYPE_NEMA_L2130R, 'NEMA L21-30R'),
+ (TYPE_NEMA_L2230R, 'NEMA L22-30R'),
)),
('California Style', (
(TYPE_CS6360C, 'CS6360C'),
@@ -638,8 +664,8 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_CS8464C, 'CS8464C'),
)),
('ITA/International', (
- (TYPE_ITA_E, 'ITA Type E (CEE7/5)'),
- (TYPE_ITA_F, 'ITA Type F (CEE7/3)'),
+ (TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
+ (TYPE_ITA_F, 'ITA Type F (CEE 7/3)'),
(TYPE_ITA_G, 'ITA Type G (BS 1363)'),
(TYPE_ITA_H, 'ITA Type H'),
(TYPE_ITA_I, 'ITA Type I'),
@@ -649,6 +675,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_ITA_M, 'ITA Type M (BS 546)'),
(TYPE_ITA_N, 'ITA Type N'),
(TYPE_ITA_O, 'ITA Type O'),
+ (TYPE_ITA_MULTISTANDARD, 'ITA Multistandard'),
)),
('USB', (
(TYPE_USB_A, 'USB Type A'),
@@ -701,6 +728,7 @@ class InterfaceTypeChoices(ChoiceSet):
# Virtual
TYPE_VIRTUAL = 'virtual'
+ TYPE_BRIDGE = 'bridge'
TYPE_LAG = 'lag'
# Ethernet
@@ -737,6 +765,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_80211AC = 'ieee802.11ac'
TYPE_80211AD = 'ieee802.11ad'
TYPE_80211AX = 'ieee802.11ax'
+ TYPE_802151 = 'ieee802.15.1'
# Cellular
TYPE_GSM = 'gsm'
@@ -760,7 +789,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_16GFC_SFP_PLUS = '16gfc-sfpp'
TYPE_32GFC_SFP28 = '32gfc-sfp28'
TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp'
- TYPE_128GFC_QSFP28 = '128gfc-sfp28'
+ TYPE_128GFC_QSFP28 = '128gfc-qsfp28'
# InfiniBand
TYPE_INFINIBAND_SDR = 'infiniband-sdr'
@@ -801,6 +830,7 @@ class InterfaceTypeChoices(ChoiceSet):
'Virtual interfaces',
(
(TYPE_VIRTUAL, 'Virtual'),
+ (TYPE_BRIDGE, 'Bridge'),
(TYPE_LAG, 'Link Aggregation Group (LAG)'),
),
),
@@ -848,6 +878,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_80211AC, 'IEEE 802.11ac'),
(TYPE_80211AD, 'IEEE 802.11ad'),
(TYPE_80211AX, 'IEEE 802.11ax'),
+ (TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'),
)
),
(
@@ -1042,7 +1073,7 @@ class PortTypeChoices(ChoiceSet):
#
-# Cables
+# Cables/links
#
class CableTypeChoices(ChoiceSet):
@@ -1106,7 +1137,7 @@ class CableTypeChoices(ChoiceSet):
)
-class CableStatusChoices(ChoiceSet):
+class LinkStatusChoices(ChoiceSet):
STATUS_CONNECTED = 'connected'
STATUS_PLANNED = 'planned'
diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py
index 2a4d368f4..2136f06aa 100644
--- a/netbox/dcim/constants.py
+++ b/netbox/dcim/constants.py
@@ -34,6 +34,7 @@ INTERFACE_MTU_MAX = 65536
VIRTUAL_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_VIRTUAL,
InterfaceTypeChoices.TYPE_LAG,
+ InterfaceTypeChoices.TYPE_BRIDGE,
]
WIRELESS_IFACE_TYPES = [
@@ -42,6 +43,7 @@ WIRELESS_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_80211N,
InterfaceTypeChoices.TYPE_80211AC,
InterfaceTypeChoices.TYPE_80211AD,
+ InterfaceTypeChoices.TYPE_80211AX,
]
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py
index 21af2ed14..d3afe5c08 100644
--- a/netbox/dcim/fields.py
+++ b/netbox/dcim/fields.py
@@ -2,11 +2,30 @@ from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
-from netaddr import AddrFormatError, EUI, mac_unix_expanded
+from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
from .lookups import PathContains
+__all__ = (
+ 'ASNField',
+ 'MACAddressField',
+ 'PathField',
+ 'WWNField',
+)
+
+
+class mac_unix_expanded_uppercase(mac_unix_expanded):
+ word_fmt = '%.2X'
+
+
+class eui64_unix_expanded_uppercase(eui64_unix_expanded):
+ word_fmt = '%.2X'
+
+
+#
+# Fields
+#
class ASNField(models.BigIntegerField):
description = "32-bit ASN field"
@@ -24,10 +43,6 @@ class ASNField(models.BigIntegerField):
return super().formfield(**defaults)
-class mac_unix_expanded_uppercase(mac_unix_expanded):
- word_fmt = '%.2X'
-
-
class MACAddressField(models.Field):
description = "PostgreSQL MAC Address field"
@@ -42,8 +57,8 @@ class MACAddressField(models.Field):
return value
try:
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
- except AddrFormatError as e:
- raise ValidationError("Invalid MAC address format: {}".format(value))
+ except AddrFormatError:
+ raise ValidationError(f"Invalid MAC address format: {value}")
def db_type(self, connection):
return 'macaddr'
@@ -54,6 +69,32 @@ class MACAddressField(models.Field):
return str(self.to_python(value))
+class WWNField(models.Field):
+ description = "World Wide Name field"
+
+ def python_type(self):
+ return EUI
+
+ def from_db_value(self, value, expression, connection):
+ return self.to_python(value)
+
+ def to_python(self, value):
+ if value is None:
+ return value
+ try:
+ return EUI(value, version=64, dialect=eui64_unix_expanded_uppercase)
+ except AddrFormatError:
+ raise ValidationError(f"Invalid WWN format: {value}")
+
+ def db_type(self, connection):
+ return 'macaddr8'
+
+ def get_prep_value(self, value):
+ if not value:
+ return None
+ return str(self.to_python(value))
+
+
class PathField(ArrayField):
"""
An ArrayField which holds a set of objects, each identified by a (type, ID) tuple.
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index df7f415e2..bd2a75fe0 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -3,6 +3,7 @@ from django.contrib.auth.models import User
from extras.filters import TagFilter
from extras.filtersets import LocalConfigContextFilterSet
+from ipam.models import ASN
from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
)
@@ -10,10 +11,11 @@ from tenancy.filtersets import TenancyFilterSet
from tenancy.models import Tenant
from utilities.choices import ColorChoices
from utilities.filters import (
- ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter,
+ ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
TreeNodeMultipleChoiceFilter,
)
from virtualization.models import Cluster
+from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
from .choices import *
from .constants import *
from .models import *
@@ -71,6 +73,7 @@ class RegionFilterSet(OrganizationalModelFilterSet):
to_field_name='slug',
label='Parent region (slug)',
)
+ tag = TagFilter()
class Meta:
model = Region
@@ -88,6 +91,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet):
to_field_name='slug',
label='Parent site group (slug)',
)
+ tag = TagFilter()
class Meta:
model = SiteGroup
@@ -127,6 +131,11 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
to_field_name='slug',
label='Group (slug)',
)
+ asn_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='asns',
+ queryset=ASN.objects.all(),
+ label='AS (ID)',
+ )
tag = TagFilter()
class Meta:
@@ -152,12 +161,13 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
)
try:
qs_filter |= Q(asn=int(value.strip()))
+ qs_filter |= Q(asns__asn=int(value.strip()))
except ValueError:
pass
return queryset.filter(qs_filter)
-class LocationFilterSet(OrganizationalModelFilterSet):
+class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
@@ -207,6 +217,7 @@ class LocationFilterSet(OrganizationalModelFilterSet):
to_field_name='slug',
label='Location (slug)',
)
+ tag = TagFilter()
class Meta:
model = Location
@@ -222,6 +233,7 @@ class LocationFilterSet(OrganizationalModelFilterSet):
class RackRoleFilterSet(OrganizationalModelFilterSet):
+ tag = TagFilter()
class Meta:
model = RackRole
@@ -387,6 +399,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class ManufacturerFilterSet(OrganizationalModelFilterSet):
+ tag = TagFilter()
class Meta:
model = Manufacturer
@@ -441,7 +454,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
class Meta:
model = DeviceType
fields = [
- 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
+ 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
]
def search(self, queryset, name, value):
@@ -569,6 +582,7 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
class DeviceRoleFilterSet(OrganizationalModelFilterSet):
+ tag = TagFilter()
class Meta:
model = DeviceRole
@@ -587,6 +601,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
to_field_name='slug',
label='Manufacturer (slug)',
)
+ tag = TagFilter()
class Meta:
model = Platform
@@ -751,7 +766,7 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex
class Meta:
model = Device
- fields = ['id', 'name', 'asset_tag', 'face', 'position', 'vc_position', 'vc_priority']
+ fields = ['id', 'name', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority']
def search(self, queryset, name, value):
if not value.strip():
@@ -861,6 +876,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name',
label='Device (name)',
)
+ virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__virtual_chassis',
+ queryset=VirtualChassis.objects.all(),
+ label='Virtual Chassis (ID)'
+ )
+ virtual_chassis = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__virtual_chassis__name',
+ queryset=VirtualChassis.objects.all(),
+ to_field_name='name',
+ label='Virtual Chassis',
+ )
tag = TagFilter()
def search(self, queryset, name, value):
@@ -967,12 +993,18 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT
queryset=Interface.objects.all(),
label='Parent interface (ID)',
)
+ bridge_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='bridge',
+ queryset=Interface.objects.all(),
+ label='Bridged interface (ID)',
+ )
lag_id = django_filters.ModelMultipleChoiceFilter(
field_name='lag',
queryset=Interface.objects.all(),
label='LAG interface (ID)',
)
mac_address = MultiValueMACAddressFilter()
+ wwn = MultiValueWWNFilter()
tag = TagFilter()
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
@@ -986,10 +1018,19 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT
choices=InterfaceTypeChoices,
null_value=None
)
+ rf_role = django_filters.MultipleChoiceFilter(
+ choices=WirelessRoleChoices
+ )
+ rf_channel = django_filters.MultipleChoiceFilter(
+ choices=WirelessChannelChoices
+ )
class Meta:
model = Interface
- fields = ['id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description']
+ fields = [
+ 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel',
+ 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
+ ]
def filter_device(self, queryset, name, value):
try:
@@ -1188,7 +1229,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet):
return queryset.filter(qs_filter).distinct()
-class CableFilterSet(PrimaryModelFilterSet):
+class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -1201,7 +1242,7 @@ class CableFilterSet(PrimaryModelFilterSet):
choices=CableTypeChoices
)
status = django_filters.MultipleChoiceFilter(
- choices=CableStatusChoices
+ choices=LinkStatusChoices
)
color = django_filters.MultipleChoiceFilter(
choices=ColorChoices
@@ -1229,14 +1270,6 @@ class CableFilterSet(PrimaryModelFilterSet):
method='filter_device',
field_name='device__site__slug'
)
- tenant_id = MultiValueNumberFilter(
- method='filter_device',
- field_name='device__tenant_id'
- )
- tenant = MultiValueNumberFilter(
- method='filter_device',
- field_name='device__tenant__slug'
- )
tag = TagFilter()
class Meta:
@@ -1394,6 +1427,10 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE
#
class ConnectionFilterSet(BaseFilterSet):
+ q = django_filters.CharFilter(
+ method='search',
+ label='Search',
+ )
site_id = MultiValueNumberFilter(
method='filter_connections',
field_name='device__site_id'
@@ -1416,6 +1453,15 @@ class ConnectionFilterSet(BaseFilterSet):
return queryset
return queryset.filter(**{f'{name}__in': value})
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ qs_filter = (
+ Q(device__name__icontains=value) |
+ Q(cable__label__icontains=value)
+ )
+ return queryset.filter(qs_filter)
+
class ConsoleConnectionFilterSet(ConnectionFilterSet):
diff --git a/netbox/dcim/forms/__init__.py b/netbox/dcim/forms/__init__.py
index 322abff9a..22f0b1204 100644
--- a/netbox/dcim/forms/__init__.py
+++ b/netbox/dcim/forms/__init__.py
@@ -1,4 +1,3 @@
-from .fields import *
from .models import *
from .filtersets import *
from .object_create import *
diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py
index 3464280f1..16e860c38 100644
--- a/netbox/dcim/forms/bulk_create.py
+++ b/netbox/dcim/forms/bulk_create.py
@@ -3,7 +3,7 @@ from django import forms
from dcim.models import *
from extras.forms import CustomFieldsMixin
from extras.models import Tag
-from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, form_from_model
+from utilities.forms import DynamicModelMultipleChoiceField, form_from_model
from .object_create import ComponentForm
__all__ = (
@@ -23,7 +23,7 @@ __all__ = (
# Device components
#
-class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentForm):
+class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentForm):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()
diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py
index c1b1bcb3a..9127b072f 100644
--- a/netbox/dcim/forms/bulk_edit.py
+++ b/netbox/dcim/forms/bulk_edit.py
@@ -1,4 +1,5 @@
from django import forms
+from django.utils.translation import gettext as _
from django.contrib.auth.models import User
from timezone_field import TimeZoneFormField
@@ -6,12 +7,12 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
-from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
-from ipam.models import VLAN
+from ipam.constants import BGP_ASN_MIN, BGP_ASN_MAX
+from ipam.models import VLAN, ASN
from tenancy.models import Tenant
from utilities.forms import (
- add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField,
- DynamicModelChoiceField, DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect,
+ add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
+ DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect,
)
__all__ = (
@@ -51,7 +52,7 @@ __all__ = (
)
-class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class RegionBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Region.objects.all(),
widget=forms.MultipleHiddenInput
@@ -69,7 +70,7 @@ class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['parent', 'description']
-class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class SiteGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
widget=forms.MultipleHiddenInput
@@ -87,7 +88,7 @@ class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['parent', 'description']
-class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Site.objects.all(),
widget=forms.MultipleHiddenInput
@@ -116,6 +117,11 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
required=False,
label='ASN'
)
+ asns = DynamicModelMultipleChoiceField(
+ queryset=ASN.objects.all(),
+ label=_('ASNs'),
+ required=False
+ )
description = forms.CharField(
max_length=100,
required=False
@@ -128,11 +134,11 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
class Meta:
nullable_fields = [
- 'region', 'group', 'tenant', 'asn', 'description', 'time_zone',
+ 'region', 'group', 'tenant', 'asn', 'asns', 'description', 'time_zone',
]
-class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class LocationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Location.objects.all(),
widget=forms.MultipleHiddenInput
@@ -148,16 +154,20 @@ class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
'site_id': '$site'
}
)
+ tenant = DynamicModelChoiceField(
+ queryset=Tenant.objects.all(),
+ required=False
+ )
description = forms.CharField(
max_length=200,
required=False
)
class Meta:
- nullable_fields = ['parent', 'description']
+ nullable_fields = ['parent', 'tenant', 'description']
-class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class RackRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=RackRole.objects.all(),
widget=forms.MultipleHiddenInput
@@ -174,7 +184,7 @@ class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['color', 'description']
-class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class RackBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Rack.objects.all(),
widget=forms.MultipleHiddenInput
@@ -274,7 +284,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
]
-class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class RackReservationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=RackReservation.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -299,7 +309,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
nullable_fields = []
-class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class ManufacturerBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
widget=forms.MultipleHiddenInput
@@ -313,7 +323,7 @@ class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['description']
-class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -331,12 +341,17 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel
widget=BulkEditNullBooleanSelect(),
label='Is full depth'
)
+ airflow = forms.ChoiceField(
+ choices=add_blank_choice(DeviceAirflowChoices),
+ required=False,
+ widget=StaticSelect()
+ )
class Meta:
- nullable_fields = []
+ nullable_fields = ['airflow']
-class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
widget=forms.MultipleHiddenInput
@@ -358,7 +373,7 @@ class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['color', 'description']
-class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class PlatformBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Platform.objects.all(),
widget=forms.MultipleHiddenInput
@@ -381,7 +396,7 @@ class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['manufacturer', 'napalm_driver', 'description']
-class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -425,6 +440,11 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
required=False,
widget=StaticSelect()
)
+ airflow = forms.ChoiceField(
+ choices=add_blank_choice(DeviceAirflowChoices),
+ required=False,
+ widget=StaticSelect()
+ )
serial = forms.CharField(
max_length=50,
required=False,
@@ -433,11 +453,11 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
class Meta:
nullable_fields = [
- 'tenant', 'platform', 'serial',
+ 'tenant', 'platform', 'serial', 'airflow',
]
-class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Cable.objects.all(),
widget=forms.MultipleHiddenInput
@@ -449,11 +469,15 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
widget=StaticSelect()
)
status = forms.ChoiceField(
- choices=add_blank_choice(CableStatusChoices),
+ choices=add_blank_choice(LinkStatusChoices),
required=False,
widget=StaticSelect(),
initial=''
)
+ tenant = DynamicModelChoiceField(
+ queryset=Tenant.objects.all(),
+ required=False
+ )
label = forms.CharField(
max_length=100,
required=False
@@ -474,7 +498,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
class Meta:
nullable_fields = [
- 'type', 'status', 'label', 'color', 'length',
+ 'type', 'status', 'tenant', 'label', 'color', 'length',
]
def clean(self):
@@ -489,7 +513,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
})
-class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class VirtualChassisBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=VirtualChassis.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -503,7 +527,7 @@ class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldM
nullable_fields = ['domain']
-class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class PowerPanelBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerPanel.objects.all(),
widget=forms.MultipleHiddenInput
@@ -542,7 +566,7 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel
nullable_fields = ['location']
-class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class PowerFeedBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerFeed.objects.all(),
widget=forms.MultipleHiddenInput
@@ -607,7 +631,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
# Device component templates
#
-class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+class ConsolePortTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ConsolePortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -626,7 +650,7 @@ class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ('label', 'type', 'description')
-class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+class ConsoleServerPortTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ConsoleServerPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -648,7 +672,7 @@ class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ('label', 'type', 'description')
-class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+class PowerPortTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -680,7 +704,7 @@ class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description')
-class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+class PowerOutletTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerOutletTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -728,7 +752,7 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
self.fields['power_port'].widget.attrs['disabled'] = True
-class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+class InterfaceTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=InterfaceTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -755,7 +779,7 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ('label', 'description')
-class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+class FrontPortTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=FrontPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -780,7 +804,7 @@ class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ('description',)
-class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+class RearPortTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=RearPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -805,7 +829,7 @@ class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ('description',)
-class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+class DeviceBayTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=DeviceBayTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -828,7 +852,6 @@ class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
class ConsolePortBulkEditForm(
form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']),
- BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -847,7 +870,6 @@ class ConsolePortBulkEditForm(
class ConsoleServerPortBulkEditForm(
form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']),
- BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -866,7 +888,6 @@ class ConsoleServerPortBulkEditForm(
class PowerPortBulkEditForm(
form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']),
- BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -885,7 +906,6 @@ class PowerPortBulkEditForm(
class PowerOutletBulkEditForm(
form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']),
- BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -921,9 +941,9 @@ class PowerOutletBulkEditForm(
class InterfaceBulkEditForm(
form_from_model(Interface, [
- 'label', 'type', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode',
+ 'label', 'type', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
+ 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
]),
- BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -945,6 +965,10 @@ class InterfaceBulkEditForm(
queryset=Interface.objects.all(),
required=False
)
+ bridge = DynamicModelChoiceField(
+ queryset=Interface.objects.all(),
+ required=False
+ )
lag = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
@@ -972,7 +996,8 @@ class InterfaceBulkEditForm(
class Meta:
nullable_fields = [
- 'label', 'parent', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans'
+ 'label', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel',
+ 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans',
]
def __init__(self, *args, **kwargs):
@@ -980,8 +1005,9 @@ class InterfaceBulkEditForm(
if 'device' in self.initial:
device = Device.objects.filter(pk=self.initial['device']).first()
- # Restrict parent/LAG interface assignment by device
+ # Restrict parent/bridge/LAG interface assignment by device
self.fields['parent'].widget.add_query_param('device_id', device.pk)
+ self.fields['bridge'].widget.add_query_param('device_id', device.pk)
self.fields['lag'].widget.add_query_param('device_id', device.pk)
# Limit VLAN choices by device
@@ -1009,6 +1035,8 @@ class InterfaceBulkEditForm(
self.fields['parent'].choices = ()
self.fields['parent'].widget.attrs['disabled'] = True
+ self.fields['bridge'].choices = ()
+ self.fields['bridge'].widget.attrs['disabled'] = True
self.fields['lag'].choices = ()
self.fields['lag'].widget.attrs['disabled'] = True
@@ -1028,7 +1056,6 @@ class InterfaceBulkEditForm(
class FrontPortBulkEditForm(
form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']),
- BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -1043,7 +1070,6 @@ class FrontPortBulkEditForm(
class RearPortBulkEditForm(
form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']),
- BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -1058,7 +1084,6 @@ class RearPortBulkEditForm(
class DeviceBayBulkEditForm(
form_from_model(DeviceBay, ['label', 'description']),
- BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -1073,7 +1098,6 @@ class DeviceBayBulkEditForm(
class InventoryItemBulkEditForm(
form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']),
- BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py
index 072cdf0e0..df8c4ec01 100644
--- a/netbox/dcim/forms/bulk_import.py
+++ b/netbox/dcim/forms/bulk_import.py
@@ -11,6 +11,7 @@ from extras.forms import CustomFieldModelCSVForm
from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
from virtualization.models import Cluster
+from wireless.choices import WirelessRoleChoices
__all__ = (
'CableCSVForm',
@@ -94,7 +95,7 @@ class SiteCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Site
fields = (
- 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
+ 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'comments',
)
@@ -120,10 +121,16 @@ class LocationCSVForm(CustomFieldModelCSVForm):
'invalid_choice': 'Location not found.',
}
)
+ tenant = CSVModelChoiceField(
+ queryset=Tenant.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text='Assigned tenant'
+ )
class Meta:
model = Location
- fields = ('site', 'parent', 'name', 'slug', 'description')
+ fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description')
class RackRoleCSVForm(CustomFieldModelCSVForm):
@@ -363,12 +370,17 @@ class DeviceCSVForm(BaseDeviceCSVForm):
required=False,
help_text='Mounted rack face'
)
+ airflow = CSVChoiceField(
+ choices=DeviceAirflowChoices,
+ required=False,
+ help_text='Airflow direction'
+ )
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
- 'site', 'location', 'rack', 'position', 'face', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster',
- 'comments',
+ 'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority',
+ 'cluster', 'comments',
]
def __init__(self, data=None, *args, **kwargs):
@@ -558,6 +570,12 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
to_field_name='name',
help_text='Parent interface'
)
+ bridge = CSVModelChoiceField(
+ queryset=Interface.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text='Bridged interface'
+ )
lag = CSVModelChoiceField(
queryset=Interface.objects.all(),
required=False,
@@ -573,42 +591,20 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
required=False,
help_text='IEEE 802.1Q operational mode (for L2 interfaces)'
)
+ rf_role = CSVChoiceField(
+ choices=WirelessRoleChoices,
+ required=False,
+ help_text='Wireless role (AP/station)'
+ )
class Meta:
model = Interface
fields = (
- 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu',
- 'mgmt_only', 'description', 'mode',
+ 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address',
+ 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+ 'rf_channel_width', 'tx_power',
)
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Limit LAG choices to interfaces belonging to this device (or virtual chassis)
- device = None
- if self.is_bound and 'device' in self.data:
- try:
- device = self.fields['device'].to_python(self.data['device'])
- except forms.ValidationError:
- pass
- if device and device.virtual_chassis:
- self.fields['lag'].queryset = Interface.objects.filter(
- Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis),
- type=InterfaceTypeChoices.TYPE_LAG
- )
- self.fields['parent'].queryset = Interface.objects.filter(
- Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis)
- )
- elif device:
- self.fields['lag'].queryset = Interface.objects.filter(
- device=device,
- type=InterfaceTypeChoices.TYPE_LAG
- )
- self.fields['parent'].queryset = Interface.objects.filter(device=device)
- else:
- self.fields['lag'].queryset = Interface.objects.none()
- self.fields['parent'].queryset = Interface.objects.none()
-
def clean_enabled(self):
# Make sure enabled is True when it's not included in the uploaded data
if 'enabled' not in self.data:
@@ -801,7 +797,7 @@ class CableCSVForm(CustomFieldModelCSVForm):
# Cable attributes
status = CSVChoiceField(
- choices=CableStatusChoices,
+ choices=LinkStatusChoices,
required=False,
help_text='Connection status'
)
@@ -810,6 +806,12 @@ class CableCSVForm(CustomFieldModelCSVForm):
required=False,
help_text='Physical medium classification'
)
+ tenant = CSVModelChoiceField(
+ queryset=Tenant.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text='Assigned tenant'
+ )
length_unit = CSVChoiceField(
choices=CableLengthUnitChoices,
required=False,
@@ -820,7 +822,7 @@ class CableCSVForm(CustomFieldModelCSVForm):
model = Cable
fields = [
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
- 'status', 'label', 'color', 'length', 'length_unit',
+ 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
]
help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'),
diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py
index a2ceea6cf..771ff38bc 100644
--- a/netbox/dcim/forms/connections.py
+++ b/netbox/dcim/forms/connections.py
@@ -2,7 +2,8 @@ from circuits.models import Circuit, CircuitTermination, Provider
from dcim.models import *
from extras.forms import CustomFieldModelForm
from extras.models import Tag
-from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
+from tenancy.forms import TenancyForm
+from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
__all__ = (
'ConnectCableToCircuitTerminationForm',
@@ -17,7 +18,7 @@ __all__ = (
)
-class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
+class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
"""
Base form for connecting a Cable to a Device component
"""
@@ -78,7 +79,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
model = Cable
fields = [
'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device',
- 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
+ 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
+ 'tags',
]
widgets = {
'status': StaticSelect,
@@ -169,7 +171,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
)
-class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm):
+class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
termination_b_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
label='Provider',
@@ -215,11 +217,11 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
required=False
)
- class Meta:
- model = Cable
+ class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
- 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
+ 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
+ 'tags',
]
def clean_termination_b_id(self):
@@ -227,7 +229,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
-class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
+class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
@@ -277,11 +279,10 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
required=False
)
- class Meta:
- model = Cable
+ class Meta(ConnectCableToDeviceForm.Meta):
fields = [
- 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label',
- 'color', 'length', 'length_unit', 'tags',
+ 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group',
+ 'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
]
def clean_termination_b_id(self):
diff --git a/netbox/dcim/forms/fields.py b/netbox/dcim/forms/fields.py
deleted file mode 100644
index 25a20667b..000000000
--- a/netbox/dcim/forms/fields.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from django import forms
-from netaddr import EUI
-from netaddr.core import AddrFormatError
-
-__all__ = (
- 'MACAddressField',
-)
-
-
-class MACAddressField(forms.Field):
- widget = forms.CharField
- default_error_messages = {
- 'invalid': 'MAC address must be in EUI-48 format',
- }
-
- def to_python(self, value):
- value = super().to_python(value)
-
- # Validate MAC address format
- try:
- value = EUI(value.strip())
- except AddrFormatError:
- raise forms.ValidationError(self.error_messages['invalid'], code='invalid')
-
- return value
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 4ef53c469..21e8c9c97 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -6,12 +6,13 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
+from ipam.models import ASN
from tenancy.forms import TenancyFilterForm
-from tenancy.models import Tenant
from utilities.forms import (
- APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect,
+ APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
)
+from wireless.choices import *
__all__ = (
'CableFilterForm',
@@ -46,15 +47,10 @@ __all__ = (
)
-class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class DeviceComponentFilterForm(CustomFieldModelFilterForm):
field_order = [
'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id',
]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
name = forms.CharField(
required=False
)
@@ -92,69 +88,56 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Location'),
fetch_trigger='open'
)
+ virtual_chassis_id = DynamicModelMultipleChoiceField(
+ queryset=VirtualChassis.objects.all(),
+ required=False,
+ label=_('Virtual Chassis'),
+ fetch_trigger='open'
+ )
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
+ 'virtual_chassis_id': '$virtual_chassis_id'
},
label=_('Device'),
fetch_trigger='open'
)
-class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class RegionFilterForm(CustomFieldModelFilterForm):
model = Region
- field_groups = [
- ['q'],
- ['parent_id'],
- ]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
parent_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Parent region'),
fetch_trigger='open'
)
+ tag = TagFilterField(model)
-class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class SiteGroupFilterForm(CustomFieldModelFilterForm):
model = SiteGroup
- field_groups = [
- ['q'],
- ['parent_id'],
- ]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
parent_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Parent group'),
fetch_trigger='open'
)
+ tag = TagFilterField(model)
-class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Site
- field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id']
+ field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id', 'asn_id']
field_groups = [
['q', 'tag'],
['status', 'region_id', 'group_id'],
['tenant_group_id', 'tenant_id'],
+ ['asn_id']
]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
status = forms.MultipleChoiceField(
choices=SiteStatusChoices,
required=False,
@@ -172,16 +155,22 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
label=_('Site group'),
fetch_trigger='open'
)
+ asn_id = DynamicModelMultipleChoiceField(
+ queryset=ASN.objects.all(),
+ required=False,
+ label=_('ASNs'),
+ fetch_trigger='open'
+ )
tag = TagFilterField(model)
-class LocationFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Location
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
+ field_groups = [
+ ['q'],
+ ['region_id', 'site_group_id', 'site_id', 'parent_id'],
+ ['tenant_group_id', 'tenant_id'],
+ ]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -214,21 +203,15 @@ class LocationFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Parent'),
fetch_trigger='open'
)
+ tag = TagFilterField(model)
-class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class RackRoleFilterForm(CustomFieldModelFilterForm):
model = RackRole
- field_groups = [
- ['q'],
- ]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
+ tag = TagFilterField(model)
-class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Rack
field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id']
field_groups = [
@@ -238,11 +221,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
['type', 'width', 'serial', 'asset_tag'],
['tenant_group_id', 'tenant_id'],
]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -316,7 +294,7 @@ class RackElevationFilterForm(RackFilterForm):
)
-class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = RackReservation
field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id']
field_groups = [
@@ -325,11 +303,6 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMo
['region_id', 'site_id', 'location_id'],
['tenant_group_id', 'tenant_id'],
]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -364,30 +337,18 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMo
tag = TagFilterField(model)
-class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class ManufacturerFilterForm(CustomFieldModelFilterForm):
model = Manufacturer
- field_groups = [
- ['q'],
- ]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
+ tag = TagFilterField(model)
-class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class DeviceTypeFilterForm(CustomFieldModelFilterForm):
model = DeviceType
field_groups = [
['q', 'tag'],
- ['manufacturer_id', 'subdevice_role'],
+ ['manufacturer_id', 'subdevice_role', 'airflow'],
['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'],
]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -399,6 +360,11 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
required=False,
widget=StaticSelectMultiple()
)
+ airflow = forms.MultipleChoiceField(
+ choices=add_blank_choice(DeviceAirflowChoices),
+ required=False,
+ widget=StaticSelectMultiple()
+ )
console_ports = forms.NullBooleanField(
required=False,
label='Has console ports',
@@ -444,34 +410,23 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
-class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class DeviceRoleFilterForm(CustomFieldModelFilterForm):
model = DeviceRole
- field_groups = [
- ['q'],
- ]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
+ tag = TagFilterField(model)
-class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class PlatformFilterForm(CustomFieldModelFilterForm):
model = Platform
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label=_('Manufacturer'),
fetch_trigger='open'
)
+ tag = TagFilterField(model)
-class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
model = Device
field_order = [
'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id',
@@ -480,7 +435,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
field_groups = [
['q', 'tag'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'],
- ['status', 'role_id', 'serial', 'asset_tag', 'mac_address'],
+ ['status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address'],
['manufacturer_id', 'device_type_id', 'platform_id'],
['tenant_group_id', 'tenant_id'],
[
@@ -488,11 +443,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data',
],
]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -569,6 +519,11 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
required=False,
widget=StaticSelectMultiple()
)
+ airflow = forms.MultipleChoiceField(
+ choices=add_blank_choice(DeviceAirflowChoices),
+ required=False,
+ widget=StaticSelectMultiple()
+ )
serial = forms.CharField(
required=False
)
@@ -638,7 +593,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
tag = TagFilterField(model)
-class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = VirtualChassis
field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id']
field_groups = [
@@ -646,11 +601,6 @@ class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod
['region_id', 'site_group_id', 'site_id'],
['tenant_group_id', 'tenant_id'],
]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -676,19 +626,14 @@ class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod
tag = TagFilterField(model)
-class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Cable
field_groups = [
['q', 'tag'],
['site_id', 'rack_id', 'device_id'],
['type', 'status', 'color'],
- ['tenant_id'],
+ ['tenant_group_id', 'tenant_id'],
]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -704,12 +649,6 @@ class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Site'),
fetch_trigger='open'
)
- tenant_id = DynamicModelMultipleChoiceField(
- queryset=Tenant.objects.all(),
- required=False,
- label=_('Tenant'),
- fetch_trigger='open'
- )
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
@@ -727,7 +666,7 @@ class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
)
status = forms.ChoiceField(
required=False,
- choices=add_blank_choice(CableStatusChoices),
+ choices=add_blank_choice(LinkStatusChoices),
widget=StaticSelect()
)
color = ColorField(
@@ -747,17 +686,12 @@ class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
-class PowerPanelFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class PowerPanelFilterForm(CustomFieldModelFilterForm):
model = PowerPanel
field_groups = (
('q', 'tag'),
('region_id', 'site_group_id', 'site_id', 'location_id')
)
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -793,7 +727,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
-class PowerFeedFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class PowerFeedFilterForm(CustomFieldModelFilterForm):
model = PowerFeed
field_groups = [
['q', 'tag'],
@@ -801,11 +735,6 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
['power_panel_id', 'rack_id'],
['status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'],
]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -888,7 +817,7 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'speed'],
- ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+ ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=ConsolePortTypeChoices,
@@ -908,7 +837,7 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'speed'],
- ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+ ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=ConsolePortTypeChoices,
@@ -928,7 +857,7 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type'],
- ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+ ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=PowerPortTypeChoices,
@@ -943,7 +872,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type'],
- ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+ ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=PowerOutletTypeChoices,
@@ -957,8 +886,9 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
model = Interface
field_groups = [
['q', 'tag'],
- ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address'],
- ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+ ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'],
+ ['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'],
+ ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
kind = forms.MultipleChoiceField(
choices=InterfaceKindChoices,
@@ -986,6 +916,36 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
required=False,
label='MAC address'
)
+ wwn = forms.CharField(
+ required=False,
+ label='WWN'
+ )
+ rf_role = forms.MultipleChoiceField(
+ choices=WirelessRoleChoices,
+ required=False,
+ widget=StaticSelectMultiple(),
+ label='Wireless role'
+ )
+ rf_channel = forms.MultipleChoiceField(
+ choices=WirelessChannelChoices,
+ required=False,
+ widget=StaticSelectMultiple(),
+ label='Wireless channel'
+ )
+ rf_channel_frequency = forms.IntegerField(
+ required=False,
+ label='Channel frequency (MHz)'
+ )
+ rf_channel_width = forms.IntegerField(
+ required=False,
+ label='Channel width (MHz)'
+ )
+ tx_power = forms.IntegerField(
+ required=False,
+ label='Transmit power (dBm)',
+ min_value=0,
+ max_value=127
+ )
tag = TagFilterField(model)
@@ -993,7 +953,7 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'color'],
- ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+ ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
model = FrontPort
type = forms.MultipleChoiceField(
@@ -1012,7 +972,7 @@ class RearPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'color'],
- ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+ ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=PortTypeChoices,
@@ -1030,7 +990,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label'],
- ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+ ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
tag = TagFilterField(model)
@@ -1040,7 +1000,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
- ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+ ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
@@ -1067,7 +1027,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
# Connections
#
-class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
+class ConsoleConnectionFilterForm(FilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -1094,7 +1054,7 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
)
-class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
+class PowerConnectionFilterForm(FilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -1121,7 +1081,7 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
)
-class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
+class InterfaceConnectionFilterForm(FilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py
index 009e1fe3f..db2f58a63 100644
--- a/netbox/dcim/forms/models.py
+++ b/netbox/dcim/forms/models.py
@@ -1,4 +1,5 @@
from django import forms
+from django.utils.translation import gettext as _
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from timezone_field import TimeZoneFormField
@@ -8,7 +9,7 @@ from dcim.constants import *
from dcim.models import *
from extras.forms import CustomFieldModelForm
from extras.models import Tag
-from ipam.models import IPAddress, VLAN, VLANGroup
+from ipam.models import IPAddress, VLAN, VLANGroup, ASN
from tenancy.forms import TenancyForm
from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, DynamicModelChoiceField,
@@ -16,6 +17,7 @@ from utilities.forms import (
SlugField, StaticSelect,
)
from virtualization.models import Cluster, ClusterGroup
+from wireless.models import WirelessLAN, WirelessLANGroup
from .common import InterfaceCommonForm
__all__ = (
@@ -64,35 +66,43 @@ Tagged (All): Implies all VLANs are available (w/optional untagged VLAN)
"""
-class RegionForm(BootstrapMixin, CustomFieldModelForm):
+class RegionForm(CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False
)
slug = SlugField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = Region
fields = (
- 'parent', 'name', 'slug', 'description',
+ 'parent', 'name', 'slug', 'description', 'tags',
)
-class SiteGroupForm(BootstrapMixin, CustomFieldModelForm):
+class SiteGroupForm(CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False
)
slug = SlugField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = SiteGroup
fields = (
- 'parent', 'name', 'slug', 'description',
+ 'parent', 'name', 'slug', 'description', 'tags',
)
-class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+class SiteForm(TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False
@@ -101,6 +111,11 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=SiteGroup.objects.all(),
required=False
)
+ asns = DynamicModelMultipleChoiceField(
+ queryset=ASN.objects.all(),
+ label=_('ASNs'),
+ required=False
+ )
slug = SlugField()
time_zone = TimeZoneFormField(
choices=add_blank_choice(TimeZoneFormField().choices),
@@ -116,13 +131,14 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class Meta:
model = Site
fields = [
- 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone',
- 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
+ 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'asns',
+ 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
'contact_phone', 'contact_email', 'comments', 'tags',
]
fieldsets = (
('Site', (
- 'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'time_zone', 'description', 'tags',
+ 'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'asns', 'time_zone', 'description',
+ 'tags',
)),
('Tenancy', ('tenant_group', 'tenant')),
('Contact Info', (
@@ -146,8 +162,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
help_texts = {
'name': "Full name of the site",
+ 'asn': "BGP autonomous system number. This field is depreciated in favour of the ASN model",
'facility': "Data center provider and facility (e.g. Equinix NY7)",
- 'asn': "BGP autonomous system number",
'time_zone': "Local time zone",
'description': "Short description (will appear in sites list)",
'physical_address': "Physical location of the building (e.g. for GPS)",
@@ -157,7 +173,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
-class LocationForm(BootstrapMixin, CustomFieldModelForm):
+class LocationForm(TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -187,25 +203,39 @@ class LocationForm(BootstrapMixin, CustomFieldModelForm):
}
)
slug = SlugField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = Location
fields = (
- 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description',
+ 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
+ )
+ fieldsets = (
+ ('Location', (
+ 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
+ )),
+ ('Tenancy', ('tenant_group', 'tenant')),
)
-class RackRoleForm(BootstrapMixin, CustomFieldModelForm):
+class RackRoleForm(CustomFieldModelForm):
slug = SlugField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = RackRole
fields = [
- 'name', 'slug', 'color', 'description',
+ 'name', 'slug', 'color', 'description', 'tags',
]
-class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+class RackForm(TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -265,7 +295,7 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
-class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+class RackReservationForm(TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -335,17 +365,21 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
)
-class ManufacturerForm(BootstrapMixin, CustomFieldModelForm):
+class ManufacturerForm(CustomFieldModelForm):
slug = SlugField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = Manufacturer
fields = [
- 'name', 'slug', 'description',
+ 'name', 'slug', 'description', 'tags',
]
-class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
+class DeviceTypeForm(CustomFieldModelForm):
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all()
)
@@ -361,12 +395,15 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
class Meta:
model = DeviceType
fields = [
- 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
+ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
'front_image', 'rear_image', 'comments', 'tags',
]
fieldsets = (
('Device Type', (
- 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'tags',
+ 'manufacturer', 'model', 'slug', 'part_number', 'tags',
+ )),
+ ('Chassis', (
+ 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
)),
('Images', ('front_image', 'rear_image')),
)
@@ -381,17 +418,21 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
}
-class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm):
+class DeviceRoleForm(CustomFieldModelForm):
slug = SlugField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = DeviceRole
fields = [
- 'name', 'slug', 'color', 'vm_role', 'description',
+ 'name', 'slug', 'color', 'vm_role', 'description', 'tags',
]
-class PlatformForm(BootstrapMixin, CustomFieldModelForm):
+class PlatformForm(CustomFieldModelForm):
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False
@@ -399,18 +440,22 @@ class PlatformForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField(
max_length=64
)
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = Platform
fields = [
- 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description',
+ 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
]
widgets = {
'napalm_args': SmallTextarea(),
}
-class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+class DeviceForm(TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -513,8 +558,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
model = Device
fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
- 'location', 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group',
- 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data'
+ 'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6',
+ 'cluster_group', 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data'
]
help_texts = {
'device_role': "The function this device serves",
@@ -525,6 +570,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
widgets = {
'face': StaticSelect(),
'status': StaticSelect(),
+ 'airflow': StaticSelect(),
'primary_ip4': StaticSelect(),
'primary_ip6': StaticSelect(),
}
@@ -591,7 +637,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
self.fields['position'].widget.choices = [(position, f'U{position}')]
-class CableForm(BootstrapMixin, CustomFieldModelForm):
+class CableForm(TenancyForm, CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -600,7 +646,7 @@ class CableForm(BootstrapMixin, CustomFieldModelForm):
class Meta:
model = Cable
fields = [
- 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
+ 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
]
widgets = {
'status': StaticSelect,
@@ -614,7 +660,7 @@ class CableForm(BootstrapMixin, CustomFieldModelForm):
}
-class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
+class PowerPanelForm(CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -658,7 +704,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
)
-class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
+class PowerFeedForm(CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -726,7 +772,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
# Virtual chassis
#
-class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm):
+class VirtualChassisForm(CustomFieldModelForm):
master = forms.ModelChoiceField(
queryset=Device.objects.all(),
required=False,
@@ -752,7 +798,6 @@ class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm):
class DeviceVCMembershipForm(forms.ModelForm):
-
class Meta:
model = Device
fields = [
@@ -848,7 +893,6 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
-
class Meta:
model = ConsolePortTemplate
fields = [
@@ -860,7 +904,6 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
-
class Meta:
model = ConsoleServerPortTemplate
fields = [
@@ -872,7 +915,6 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
-
class Meta:
model = PowerPortTemplate
fields = [
@@ -884,7 +926,6 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
-
class Meta:
model = PowerOutletTemplate
fields = [
@@ -895,7 +936,6 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
}
def __init__(self, *args, **kwargs):
-
super().__init__(*args, **kwargs)
# Limit power_port choices to current DeviceType
@@ -906,7 +946,6 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
-
class Meta:
model = InterfaceTemplate
fields = [
@@ -919,7 +958,6 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
-
class Meta:
model = FrontPortTemplate
fields = [
@@ -931,7 +969,6 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
}
def __init__(self, *args, **kwargs):
-
super().__init__(*args, **kwargs)
# Limit rear_port choices to current DeviceType
@@ -942,7 +979,6 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
-
class Meta:
model = RearPortTemplate
fields = [
@@ -955,7 +991,6 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
-
class Meta:
model = DeviceBayTemplate
fields = [
@@ -970,7 +1005,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
# Device components
#
-class ConsolePortForm(BootstrapMixin, CustomFieldModelForm):
+class ConsolePortForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -986,7 +1021,7 @@ class ConsolePortForm(BootstrapMixin, CustomFieldModelForm):
}
-class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm):
+class ConsoleServerPortForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -1002,7 +1037,7 @@ class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm):
}
-class PowerPortForm(BootstrapMixin, CustomFieldModelForm):
+class PowerPortForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -1019,7 +1054,7 @@ class PowerPortForm(BootstrapMixin, CustomFieldModelForm):
}
-class PowerOutletForm(BootstrapMixin, CustomFieldModelForm):
+class PowerOutletForm(CustomFieldModelForm):
power_port = forms.ModelChoiceField(
queryset=PowerPort.objects.all(),
required=False
@@ -1048,12 +1083,17 @@ class PowerOutletForm(BootstrapMixin, CustomFieldModelForm):
)
-class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
+class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
label='Parent interface'
)
+ bridge = DynamicModelChoiceField(
+ queryset=Interface.objects.all(),
+ required=False,
+ label='Bridged interface'
+ )
lag = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
@@ -1062,6 +1102,19 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
'type': 'lag',
}
)
+ wireless_lan_group = DynamicModelChoiceField(
+ queryset=WirelessLANGroup.objects.all(),
+ required=False,
+ label='Wireless LAN group'
+ )
+ wireless_lans = DynamicModelMultipleChoiceField(
+ queryset=WirelessLAN.objects.all(),
+ required=False,
+ label='Wireless LANs',
+ query_params={
+ 'group_id': '$wireless_lan_group',
+ }
+ )
vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
@@ -1091,19 +1144,24 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
class Meta:
model = Interface
fields = [
- 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only',
- 'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
+ 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu',
+ 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+ 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
'mode': StaticSelect(),
+ 'rf_role': StaticSelect(),
+ 'rf_channel': StaticSelect(),
}
labels = {
'mode': '802.1Q Mode',
}
help_texts = {
'mode': INTERFACE_MODE_HELP_TEXT,
+ 'rf_channel_frequency': "Populated by selected channel (if set)",
+ 'rf_channel_width': "Populated by selected channel (if set)",
}
def __init__(self, *args, **kwargs):
@@ -1111,20 +1169,21 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device
- # Restrict parent/LAG interface assignment by device/VC
+ # Restrict parent/bridge/LAG interface assignment by device/VC
self.fields['parent'].widget.add_query_param('device_id', device.pk)
+ self.fields['bridge'].widget.add_query_param('device_id', device.pk)
+ self.fields['lag'].widget.add_query_param('device_id', device.pk)
if device.virtual_chassis and device.virtual_chassis.master:
- # Get available LAG interfaces by VirtualChassis master
+ self.fields['parent'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
+ self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
- else:
- self.fields['lag'].widget.add_query_param('device_id', device.pk)
# Limit VLAN choices by device
self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk)
-class FrontPortForm(BootstrapMixin, CustomFieldModelForm):
+class FrontPortForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -1152,7 +1211,7 @@ class FrontPortForm(BootstrapMixin, CustomFieldModelForm):
)
-class RearPortForm(BootstrapMixin, CustomFieldModelForm):
+class RearPortForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -1169,7 +1228,7 @@ class RearPortForm(BootstrapMixin, CustomFieldModelForm):
}
-class DeviceBayForm(BootstrapMixin, CustomFieldModelForm):
+class DeviceBayForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -1194,7 +1253,6 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
)
def __init__(self, device_bay, *args, **kwargs):
-
super().__init__(*args, **kwargs)
self.fields['installed_device'].queryset = Device.objects.filter(
@@ -1206,7 +1264,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
).exclude(pk=device_bay.device.pk)
-class InventoryItemForm(BootstrapMixin, CustomFieldModelForm):
+class InventoryItemForm(CustomFieldModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all()
)
diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py
index 7577ad355..cdda4c0f5 100644
--- a/netbox/dcim/forms/object_create.py
+++ b/netbox/dcim/forms/object_create.py
@@ -10,6 +10,7 @@ from utilities.forms import (
add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
ExpandableNameField, StaticSelect,
)
+from wireless.choices import *
from .common import InterfaceCommonForm
__all__ = (
@@ -34,7 +35,7 @@ __all__ = (
)
-class ComponentForm(forms.Form):
+class ComponentForm(BootstrapMixin, forms.Form):
"""
Subclass this form when facilitating the creation of one or more device component or component templates based on
a name pattern.
@@ -62,7 +63,7 @@ class ComponentForm(forms.Form):
}, code='label_pattern_mismatch')
-class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
+class VirtualChassisCreateForm(CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -117,12 +118,18 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
]
+ def clean(self):
+ if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
+ raise forms.ValidationError({
+ 'initial_position': "A position must be specified for the first VC member."
+ })
+
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
# Assign VC members
- if instance.pk:
- initial_position = self.cleaned_data.get('initial_position') or 1
+ if instance.pk and self.cleaned_data['members']:
+ initial_position = self.cleaned_data.get('initial_position', 1)
for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
member.virtual_chassis = instance
member.vc_position = i
@@ -135,7 +142,7 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
# Component templates
#
-class ComponentTemplateCreateForm(BootstrapMixin, ComponentForm):
+class ComponentTemplateCreateForm(ComponentForm):
"""
Base form for the creation of device component templates (subclassed from ComponentTemplateModel).
"""
@@ -328,7 +335,7 @@ class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm):
# Device components
#
-class ComponentCreateForm(BootstrapMixin, CustomFieldsMixin, ComponentForm):
+class ComponentCreateForm(CustomFieldsMixin, ComponentForm):
"""
Base form for the creation of device components (models subclassed from ComponentModel).
"""
@@ -445,6 +452,13 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
'device_id': '$device',
}
)
+ bridge = DynamicModelChoiceField(
+ queryset=Interface.objects.all(),
+ required=False,
+ query_params={
+ 'device_id': '$device',
+ }
+ )
lag = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
@@ -465,7 +479,27 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
mode = forms.ChoiceField(
choices=add_blank_choice(InterfaceModeChoices),
required=False,
+ widget=StaticSelect()
+ )
+ rf_role = forms.ChoiceField(
+ choices=add_blank_choice(WirelessRoleChoices),
+ required=False,
widget=StaticSelect(),
+ label='Wireless role'
+ )
+ rf_channel = forms.ChoiceField(
+ choices=add_blank_choice(WirelessChannelChoices),
+ required=False,
+ widget=StaticSelect(),
+ label='Wireless channel'
+ )
+ rf_channel_frequency = forms.DecimalField(
+ required=False,
+ label='Channel frequency (MHz)'
+ )
+ rf_channel_width = forms.DecimalField(
+ required=False,
+ label='Channel width (MHz)'
)
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
@@ -476,8 +510,9 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
required=False
)
field_order = (
- 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address',
- 'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
+ 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address',
+ 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+ 'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
)
def __init__(self, *args, **kwargs):
diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py
index 0596261a6..03f040a00 100644
--- a/netbox/dcim/forms/object_import.py
+++ b/netbox/dcim/forms/object_import.py
@@ -26,7 +26,7 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = DeviceType
fields = [
- 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
+ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
'comments',
]
diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py
index be10556be..8ce10979e 100644
--- a/netbox/dcim/graphql/types.py
+++ b/netbox/dcim/graphql/types.py
@@ -1,8 +1,11 @@
+import graphene
+
from dcim import filtersets, models
from extras.graphql.mixins import (
ChangelogMixin, ConfigContextMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin,
)
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
+from netbox.graphql.scalars import BigInt
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType
__all__ = (
@@ -144,6 +147,9 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, PrimaryObjectType):
def resolve_face(self, info):
return self.face or None
+ def resolve_airflow(self, info):
+ return self.airflow or None
+
class DeviceBayType(ComponentObjectType):
@@ -179,6 +185,9 @@ class DeviceTypeType(PrimaryObjectType):
def resolve_subdevice_role(self, info):
return self.subdevice_role or None
+ def resolve_airflow(self, info):
+ return self.airflow or None
+
class FrontPortType(ComponentObjectType):
@@ -206,6 +215,12 @@ class InterfaceType(IPAddressesMixin, ComponentObjectType):
def resolve_mode(self, info):
return self.mode or None
+ def resolve_rf_role(self, info):
+ return self.rf_role or None
+
+ def resolve_rf_channel(self, info):
+ return self.rf_channel or None
+
class InterfaceTemplateType(ComponentTemplateObjectType):
@@ -368,6 +383,7 @@ class RegionType(VLANGroupsMixin, OrganizationalObjectType):
class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType):
+ asn = graphene.Field(BigInt)
class Meta:
model = models.Site
diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py
index fd5f9cfab..d0cd64486 100644
--- a/netbox/dcim/management/commands/trace_paths.py
+++ b/netbox/dcim/management/commands/trace_paths.py
@@ -1,6 +1,7 @@
from django.core.management.base import BaseCommand
from django.core.management.color import no_style
from django.db import connection
+from django.db.models import Q
from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort
from dcim.signals import create_cablepath
@@ -67,7 +68,10 @@ class Command(BaseCommand):
# Retrace paths
for model in ENDPOINT_MODELS:
- origins = model.objects.filter(cable__isnull=False)
+ params = Q(cable__isnull=False)
+ if hasattr(model, 'wireless_link'):
+ params |= Q(wireless_link__isnull=False)
+ origins = model.objects.filter(params)
if not options['force']:
origins = origins.filter(_path__isnull=True)
origins_count = origins.count()
diff --git a/netbox/dcim/migrations/0134_interface_wwn_bridge.py b/netbox/dcim/migrations/0134_interface_wwn_bridge.py
new file mode 100644
index 000000000..a900ae6be
--- /dev/null
+++ b/netbox/dcim/migrations/0134_interface_wwn_bridge.py
@@ -0,0 +1,23 @@
+import dcim.fields
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0133_port_colors'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='interface',
+ name='wwn',
+ field=dcim.fields.WWNField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='bridge',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='dcim.interface'),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0135_tenancy_extensions.py b/netbox/dcim/migrations/0135_tenancy_extensions.py
new file mode 100644
index 000000000..96d765eea
--- /dev/null
+++ b/netbox/dcim/migrations/0135_tenancy_extensions.py
@@ -0,0 +1,23 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tenancy', '0002_tenant_ordering'),
+ ('dcim', '0134_interface_wwn_bridge'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='location',
+ name='tenant',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='locations', to='tenancy.tenant'),
+ ),
+ migrations.AddField(
+ model_name='cable',
+ name='tenant',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cables', to='tenancy.tenant'),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0136_device_airflow.py b/netbox/dcim/migrations/0136_device_airflow.py
new file mode 100644
index 000000000..94cc89f3f
--- /dev/null
+++ b/netbox/dcim/migrations/0136_device_airflow.py
@@ -0,0 +1,21 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0135_tenancy_extensions'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='devicetype',
+ name='airflow',
+ field=models.CharField(blank=True, max_length=50),
+ ),
+ migrations.AddField(
+ model_name='device',
+ name='airflow',
+ field=models.CharField(blank=True, max_length=50),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py b/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py
new file mode 100644
index 000000000..7cedb1b08
--- /dev/null
+++ b/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py
@@ -0,0 +1,83 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0136_device_airflow'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='region',
+ name='name',
+ field=models.CharField(max_length=100),
+ ),
+ migrations.AlterField(
+ model_name='region',
+ name='slug',
+ field=models.SlugField(max_length=100),
+ ),
+ migrations.AlterField(
+ model_name='sitegroup',
+ name='name',
+ field=models.CharField(max_length=100),
+ ),
+ migrations.AlterField(
+ model_name='sitegroup',
+ name='slug',
+ field=models.SlugField(max_length=100),
+ ),
+ migrations.AlterUniqueTogether(
+ name='location',
+ unique_together=set(),
+ ),
+ migrations.AddConstraint(
+ model_name='location',
+ constraint=models.UniqueConstraint(fields=('site', 'parent', 'name'), name='dcim_location_parent_name'),
+ ),
+ migrations.AddConstraint(
+ model_name='location',
+ constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('site', 'name'), name='dcim_location_name'),
+ ),
+ migrations.AddConstraint(
+ model_name='location',
+ constraint=models.UniqueConstraint(fields=('site', 'parent', 'slug'), name='dcim_location_parent_slug'),
+ ),
+ migrations.AddConstraint(
+ model_name='location',
+ constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('site', 'slug'), name='dcim_location_slug'),
+ ),
+ migrations.AddConstraint(
+ model_name='region',
+ constraint=models.UniqueConstraint(fields=('parent', 'name'), name='dcim_region_parent_name'),
+ ),
+ migrations.AddConstraint(
+ model_name='region',
+ constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('name',), name='dcim_region_name'),
+ ),
+ migrations.AddConstraint(
+ model_name='region',
+ constraint=models.UniqueConstraint(fields=('parent', 'slug'), name='dcim_region_parent_slug'),
+ ),
+ migrations.AddConstraint(
+ model_name='region',
+ constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('slug',), name='dcim_region_slug'),
+ ),
+ migrations.AddConstraint(
+ model_name='sitegroup',
+ constraint=models.UniqueConstraint(fields=('parent', 'name'), name='dcim_sitegroup_parent_name'),
+ ),
+ migrations.AddConstraint(
+ model_name='sitegroup',
+ constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('name',), name='dcim_sitegroup_name'),
+ ),
+ migrations.AddConstraint(
+ model_name='sitegroup',
+ constraint=models.UniqueConstraint(fields=('parent', 'slug'), name='dcim_sitegroup_parent_slug'),
+ ),
+ migrations.AddConstraint(
+ model_name='sitegroup',
+ constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('slug',), name='dcim_sitegroup_slug'),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0138_extend_tag_support.py b/netbox/dcim/migrations/0138_extend_tag_support.py
new file mode 100644
index 000000000..763b53c50
--- /dev/null
+++ b/netbox/dcim/migrations/0138_extend_tag_support.py
@@ -0,0 +1,50 @@
+# Generated by Django 3.2.8 on 2021-10-21 14:50
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0062_clear_secrets_changelog'),
+ ('dcim', '0137_relax_uniqueness_constraints'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='devicerole',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ migrations.AddField(
+ model_name='location',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ migrations.AddField(
+ model_name='manufacturer',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ migrations.AddField(
+ model_name='platform',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ migrations.AddField(
+ model_name='rackrole',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ migrations.AddField(
+ model_name='region',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ migrations.AddField(
+ model_name='sitegroup',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0139_rename_cable_peer.py b/netbox/dcim/migrations/0139_rename_cable_peer.py
new file mode 100644
index 000000000..59dc04e2a
--- /dev/null
+++ b/netbox/dcim/migrations/0139_rename_cable_peer.py
@@ -0,0 +1,91 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0138_extend_tag_support'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='consoleport',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='consoleport',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ migrations.RenameField(
+ model_name='consoleserverport',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='consoleserverport',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ migrations.RenameField(
+ model_name='frontport',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='frontport',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ migrations.RenameField(
+ model_name='interface',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='interface',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ migrations.RenameField(
+ model_name='powerfeed',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='powerfeed',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ migrations.RenameField(
+ model_name='poweroutlet',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='poweroutlet',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ migrations.RenameField(
+ model_name='powerport',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='powerport',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ migrations.RenameField(
+ model_name='rearport',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='rearport',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0140_wireless.py b/netbox/dcim/migrations/0140_wireless.py
new file mode 100644
index 000000000..430782cf0
--- /dev/null
+++ b/netbox/dcim/migrations/0140_wireless.py
@@ -0,0 +1,49 @@
+from django.db import migrations, models
+import django.core.validators
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0139_rename_cable_peer'),
+ ('wireless', '0001_wireless'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='interface',
+ name='rf_role',
+ field=models.CharField(blank=True, max_length=30),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='rf_channel',
+ field=models.CharField(blank=True, max_length=50),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='rf_channel_frequency',
+ field=models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='rf_channel_width',
+ field=models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='tx_power',
+ field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(127)]),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='wireless_lans',
+ field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='wireless_link',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wireless.wirelesslink'),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0141_asn_model.py b/netbox/dcim/migrations/0141_asn_model.py
new file mode 100644
index 000000000..6f011f35d
--- /dev/null
+++ b/netbox/dcim/migrations/0141_asn_model.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.8 on 2021-11-02 16:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ipam', '0053_asn_model'),
+ ('dcim', '0140_wireless'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='site',
+ name='asns',
+ field=models.ManyToManyField(blank=True, related_name='sites', to='ipam.ASN'),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0142_rename_128gfc_qsfp28.py b/netbox/dcim/migrations/0142_rename_128gfc_qsfp28.py
new file mode 100644
index 000000000..d1f91afae
--- /dev/null
+++ b/netbox/dcim/migrations/0142_rename_128gfc_qsfp28.py
@@ -0,0 +1,29 @@
+from django.db import migrations
+
+OLD_VALUE = '128gfc-sfp28'
+NEW_VALUE = '128gfc-qsfp28'
+
+
+def correct_type(apps, schema_editor):
+ """
+ Correct TYPE_128GFC_QSFP28 interface type.
+ """
+ Interface = apps.get_model('dcim', 'Interface')
+ InterfaceTemplate = apps.get_model('dcim', 'InterfaceTemplate')
+
+ for model in (Interface, InterfaceTemplate):
+ model.objects.filter(type=OLD_VALUE).update(type=NEW_VALUE)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0141_asn_model'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ code=correct_type,
+ reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0143_remove_primary_for_related_name.py b/netbox/dcim/migrations/0143_remove_primary_for_related_name.py
new file mode 100644
index 000000000..820c9e3fe
--- /dev/null
+++ b/netbox/dcim/migrations/0143_remove_primary_for_related_name.py
@@ -0,0 +1,23 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ipam', '0053_asn_model'),
+ ('dcim', '0142_rename_128gfc_qsfp28'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='device',
+ name='primary_ip4',
+ field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress'),
+ ),
+ migrations.AlterField(
+ model_name='device',
+ name='primary_ip6',
+ field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress'),
+ ),
+ ]
diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py
index 0375a9fb4..58a3e1de5 100644
--- a/netbox/dcim/models/__init__.py
+++ b/netbox/dcim/models/__init__.py
@@ -10,7 +10,7 @@ __all__ = (
'BaseInterface',
'Cable',
'CablePath',
- 'CableTermination',
+ 'LinkTermination',
'ConsolePort',
'ConsolePortTemplate',
'ConsoleServerPort',
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index c3f8cac3f..333972b21 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -14,7 +14,6 @@ from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_ob
from extras.utils import extras_features
from netbox.models import BigIDModel, PrimaryModel
from utilities.fields import ColorField
-from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters
from .devices import Device
from .device_components import FrontPort, RearPort
@@ -64,8 +63,15 @@ class Cable(PrimaryModel):
)
status = models.CharField(
max_length=50,
- choices=CableStatusChoices,
- default=CableStatusChoices.STATUS_CONNECTED
+ choices=LinkStatusChoices,
+ default=LinkStatusChoices.STATUS_CONNECTED
+ )
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='cables',
+ blank=True,
+ null=True
)
label = models.CharField(
max_length=100,
@@ -109,8 +115,6 @@ class Cable(PrimaryModel):
null=True
)
- objects = RestrictedQuerySet.as_manager()
-
class Meta:
ordering = ['pk']
unique_together = (
@@ -285,7 +289,7 @@ class Cable(PrimaryModel):
self._pk = self.pk
def get_status_class(self):
- return CableStatusChoices.CSS_CLASSES.get(self.status)
+ return LinkStatusChoices.CSS_CLASSES.get(self.status)
def get_compatible_types(self):
"""
@@ -379,7 +383,7 @@ class CablePath(BigIDModel):
"""
from circuits.models import CircuitTermination
- if origin is None or origin.cable is None:
+ if origin is None or origin.link is None:
return None
destination = None
@@ -389,13 +393,13 @@ class CablePath(BigIDModel):
is_split = False
node = origin
- while node.cable is not None:
- if node.cable.status != CableStatusChoices.STATUS_CONNECTED:
+ while node.link is not None:
+ if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED:
is_active = False
- # Follow the cable to its far-end termination
- path.append(object_to_path_node(node.cable))
- peer_termination = node.get_cable_peer()
+ # Follow the link to its far-end termination
+ path.append(object_to_path_node(node.link))
+ peer_termination = node.get_link_peer()
# Follow a FrontPort to its corresponding RearPort
if isinstance(peer_termination, FrontPort):
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index e704f74a7..42e453669 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -7,7 +7,6 @@ from dcim.constants import *
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel
from utilities.fields import ColorField, NaturalOrderingField
-from utilities.querysets import RestrictedQuerySet
from utilities.ordering import naturalize_interface
from .device_components import (
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
@@ -50,8 +49,6 @@ class ComponentTemplateModel(ChangeLoggedModel):
blank=True
)
- objects = RestrictedQuerySet.as_manager()
-
class Meta:
abstract = True
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index a321c8059..75363b4f0 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -9,20 +9,21 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
from dcim.constants import *
-from dcim.fields import MACAddressField
+from dcim.fields import MACAddressField, WWNField
from dcim.svg import CableTraceSVG
from extras.utils import extras_features
from netbox.models import PrimaryModel
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface
-from utilities.querysets import RestrictedQuerySet
from utilities.query_functions import CollateAsChar
+from wireless.choices import *
+from wireless.utils import get_channel_attr
__all__ = (
'BaseInterface',
- 'CableTermination',
+ 'LinkTermination',
'ConsolePort',
'ConsoleServerPort',
'DeviceBay',
@@ -63,8 +64,6 @@ class ComponentModel(PrimaryModel):
blank=True
)
- objects = RestrictedQuerySet.as_manager()
-
class Meta:
abstract = True
@@ -87,14 +86,14 @@ class ComponentModel(PrimaryModel):
return self.device
-class CableTermination(models.Model):
+class LinkTermination(models.Model):
"""
- An abstract model inherited by all models to which a Cable can terminate (certain device components, PowerFeed, and
- CircuitTermination instances). The `cable` field indicates the Cable instance which is terminated to this instance.
+ An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples
+ include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields
+ reference the attached Cable or WirelessLink instance, respectively.
- `_cable_peer` is a GenericForeignKey used to cache the far-end CableTermination on the local instance; this is a
- shortcut to referencing `cable.termination_b`, for example. `_cable_peer` is set or cleared by the receivers in
- dcim.signals when a Cable instance is created or deleted, respectively.
+ `_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination on the local instance; this is a
+ shortcut to referencing `instance.link.termination_b`, for example.
"""
cable = models.ForeignKey(
to='dcim.Cable',
@@ -103,20 +102,20 @@ class CableTermination(models.Model):
blank=True,
null=True
)
- _cable_peer_type = models.ForeignKey(
+ _link_peer_type = models.ForeignKey(
to=ContentType,
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
- _cable_peer_id = models.PositiveIntegerField(
+ _link_peer_id = models.PositiveIntegerField(
blank=True,
null=True
)
- _cable_peer = GenericForeignKey(
- ct_field='_cable_peer_type',
- fk_field='_cable_peer_id'
+ _link_peer = GenericForeignKey(
+ ct_field='_link_peer_type',
+ fk_field='_link_peer_id'
)
mark_connected = models.BooleanField(
default=False,
@@ -146,8 +145,8 @@ class CableTermination(models.Model):
"mark_connected": "Cannot mark as connected with a cable attached."
})
- def get_cable_peer(self):
- return self._cable_peer
+ def get_link_peer(self):
+ return self._link_peer
@property
def _occupied(self):
@@ -157,6 +156,13 @@ class CableTermination(models.Model):
def parent_object(self):
raise NotImplementedError("CableTermination models must implement parent_object()")
+ @property
+ def link(self):
+ """
+ Generic wrapper for a Cable, WirelessLink, or some other relation to a connected termination.
+ """
+ return self.cable
+
class PathEndpoint(models.Model):
"""
@@ -180,15 +186,23 @@ class PathEndpoint(models.Model):
abstract = True
def trace(self):
- if self._path is None:
- return []
+ origin = self
+ path = []
# Construct the complete path
- path = [self, *self._path.get_path()]
- while (len(path) + 1) % 3:
- # Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort)
- path.append(None)
- path.append(self._path.destination)
+ while origin is not None:
+
+ if origin._path is None:
+ return path
+
+ path.extend([origin, *origin._path.get_path()])
+ while (len(path) + 1) % 3:
+ # Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort)
+ path.append(None)
+ path.append(origin._path.destination)
+
+ # Check for bridge interface to continue the trace
+ origin = getattr(origin._path.destination, 'bridge', None)
# Return the path as a list of three-tuples (A termination, cable, B termination)
return list(zip(*[iter(path)] * 3))
@@ -219,7 +233,7 @@ class PathEndpoint(models.Model):
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
+class ConsolePort(ComponentModel, LinkTermination, PathEndpoint):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
"""
@@ -251,7 +265,7 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
+class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
"""
@@ -283,7 +297,7 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class PowerPort(ComponentModel, CableTermination, PathEndpoint):
+class PowerPort(ComponentModel, LinkTermination, PathEndpoint):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
"""
@@ -333,8 +347,8 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet)
outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
utilization = PowerPort.objects.filter(
- _cable_peer_type=poweroutlet_ct,
- _cable_peer_id__in=outlet_ids
+ _link_peer_type=poweroutlet_ct,
+ _link_peer_id__in=outlet_ids
).aggregate(
maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'),
@@ -347,12 +361,12 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
}
# Calculate per-leg aggregates for three-phase feeds
- if getattr(self._cable_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE:
+ if getattr(self._link_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE:
for leg, leg_name in PowerOutletFeedLegChoices:
outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
utilization = PowerPort.objects.filter(
- _cable_peer_type=poweroutlet_ct,
- _cable_peer_id__in=outlet_ids
+ _link_peer_type=poweroutlet_ct,
+ _link_peer_id__in=outlet_ids
).aggregate(
maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'),
@@ -380,7 +394,7 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
+class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
"""
@@ -453,6 +467,22 @@ class BaseInterface(models.Model):
choices=InterfaceModeChoices,
blank=True
)
+ parent = models.ForeignKey(
+ to='self',
+ on_delete=models.SET_NULL,
+ related_name='child_interfaces',
+ null=True,
+ blank=True,
+ verbose_name='Parent interface'
+ )
+ bridge = models.ForeignKey(
+ to='self',
+ on_delete=models.SET_NULL,
+ related_name='bridge_interfaces',
+ null=True,
+ blank=True,
+ verbose_name='Bridge interface'
+ )
class Meta:
abstract = True
@@ -473,9 +503,13 @@ class BaseInterface(models.Model):
def count_ipaddresses(self):
return self.ip_addresses.count()
+ @property
+ def count_fhrp_groups(self):
+ return self.fhrp_group_assignments.count()
+
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
+class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint):
"""
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
"""
@@ -486,14 +520,6 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
max_length=100,
blank=True
)
- parent = models.ForeignKey(
- to='self',
- on_delete=models.SET_NULL,
- related_name='child_interfaces',
- null=True,
- blank=True,
- verbose_name='Parent interface'
- )
lag = models.ForeignKey(
to='self',
on_delete=models.SET_NULL,
@@ -511,6 +537,57 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
verbose_name='Management only',
help_text='This interface is used only for out-of-band management'
)
+ wwn = WWNField(
+ null=True,
+ blank=True,
+ verbose_name='WWN',
+ help_text='64-bit World Wide Name'
+ )
+ rf_role = models.CharField(
+ max_length=30,
+ choices=WirelessRoleChoices,
+ blank=True,
+ verbose_name='Wireless role'
+ )
+ rf_channel = models.CharField(
+ max_length=50,
+ choices=WirelessChannelChoices,
+ blank=True,
+ verbose_name='Wireless channel'
+ )
+ rf_channel_frequency = models.DecimalField(
+ max_digits=7,
+ decimal_places=2,
+ blank=True,
+ null=True,
+ verbose_name='Channel frequency (MHz)'
+ )
+ rf_channel_width = models.DecimalField(
+ max_digits=7,
+ decimal_places=3,
+ blank=True,
+ null=True,
+ verbose_name='Channel width (MHz)'
+ )
+ tx_power = models.PositiveSmallIntegerField(
+ blank=True,
+ null=True,
+ validators=(MaxValueValidator(127),),
+ verbose_name='Transmit power (dBm)'
+ )
+ wireless_link = models.ForeignKey(
+ to='wireless.WirelessLink',
+ on_delete=models.SET_NULL,
+ related_name='+',
+ blank=True,
+ null=True
+ )
+ wireless_lans = models.ManyToManyField(
+ to='wireless.WirelessLAN',
+ related_name='interfaces',
+ blank=True,
+ verbose_name='Wireless LANs'
+ )
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
@@ -531,8 +608,14 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
object_id_field='assigned_object_id',
related_query_name='interface'
)
+ fhrp_group_assignments = GenericRelation(
+ to='ipam.FHRPGroupAssignment',
+ content_type_field='interface_type',
+ object_id_field='interface_id',
+ related_query_name='+'
+ )
- clone_fields = ['device', 'parent', 'lag', 'type', 'mgmt_only']
+ clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only']
class Meta:
ordering = ('device', CollateAsChar('_name'))
@@ -544,18 +627,28 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
def clean(self):
super().clean()
- # Virtual interfaces cannot be connected
- if not self.is_connectable and self.cable:
+ # Virtual Interfaces cannot have a Cable attached
+ if self.is_virtual and self.cable:
raise ValidationError({
'type': f"{self.get_type_display()} interfaces cannot have a cable attached."
})
- # Non-connectable interfaces cannot be marked as connected
- if not self.is_connectable and self.mark_connected:
+ # Virtual Interfaces cannot be marked as connected
+ if self.is_virtual and self.mark_connected:
raise ValidationError({
'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected."
})
+ # Parent validation
+
+ # An interface cannot be its own parent
+ if self.pk and self.parent_id == self.pk:
+ raise ValidationError({'parent': "An interface cannot be its own parent."})
+
+ # A physical interface cannot have a parent interface
+ if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
+ raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."})
+
# An interface's parent must belong to the same device or virtual chassis
if self.parent and self.parent.device != self.device:
if self.device.virtual_chassis is None:
@@ -569,13 +662,34 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
f"is not part of virtual chassis {self.device.virtual_chassis}."
})
- # An interface cannot be its own parent
- if self.pk and self.parent_id == self.pk:
- raise ValidationError({'parent': "An interface cannot be its own parent."})
+ # Bridge validation
- # A physical interface cannot have a parent interface
- if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
- raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."})
+ # An interface cannot be bridged to itself
+ if self.pk and self.bridge_id == self.pk:
+ raise ValidationError({'bridge': "An interface cannot be bridged to itself."})
+
+ # A bridged interface belong to the same device or virtual chassis
+ if self.bridge and self.bridge.device != self.device:
+ if self.device.virtual_chassis is None:
+ raise ValidationError({
+ 'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different device "
+ f"({self.bridge.device})."
+ })
+ elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
+ raise ValidationError({
+ 'bridge': f"The selected bridge interface ({self.bridge}) belongs to {self.bridge.device}, which "
+ f"is not part of virtual chassis {self.device.virtual_chassis}."
+ })
+
+ # LAG validation
+
+ # A virtual interface cannot have a parent LAG
+ if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
+ raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."})
+
+ # A LAG interface cannot be its own parent
+ if self.pk and self.lag_id == self.pk:
+ raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
# An interface's LAG must belong to the same device or virtual chassis
if self.lag and self.lag.device != self.device:
@@ -589,24 +703,52 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
f"of virtual chassis {self.device.virtual_chassis}."
})
- # A virtual interface cannot have a parent LAG
- if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
- raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."})
+ # Wireless validation
- # A LAG interface cannot be its own parent
- if self.pk and self.lag_id == self.pk:
- raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
+ # RF role & channel may only be set for wireless interfaces
+ if self.rf_role and not self.is_wireless:
+ raise ValidationError({'rf_role': "Wireless role may be set only on wireless interfaces."})
+ if self.rf_channel and not self.is_wireless:
+ raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."})
+
+ # Validate channel frequency against interface type and selected channel (if any)
+ if self.rf_channel_frequency:
+ if not self.is_wireless:
+ raise ValidationError({
+ 'rf_channel_frequency': "Channel frequency may be set only on wireless interfaces.",
+ })
+ if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'):
+ raise ValidationError({
+ 'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
+ })
+ elif self.rf_channel:
+ self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
+
+ # Validate channel width against interface type and selected channel (if any)
+ if self.rf_channel_width:
+ if not self.is_wireless:
+ raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."})
+ if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
+ raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."})
+ elif self.rf_channel:
+ self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
+
+ # VLAN validation
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
raise ValidationError({
- 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
- "device, or it must be global".format(self.untagged_vlan)
+ 'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the "
+ f"interface's parent device, or it must be global."
})
@property
- def is_connectable(self):
- return self.type not in NONCONNECTABLE_IFACE_TYPES
+ def _occupied(self):
+ return super()._occupied or bool(self.wireless_link_id)
+
+ @property
+ def is_wired(self):
+ return not self.is_virtual and not self.is_wireless
@property
def is_virtual(self):
@@ -620,13 +762,17 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
def is_lag(self):
return self.type == InterfaceTypeChoices.TYPE_LAG
+ @property
+ def link(self):
+ return self.cable or self.wireless_link
+
#
# Pass-through ports
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class FrontPort(ComponentModel, CableTermination):
+class FrontPort(ComponentModel, LinkTermination):
"""
A pass-through port on the front of a Device.
"""
@@ -680,7 +826,7 @@ class FrontPort(ComponentModel, CableTermination):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class RearPort(ComponentModel, CableTermination):
+class RearPort(ComponentModel, LinkTermination):
"""
A pass-through port on the rear of a Device.
"""
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 10cd35c13..a2ae20319 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -1,7 +1,6 @@
from collections import OrderedDict
import yaml
-from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -15,10 +14,10 @@ from dcim.constants import *
from extras.models import ConfigContextModel
from extras.querysets import ConfigContextModelQuerySet
from extras.utils import extras_features
+from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
-from utilities.querysets import RestrictedQuerySet
from .device_components import *
@@ -36,7 +35,7 @@ __all__ = (
# Device Types
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Manufacturer(OrganizationalModel):
"""
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@@ -54,7 +53,10 @@ class Manufacturer(OrganizationalModel):
blank=True
)
- objects = RestrictedQuerySet.as_manager()
+ # Generic relations
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
class Meta:
ordering = ['name']
@@ -115,6 +117,11 @@ class DeviceType(PrimaryModel):
help_text='Parent devices house child devices in device bays. Leave blank '
'if this device type is neither a parent nor a child.'
)
+ airflow = models.CharField(
+ max_length=50,
+ choices=DeviceAirflowChoices,
+ blank=True
+ )
front_image = models.ImageField(
upload_to='devicetype-images',
blank=True
@@ -127,10 +134,8 @@ class DeviceType(PrimaryModel):
blank=True
)
- objects = RestrictedQuerySet.as_manager()
-
clone_fields = [
- 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
+ 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
]
class Meta:
@@ -165,6 +170,7 @@ class DeviceType(PrimaryModel):
('u_height', self.u_height),
('is_full_depth', self.is_full_depth),
('subdevice_role', self.subdevice_role),
+ ('airflow', self.airflow),
('comments', self.comments),
))
@@ -340,7 +346,7 @@ class DeviceType(PrimaryModel):
# Devices
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceRole(OrganizationalModel):
"""
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
@@ -368,8 +374,6 @@ class DeviceRole(OrganizationalModel):
blank=True,
)
- objects = RestrictedQuerySet.as_manager()
-
class Meta:
ordering = ['name']
@@ -380,7 +384,7 @@ class DeviceRole(OrganizationalModel):
return reverse('dcim:devicerole', args=[self.pk])
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Platform(OrganizationalModel):
"""
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
@@ -420,8 +424,6 @@ class Platform(OrganizationalModel):
blank=True
)
- objects = RestrictedQuerySet.as_manager()
-
class Meta:
ordering = ['name']
@@ -530,10 +532,15 @@ class Device(PrimaryModel, ConfigContextModel):
choices=DeviceStatusChoices,
default=DeviceStatusChoices.STATUS_ACTIVE
)
+ airflow = models.CharField(
+ max_length=50,
+ choices=DeviceAirflowChoices,
+ blank=True
+ )
primary_ip4 = models.OneToOneField(
to='ipam.IPAddress',
on_delete=models.SET_NULL,
- related_name='primary_ip4_for',
+ related_name='+',
blank=True,
null=True,
verbose_name='Primary IPv4'
@@ -541,7 +548,7 @@ class Device(PrimaryModel, ConfigContextModel):
primary_ip6 = models.OneToOneField(
to='ipam.IPAddress',
on_delete=models.SET_NULL,
- related_name='primary_ip6_for',
+ related_name='+',
blank=True,
null=True,
verbose_name='Primary IPv6'
@@ -573,6 +580,11 @@ class Device(PrimaryModel, ConfigContextModel):
comments = models.TextField(
blank=True
)
+
+ # Generic relations
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
images = GenericRelation(
to='extras.ImageAttachment'
)
@@ -580,7 +592,7 @@ class Device(PrimaryModel, ConfigContextModel):
objects = ConfigContextModelQuerySet.as_manager()
clone_fields = [
- 'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'status', 'cluster',
+ 'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'status', 'airflow', 'cluster',
]
class Meta:
@@ -592,7 +604,9 @@ class Device(PrimaryModel, ConfigContextModel):
)
def __str__(self):
- if self.name:
+ if self.name and self.asset_tag:
+ return f'{self.name} ({self.asset_tag})'
+ elif self.name:
return self.name
elif self.virtual_chassis:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
@@ -741,9 +755,12 @@ class Device(PrimaryModel, ConfigContextModel):
})
def save(self, *args, **kwargs):
-
is_new = not bool(self.pk)
+ # Inherit airflow attribute from DeviceType if not set
+ if is_new and not self.airflow:
+ self.airflow = self.device_type.airflow
+
super().save(*args, **kwargs)
# If this is a new Device, instantiate all of the related components per the DeviceType definition
@@ -791,7 +808,7 @@ class Device(PrimaryModel, ConfigContextModel):
@property
def primary_ip(self):
- if settings.PREFER_IPV4 and self.primary_ip4:
+ if ConfigItem('PREFER_IPV4')() and self.primary_ip4:
return self.primary_ip4
elif self.primary_ip6:
return self.primary_ip6
@@ -872,8 +889,6 @@ class VirtualChassis(PrimaryModel):
blank=True
)
- objects = RestrictedQuerySet.as_manager()
-
class Meta:
ordering = ['name']
verbose_name_plural = 'virtual chassis'
diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py
index 0e9520b36..b5d8d4c83 100644
--- a/netbox/dcim/models/power.py
+++ b/netbox/dcim/models/power.py
@@ -8,9 +8,8 @@ from dcim.choices import *
from dcim.constants import *
from extras.utils import extras_features
from netbox.models import PrimaryModel
-from utilities.querysets import RestrictedQuerySet
from utilities.validators import ExclusionValidator
-from .device_components import CableTermination, PathEndpoint
+from .device_components import LinkTermination, PathEndpoint
__all__ = (
'PowerFeed',
@@ -40,12 +39,15 @@ class PowerPanel(PrimaryModel):
name = models.CharField(
max_length=100
)
+
+ # Generic relations
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
images = GenericRelation(
to='extras.ImageAttachment'
)
- objects = RestrictedQuerySet.as_manager()
-
class Meta:
ordering = ['site', 'name']
unique_together = ['site', 'name']
@@ -67,7 +69,7 @@ class PowerPanel(PrimaryModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
+class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination):
"""
An electrical circuit delivered from a PowerPanel.
"""
@@ -126,8 +128,6 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
blank=True
)
- objects = RestrictedQuerySet.as_manager()
-
clone_fields = [
'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization', 'available_power',
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index c287d7d6c..082ecfe57 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -1,6 +1,5 @@
from collections import OrderedDict
-from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
@@ -15,10 +14,10 @@ from dcim.choices import *
from dcim.constants import *
from dcim.svg import RackElevationSVG
from extras.utils import extras_features
+from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
-from utilities.querysets import RestrictedQuerySet
from utilities.utils import array_to_string
from .device_components import PowerOutlet, PowerPort
from .devices import Device
@@ -35,7 +34,7 @@ __all__ = (
# Racks
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RackRole(OrganizationalModel):
"""
Racks can be organized by functional role, similar to Devices.
@@ -56,8 +55,6 @@ class RackRole(OrganizationalModel):
blank=True,
)
- objects = RestrictedQuerySet.as_manager()
-
class Meta:
ordering = ['name']
@@ -175,18 +172,21 @@ class Rack(PrimaryModel):
comments = models.TextField(
blank=True
)
+
+ # Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='rack'
)
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
images = GenericRelation(
to='extras.ImageAttachment'
)
- objects = RestrictedQuerySet.as_manager()
-
clone_fields = [
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit',
@@ -368,8 +368,8 @@ class Rack(PrimaryModel):
self,
face=DeviceFaceChoices.FACE_FRONT,
user=None,
- unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
- unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
+ unit_width=None,
+ unit_height=None,
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
include_images=True,
base_url=None
@@ -388,6 +388,10 @@ class Rack(PrimaryModel):
:param base_url: Base URL for links and images. If none, URLs will be relative.
"""
elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
+ if unit_width is None or unit_height is None:
+ config = get_config()
+ unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
+ unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
return elevation.render(face, unit_width, unit_height, legend_width)
@@ -422,13 +426,13 @@ class Rack(PrimaryModel):
return 0
pf_powerports = PowerPort.objects.filter(
- _cable_peer_type=ContentType.objects.get_for_model(PowerFeed),
- _cable_peer_id__in=powerfeeds.values_list('id', flat=True)
+ _link_peer_type=ContentType.objects.get_for_model(PowerFeed),
+ _link_peer_id__in=powerfeeds.values_list('id', flat=True)
)
poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports)
allocated_draw_total = PowerPort.objects.filter(
- _cable_peer_type=ContentType.objects.get_for_model(PowerOutlet),
- _cable_peer_id__in=poweroutlets.values_list('id', flat=True)
+ _link_peer_type=ContentType.objects.get_for_model(PowerOutlet),
+ _link_peer_id__in=poweroutlets.values_list('id', flat=True)
).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0
return int(allocated_draw_total / available_power_total * 100)
@@ -462,8 +466,6 @@ class RackReservation(PrimaryModel):
max_length=200
)
- objects = RestrictedQuerySet.as_manager()
-
class Meta:
ordering = ['created', 'pk']
diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py
index 56946642b..a19ae8050 100644
--- a/netbox/dcim/models/sites.py
+++ b/netbox/dcim/models/sites.py
@@ -7,12 +7,10 @@ from timezone_field import TimeZoneField
from dcim.choices import *
from dcim.constants import *
-from django.core.exceptions import ValidationError
from dcim.fields import ASNField
from extras.utils import extras_features
from netbox.models import NestedGroupModel, PrimaryModel
from utilities.fields import NaturalOrderingField
-from utilities.querysets import RestrictedQuerySet
__all__ = (
'Location',
@@ -26,7 +24,7 @@ __all__ = (
# Regions
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Region(NestedGroupModel):
"""
A region represents a geographic collection of sites. For example, you might create regions representing countries,
@@ -42,23 +40,62 @@ class Region(NestedGroupModel):
db_index=True
)
name = models.CharField(
- max_length=100,
- unique=True
+ max_length=100
)
slug = models.SlugField(
- max_length=100,
- unique=True
+ max_length=100
)
description = models.CharField(
max_length=200,
blank=True
)
+
+ # Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='region'
)
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
+
+ class Meta:
+ constraints = (
+ models.UniqueConstraint(
+ fields=('parent', 'name'),
+ name='dcim_region_parent_name'
+ ),
+ models.UniqueConstraint(
+ fields=('name',),
+ name='dcim_region_name',
+ condition=Q(parent=None)
+ ),
+ models.UniqueConstraint(
+ fields=('parent', 'slug'),
+ name='dcim_region_parent_slug'
+ ),
+ models.UniqueConstraint(
+ fields=('slug',),
+ name='dcim_region_slug',
+ condition=Q(parent=None)
+ ),
+ )
+
+ def validate_unique(self, exclude=None):
+ if self.parent is None:
+ regions = Region.objects.exclude(pk=self.pk)
+ if regions.filter(name=self.name, parent__isnull=True).exists():
+ raise ValidationError({
+ 'name': 'A region with this name already exists.'
+ })
+ if regions.filter(slug=self.slug, parent__isnull=True).exists():
+ raise ValidationError({
+ 'name': 'A region with this slug already exists.'
+ })
+
+ super().validate_unique(exclude=exclude)
def get_absolute_url(self):
return reverse('dcim:region', args=[self.pk])
@@ -74,7 +111,7 @@ class Region(NestedGroupModel):
# Site groups
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class SiteGroup(NestedGroupModel):
"""
A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
@@ -90,23 +127,62 @@ class SiteGroup(NestedGroupModel):
db_index=True
)
name = models.CharField(
- max_length=100,
- unique=True
+ max_length=100
)
slug = models.SlugField(
- max_length=100,
- unique=True
+ max_length=100
)
description = models.CharField(
max_length=200,
blank=True
)
+
+ # Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='site_group'
)
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
+
+ class Meta:
+ constraints = (
+ models.UniqueConstraint(
+ fields=('parent', 'name'),
+ name='dcim_sitegroup_parent_name'
+ ),
+ models.UniqueConstraint(
+ fields=('name',),
+ name='dcim_sitegroup_name',
+ condition=Q(parent=None)
+ ),
+ models.UniqueConstraint(
+ fields=('parent', 'slug'),
+ name='dcim_sitegroup_parent_slug'
+ ),
+ models.UniqueConstraint(
+ fields=('slug',),
+ name='dcim_sitegroup_slug',
+ condition=Q(parent=None)
+ ),
+ )
+
+ def validate_unique(self, exclude=None):
+ if self.parent is None:
+ site_groups = SiteGroup.objects.exclude(pk=self.pk)
+ if site_groups.filter(name=self.name, parent__isnull=True).exists():
+ raise ValidationError({
+ 'name': 'A site group with this name already exists.'
+ })
+ if site_groups.filter(slug=self.slug, parent__isnull=True).exists():
+ raise ValidationError({
+ 'name': 'A site group with this slug already exists.'
+ })
+
+ super().validate_unique(exclude=exclude)
def get_absolute_url(self):
return reverse('dcim:sitegroup', args=[self.pk])
@@ -178,6 +254,11 @@ class Site(PrimaryModel):
verbose_name='ASN',
help_text='32-bit autonomous system number'
)
+ asns = models.ManyToManyField(
+ to='ipam.ASN',
+ related_name='sites',
+ blank=True
+ )
time_zone = TimeZoneField(
blank=True
)
@@ -222,18 +303,21 @@ class Site(PrimaryModel):
comments = models.TextField(
blank=True
)
+
+ # Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='site'
)
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
images = GenericRelation(
to='extras.ImageAttachment'
)
- objects = RestrictedQuerySet.as_manager()
-
clone_fields = [
'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
@@ -256,7 +340,7 @@ class Site(PrimaryModel):
# Locations
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Location(NestedGroupModel):
"""
A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
@@ -281,16 +365,28 @@ class Location(NestedGroupModel):
null=True,
db_index=True
)
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='locations',
+ blank=True,
+ null=True
+ )
description = models.CharField(
max_length=200,
blank=True
)
+
+ # Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='location'
)
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
images = GenericRelation(
to='extras.ImageAttachment'
)
@@ -299,10 +395,40 @@ class Location(NestedGroupModel):
class Meta:
ordering = ['site', 'name']
- unique_together = [
- ['site', 'name'],
- ['site', 'slug'],
- ]
+ constraints = (
+ models.UniqueConstraint(
+ fields=('site', 'parent', 'name'),
+ name='dcim_location_parent_name'
+ ),
+ models.UniqueConstraint(
+ fields=('site', 'name'),
+ name='dcim_location_name',
+ condition=Q(parent=None)
+ ),
+ models.UniqueConstraint(
+ fields=('site', 'parent', 'slug'),
+ name='dcim_location_parent_slug'
+ ),
+ models.UniqueConstraint(
+ fields=('site', 'slug'),
+ name='dcim_location_slug',
+ condition=Q(parent=None)
+ ),
+ )
+
+ def validate_unique(self, exclude=None):
+ if self.parent is None:
+ locations = Location.objects.exclude(pk=self.pk)
+ if locations.filter(name=self.name, site=self.site, parent__isnull=True).exists():
+ raise ValidationError({
+ "name": f"A location with this name in site {self.site} already exists."
+ })
+ if locations.filter(slug=self.slug, site=self.site, parent__isnull=True).exists():
+ raise ValidationError({
+ "name": f"A location with this slug in site {self.site} already exists."
+ })
+
+ super().validate_unique(exclude=exclude)
def get_absolute_url(self):
return reverse('dcim:location', args=[self.pk])
diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py
index 9fc68ee70..79e9c6687 100644
--- a/netbox/dcim/signals.py
+++ b/netbox/dcim/signals.py
@@ -2,37 +2,11 @@ import logging
from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import post_save, post_delete, pre_delete
-from django.db import transaction
from django.dispatch import receiver
-from .choices import CableStatusChoices
+from .choices import LinkStatusChoices
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
-
-
-def create_cablepath(node):
- """
- Create CablePaths for all paths originating from the specified node.
- """
- cp = CablePath.from_origin(node)
- if cp:
- try:
- cp.save()
- except Exception as e:
- print(node, node.pk)
- raise e
-
-
-def rebuild_paths(obj):
- """
- Rebuild all CablePaths which traverse the specified node
- """
- cable_paths = CablePath.objects.filter(path__contains=obj)
-
- with transaction.atomic():
- for cp in cable_paths:
- cp.delete()
- if cp.origin:
- create_cablepath(cp.origin)
+from .utils import create_cablepath, rebuild_paths
#
@@ -109,12 +83,12 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
if instance.termination_a.cable != instance:
logger.debug(f"Updating termination A for cable {instance}")
instance.termination_a.cable = instance
- instance.termination_a._cable_peer = instance.termination_b
+ instance.termination_a._link_peer = instance.termination_b
instance.termination_a.save()
if instance.termination_b.cable != instance:
logger.debug(f"Updating termination B for cable {instance}")
instance.termination_b.cable = instance
- instance.termination_b._cable_peer = instance.termination_a
+ instance.termination_b._link_peer = instance.termination_a
instance.termination_b.save()
# Create/update cable paths
@@ -128,7 +102,7 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
# We currently don't support modifying either termination of an existing Cable. (This
# may change in the future.) However, we do need to capture status changes and update
# any CablePaths accordingly.
- if instance.status != CableStatusChoices.STATUS_CONNECTED:
+ if instance.status != LinkStatusChoices.STATUS_CONNECTED:
CablePath.objects.filter(path__contains=instance).update(is_active=False)
else:
rebuild_paths(instance)
@@ -145,11 +119,11 @@ def nullify_connected_endpoints(instance, **kwargs):
if instance.termination_a is not None:
logger.debug(f"Nullifying termination A for cable {instance}")
model = instance.termination_a._meta.model
- model.objects.filter(pk=instance.termination_a.pk).update(_cable_peer_type=None, _cable_peer_id=None)
+ model.objects.filter(pk=instance.termination_a.pk).update(_link_peer_type=None, _link_peer_id=None)
if instance.termination_b is not None:
logger.debug(f"Nullifying termination B for cable {instance}")
model = instance.termination_b._meta.model
- model.objects.filter(pk=instance.termination_b.pk).update(_cable_peer_type=None, _cable_peer_id=None)
+ model.objects.filter(pk=instance.termination_b.pk).update(_link_peer_type=None, _link_peer_id=None)
# Delete and retrace any dependent cable paths
for cablepath in CablePath.objects.filter(path__contains=instance):
diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py
index 5601bc591..e90890eeb 100644
--- a/netbox/dcim/svg.py
+++ b/netbox/dcim/svg.py
@@ -398,6 +398,39 @@ class CableTraceSVG:
return group
+ def _draw_wirelesslink(self, url, labels):
+ """
+ Draw a line with labels representing a WirelessLink.
+
+ :param url: Hyperlink URL
+ :param labels: Iterable of text labels
+ """
+ group = Group(class_='connector')
+
+ # Draw the wireless link
+ start = (OFFSET + self.center, self.cursor)
+ height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
+ end = (start[0], start[1] + height)
+ line = Line(start=start, end=end, class_='wireless-link')
+ group.add(line)
+
+ self.cursor += PADDING * 2
+
+ # Add link
+ link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
+
+ # Add text label(s)
+ for i, label in enumerate(labels):
+ self.cursor += LINE_HEIGHT
+ text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
+ text = Text(label, insert=text_coords, class_='bold' if not i else [])
+ link.add(text)
+
+ group.add(link)
+ self.cursor += PADDING * 2
+
+ return group
+
def _draw_attachment(self):
"""
Return an SVG group containing a line element and "Attachment" label.
@@ -418,6 +451,9 @@ class CableTraceSVG:
"""
Return an SVG document representing a cable trace.
"""
+ from dcim.models import Cable
+ from wireless.models import WirelessLink
+
traced_path = self.origin.trace()
# Prep elements list
@@ -442,34 +478,50 @@ class CableTraceSVG:
parent_objects.append(parent_object)
# Near end termination
- termination = self._draw_box(
- width=self.width * .8,
- color=self._get_color(near_end),
- url=near_end.get_absolute_url(),
- labels=self._get_labels(near_end),
- y_indent=PADDING,
- radius=5
- )
- terminations.append(termination)
+ if near_end is not None:
+ termination = self._draw_box(
+ width=self.width * .8,
+ color=self._get_color(near_end),
+ url=near_end.get_absolute_url(),
+ labels=self._get_labels(near_end),
+ y_indent=PADDING,
+ radius=5
+ )
+ terminations.append(termination)
- # Connector (either a Cable or attachment to a ProviderNetwork)
+ # Connector (a Cable or WirelessLink)
if connector is not None:
# Cable
- cable_labels = [
- f'Cable {connector}',
- connector.get_status_display()
- ]
- if connector.type:
- cable_labels.append(connector.get_type_display())
- if connector.length and connector.length_unit:
- cable_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
- cable = self._draw_cable(
- color=connector.color or '000000',
- url=connector.get_absolute_url(),
- labels=cable_labels
- )
- connectors.append(cable)
+ if type(connector) is Cable:
+ connector_labels = [
+ f'Cable {connector}',
+ connector.get_status_display()
+ ]
+ if connector.type:
+ connector_labels.append(connector.get_type_display())
+ if connector.length and connector.length_unit:
+ connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
+ cable = self._draw_cable(
+ color=connector.color or '000000',
+ url=connector.get_absolute_url(),
+ labels=connector_labels
+ )
+ connectors.append(cable)
+
+ # WirelessLink
+ elif type(connector) is WirelessLink:
+ connector_labels = [
+ f'Wireless link {connector}',
+ connector.get_status_display()
+ ]
+ if connector.ssid:
+ connector_labels.append(connector.ssid)
+ wirelesslink = self._draw_wirelesslink(
+ url=connector.get_absolute_url(),
+ labels=connector_labels
+ )
+ connectors.append(wirelesslink)
# Far end termination
termination = self._draw_box(
diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py
index 5533c4528..9b912894b 100644
--- a/netbox/dcim/tables/cables.py
+++ b/netbox/dcim/tables/cables.py
@@ -2,6 +2,7 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import Cable
+from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
@@ -41,6 +42,7 @@ class CableTable(BaseTable):
verbose_name='Termination B'
)
status = ChoiceFieldColumn()
+ tenant = TenantColumn()
length = TemplateColumn(
template_code=CABLE_LENGTH,
order_by='_abs_length'
@@ -54,7 +56,7 @@ class CableTable(BaseTable):
model = Cable
fields = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
- 'status', 'type', 'color', 'length', 'tags',
+ 'status', 'type', 'tenant', 'color', 'length', 'tags',
)
default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 675f7d777..f0e9c9bb0 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -1,6 +1,5 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
-from django.conf import settings
from dcim.models import (
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform,
@@ -11,14 +10,11 @@ from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn,
)
-from .template_code import (
- CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
- FRONTPORT_BUTTONS, INTERFACE_BUTTONS, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, POWEROUTLET_BUTTONS,
- POWERPORT_BUTTONS, REARPORT_BUTTONS,
-)
+from .template_code import *
__all__ = (
'BaseInterfaceTable',
+ 'CableTerminationTable',
'ConsolePortTable',
'ConsoleServerPortTable',
'DeviceBayTable',
@@ -53,6 +49,14 @@ def get_cabletermination_row_class(record):
return ''
+def get_interface_row_class(record):
+ if not record.enabled:
+ return 'danger'
+ elif record.is_virtual:
+ return 'primary'
+ return get_cabletermination_row_class(record)
+
+
def get_interface_state_attribute(record):
"""
Get interface enabled state as string to attach to
- {% include 'inc/custom_fields_panel.html' %}
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:rack_list' %}
- {% include 'inc/comments_panel.html' %}
+ {% include 'inc/panels/custom_fields.html' %}
+ {% include 'inc/panels/tags.html' %}
+ {% include 'inc/panels/comments.html' %}
{% if power_feeds %}
@@ -206,7 +206,7 @@
{% endif %}
- {% include 'inc/image_attachments_panel.html' %}
+ {% include 'inc/panels/image_attachments.html' %}
Reservations
@@ -332,6 +332,7 @@
{% endif %}
+ {% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html
index 9d1b4deea..1e16af675 100644
--- a/netbox/templates/dcim/rackreservation.html
+++ b/netbox/templates/dcim/rackreservation.html
@@ -83,8 +83,8 @@
- {% include 'inc/custom_fields_panel.html' %}
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:rackreservation_list' %}
+ {% include 'inc/panels/custom_fields.html' %}
+ {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
+ {# Legacy contact fields #}
+ {% with deprecation_warning="This field will be removed in a future release. Please migrate this data to contact objects." %}
+ {% if object.contact_name %}
+
diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/panels/custom_fields.html
similarity index 81%
rename from netbox/templates/inc/custom_fields_panel.html
rename to netbox/templates/inc/panels/custom_fields.html
index 91fca103e..b979cc073 100644
--- a/netbox/templates/inc/custom_fields_panel.html
+++ b/netbox/templates/inc/panels/custom_fields.html
@@ -1,3 +1,5 @@
+{% load helpers %}
+
{% with custom_fields=object.get_custom_fields %}
{% if custom_fields %}
@@ -10,12 +12,16 @@
{{ field }}
- {% if field.type == 'boolean' and value == True %}
+ {% if field.type == 'longtext' and value %}
+ {{ value|render_markdown }}
+ {% elif field.type == 'boolean' and value == True %}
{% elif field.type == 'boolean' and value == False %}
{% elif field.type == 'url' and value %}
{{ value|truncatechars:70 }}
+ {% elif field.type == 'json' and value %}
+
{{ value|render_json }}
{% elif field.type == 'multiselect' and value %}
{{ value|join:", " }}
{% elif value is not None %}
diff --git a/netbox/templates/inc/image_attachments_panel.html b/netbox/templates/inc/panels/image_attachments.html
similarity index 94%
rename from netbox/templates/inc/image_attachments_panel.html
rename to netbox/templates/inc/panels/image_attachments.html
index ca7312901..9706a7ffe 100644
--- a/netbox/templates/inc/image_attachments_panel.html
+++ b/netbox/templates/inc/panels/image_attachments.html
@@ -44,7 +44,7 @@
{% if perms.extras.add_imageattachment %}
diff --git a/netbox/templates/inc/panels/tags.html b/netbox/templates/inc/panels/tags.html
new file mode 100644
index 000000000..c309afdf0
--- /dev/null
+++ b/netbox/templates/inc/panels/tags.html
@@ -0,0 +1,14 @@
+{% load helpers %}
+
+
+
Tags
+
+ {% with url=object|validated_viewname:"list" %}
+ {% for tag in object.tags.all %}
+ {% tag tag url %}
+ {% empty %}
+ No tags assigned
+ {% endfor %}
+ {% endwith %}
+