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: + +![Cable path](../media/models/dcim_cable_trace.png) + +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: - -![Cable path](../media/models/dcim_cable_trace.png) - -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 DOM element. @@ -84,11 +88,17 @@ class DeviceRoleTable(BaseTable): ) color = ColorColumn() vm_role = BooleanColumn() + tags = TagColumn( + url_name='dcim:devicerole_list' + ) actions = ButtonsColumn(DeviceRole) class Meta(BaseTable.Meta): model = DeviceRole - fields = ('pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions') + fields = ( + 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', + 'actions', + ) default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions') @@ -111,13 +121,16 @@ class PlatformTable(BaseTable): url_params={'platform_id': 'pk'}, verbose_name='VMs' ) + tags = TagColumn( + url_name='dcim:platform_list' + ) actions = ButtonsColumn(Platform) class Meta(BaseTable.Meta): model = Platform fields = ( 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', - 'description', 'actions', + 'description', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', @@ -156,18 +169,11 @@ class DeviceTable(BaseTable): linkify=True, verbose_name='Type' ) - if settings.PREFER_IPV4: - primary_ip = tables.Column( - linkify=True, - order_by=('primary_ip4', 'primary_ip6'), - verbose_name='IP Address' - ) - else: - primary_ip = tables.Column( - linkify=True, - order_by=('primary_ip6', 'primary_ip4'), - verbose_name='IP Address' - ) + primary_ip = tables.Column( + linkify=True, + order_by=('primary_ip4', 'primary_ip6'), + verbose_name='IP Address' + ) primary_ip4 = tables.Column( linkify=True, verbose_name='IPv4 Address' @@ -197,8 +203,8 @@ class DeviceTable(BaseTable): model = Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', - 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', + 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4', + 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', @@ -258,11 +264,11 @@ class CableTerminationTable(BaseTable): orderable=False, verbose_name='Cable Color' ) - cable_peer = TemplateColumn( - accessor='_cable_peer', - template_code=CABLETERMINATION, + link_peer = TemplateColumn( + accessor='_link_peer', + template_code=LINKTERMINATION, orderable=False, - verbose_name='Cable Peer' + verbose_name='Link Peer' ) mark_connected = BooleanColumn() @@ -270,7 +276,7 @@ class CableTerminationTable(BaseTable): class PathEndpointTable(CableTerminationTable): connection = TemplateColumn( accessor='_path.last_node', - template_code=CABLETERMINATION, + template_code=LINKTERMINATION, verbose_name='Connection', orderable=False ) @@ -291,7 +297,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable): model = ConsolePort fields = ( 'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'connection', 'tags', + 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -312,7 +318,7 @@ class DeviceConsolePortTable(ConsolePortTable): model = ConsolePort fields = ( 'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'connection', 'tags', 'actions' + 'link_peer', 'connection', 'tags', 'actions' ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { @@ -334,8 +340,8 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'connection', 'tags', + 'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', + 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -357,7 +363,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): model = ConsoleServerPort fields = ( 'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'connection', 'tags', 'actions', + 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { @@ -379,8 +385,8 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw', - 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', + 'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', + 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -401,8 +407,8 @@ class DevicePowerPortTable(PowerPortTable): class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'connection', 'tags', 'actions', + 'pk', 'id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', + 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', @@ -430,8 +436,8 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = PowerOutlet fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'connection', 'tags', + 'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', + 'cable', 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -452,7 +458,7 @@ class DevicePowerOutletTable(PowerOutletTable): model = PowerOutlet fields = ( 'pk', 'id', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'connection', 'tags', 'actions', + 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', @@ -469,6 +475,12 @@ class BaseInterfaceTable(BaseTable): orderable=False, verbose_name='IP Addresses' ) + fhrp_groups = tables.TemplateColumn( + accessor=Accessor('fhrp_group_assignments'), + template_code=INTERFACE_FHRPGROUPS, + orderable=False, + verbose_name='FHRP Groups' + ) untagged_vlan = tables.Column(linkify=True) tagged_vlans = TemplateColumn( template_code=INTERFACE_TAGGED_VLANS, @@ -485,6 +497,14 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable } ) mgmt_only = BooleanColumn() + wireless_link = tables.Column( + linkify=True + ) + wireless_lans = TemplateColumn( + template_code=INTERFACE_WIRELESS_LANS, + orderable=False, + verbose_name='Wireless LANs' + ) tags = TagColumn( url_name='dcim:interface_list' ) @@ -492,24 +512,27 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', - 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', - 'untagged_vlan', 'tagged_vlans', + 'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', + 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', + 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', + 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') class DeviceInterfaceTable(InterfaceTable): name = tables.TemplateColumn( - template_code=' {{ value }}', order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) parent = tables.Column( - linkify=True, - verbose_name='Parent' + linkify=True + ) + bridge = tables.Column( + linkify=True ) lag = tables.Column( linkify=True, @@ -524,9 +547,10 @@ class DeviceInterfaceTable(InterfaceTable): class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'id', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', - 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', - 'untagged_vlan', 'tagged_vlans', 'actions', + 'pk', 'id', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', + 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', + 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', ) order_by = ('name',) default_columns = ( @@ -534,7 +558,7 @@ class DeviceInterfaceTable(InterfaceTable): 'cable', 'connection', 'actions', ) row_attrs = { - 'class': get_cabletermination_row_class, + 'class': get_interface_row_class, 'data-name': lambda record: record.name, 'data-enabled': get_interface_state_attribute, } @@ -562,7 +586,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable): model = FrontPort fields = ( 'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', - 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'tags', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', ) default_columns = ( 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', @@ -586,10 +610,10 @@ class DeviceFrontPortTable(FrontPortTable): model = FrontPort fields = ( 'pk', 'id', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'tags', 'actions', + 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer', + 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer', 'actions', ) row_attrs = { @@ -613,7 +637,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable): model = RearPort fields = ( 'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'tags', + 'cable_color', 'link_peer', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description') @@ -635,10 +659,10 @@ class DeviceRearPortTable(RearPortTable): model = RearPort fields = ( 'pk', 'id', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'tags', 'actions', + 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions', + 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions', ) row_attrs = { 'class': get_cabletermination_row_class @@ -653,7 +677,8 @@ class DeviceBayTable(DeviceComponentTable): } ) status = tables.TemplateColumn( - template_code=DEVICEBAY_STATUS + template_code=DEVICEBAY_STATUS, + order_by=Accessor('installed_device__status') ) installed_device = tables.Column( linkify=True diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index d176d3ff6..f932b7994 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -41,12 +41,16 @@ class ManufacturerTable(BaseTable): verbose_name='Platforms' ) slug = tables.Column() + tags = TagColumn( + url_name='dcim:manufacturer_list' + ) actions = ButtonsColumn(Manufacturer) class Meta(BaseTable.Meta): model = Manufacturer fields = ( - 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', + 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', + 'actions', ) default_columns = ( 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', @@ -80,7 +84,7 @@ class DeviceTypeTable(BaseTable): model = DeviceType fields = ( 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', - 'comments', 'instance_count', 'tags', + 'airflow', 'comments', 'instance_count', 'tags', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index 955b27941..ac58b64de 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -71,10 +71,10 @@ class PowerFeedTable(CableTerminationTable): model = PowerFeed fields = ( 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', - 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power', + 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power', 'comments', 'tags', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', - 'cable_peer', + 'link_peer', ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index f3d1cb7f8..30c560d88 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -24,11 +24,14 @@ class RackRoleTable(BaseTable): name = tables.Column(linkify=True) rack_count = tables.Column(verbose_name='Racks') color = ColorColumn() + tags = TagColumn( + url_name='dcim:rackrole_list' + ) actions = ButtonsColumn(RackRole) class Meta(BaseTable.Meta): model = RackRole - fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'actions') + fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions') default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') @@ -72,12 +75,20 @@ class RackTable(BaseTable): tags = TagColumn( url_name='dcim:rack_list' ) + outer_width = tables.TemplateColumn( + template_code="{{ record.outer_width }} {{ record.outer_unit }}", + verbose_name='Outer Width' + ) + outer_depth = tables.TemplateColumn( + template_code="{{ record.outer_depth }} {{ record.outer_unit }}", + verbose_name='Outer Depth' + ) class Meta(BaseTable.Meta): model = Rack fields = ( 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', - 'width', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', + 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 56180236d..8ef17c6f2 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -29,11 +29,14 @@ class RegionTable(BaseTable): url_params={'region_id': 'pk'}, verbose_name='Sites' ) + tags = TagColumn( + url_name='dcim:region_list' + ) actions = ButtonsColumn(Region) class Meta(BaseTable.Meta): model = Region - fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'actions') + fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'site_count', 'description', 'actions') @@ -51,11 +54,14 @@ class SiteGroupTable(BaseTable): url_params={'group_id': 'pk'}, verbose_name='Sites' ) + tags = TagColumn( + url_name='dcim:sitegroup_list' + ) actions = ButtonsColumn(SiteGroup) class Meta(BaseTable.Meta): model = SiteGroup - fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'actions') + fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'site_count', 'description', 'actions') @@ -75,6 +81,12 @@ class SiteTable(BaseTable): group = tables.Column( linkify=True ) + asn_count = LinkedCountColumn( + accessor=tables.A('asns.count'), + viewname='ipam:asn_list', + url_params={'site_id': 'pk'}, + verbose_name='ASNs' + ) tenant = TenantColumn() comments = MarkdownColumn() tags = TagColumn( @@ -84,11 +96,11 @@ class SiteTable(BaseTable): class Meta(BaseTable.Meta): model = Site fields = ( - 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'comments', 'tags', + 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', + 'contact_phone', 'contact_email', 'comments', 'tags', ) - default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description') + default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description') # @@ -103,6 +115,7 @@ class LocationTable(BaseTable): site = tables.Column( linkify=True ) + tenant = TenantColumn() rack_count = LinkedCountColumn( viewname='dcim:rack_list', url_params={'location_id': 'pk'}, @@ -113,6 +126,9 @@ class LocationTable(BaseTable): url_params={'location_id': 'pk'}, verbose_name='Devices' ) + tags = TagColumn( + url_name='dcim:location_list' + ) actions = ButtonsColumn( model=Location, prepend_template=LOCATION_ELEVATIONS @@ -120,5 +136,8 @@ class LocationTable(BaseTable): class Meta(BaseTable.Meta): model = Location - fields = ('pk', 'id', 'name', 'site', 'rack_count', 'device_count', 'description', 'slug', 'actions') - default_columns = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'actions') + fields = ( + 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags', + 'actions', + ) + default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions') diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 2f359e1b9..ccca32be8 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -1,4 +1,4 @@ -CABLETERMINATION = """ +LINKTERMINATION = """ {% if value %} {% if value.parent_object %} {{ value.parent_object }} @@ -40,17 +40,21 @@ DEVICEBAY_STATUS = """ INTERFACE_IPADDRESSES = """
- {% for ip in record.ip_addresses.all %} - - {{ ip }} - - {% endfor %} + {% for ip in record.ip_addresses.all %} + {% if ip.status != 'active' %} + {{ ip }} + {% else %} + {{ ip }} + {% endif %} + {% endfor %} +
+""" + +INTERFACE_FHRPGROUPS = """ +
+ {% for assignment in value.all %} + {{ assignment.group.get_protocol_display }}: {{ assignment.group.group_id }} + {% endfor %}
""" @@ -64,6 +68,12 @@ INTERFACE_TAGGED_VLANS = """ {% endif %} """ +INTERFACE_WIRELESS_LANS = """ +{% for wlan in record.wireless_lans.all %} + {{ wlan }}
+{% endfor %} +""" + POWERFEED_CABLE = """ {{ value }} @@ -195,15 +205,23 @@ INTERFACE_BUTTONS = """ {% endif %} -{% if record.cable %} +{% if record.link %} +{% endif %} +{% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} {% if perms.dcim.delete_cable %} {% endif %} -{% elif record.is_connectable and perms.dcim.add_cable %} +{% elif record.wireless_link %} + {% if perms.wireless.delete_wirelesslink %} + + + + {% endif %} +{% elif record.is_wired and perms.dcim.add_cable %} {% if not record.mark_connected %} @@ -221,6 +239,10 @@ INTERFACE_BUTTONS = """ {% else %} {% endif %} +{% elif record.is_wireless and perms.wireless.add_wirelesslink %} + + + {% endif %} """ diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index e5977b760..bc6b18ead 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -6,9 +6,10 @@ from rest_framework import status from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import VLAN +from ipam.models import ASN, RIR, VLAN from utilities.testing import APITestCase, APIViewTestCases from virtualization.models import Cluster, ClusterType +from wireless.models import WirelessLAN class AppTest(APITestCase): @@ -143,6 +144,13 @@ class SiteTest(APIViewTestCases.APIViewTestCase): ) Site.objects.bulk_create(sites) + rir = RIR.objects.create(name='RFC 6996', is_private=True) + + asns = [ + ASN(asn=65000 + i, rir=rir) for i in range(8) + ] + ASN.objects.bulk_create(asns) + cls.create_data = [ { 'name': 'Site 4', @@ -150,6 +158,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase): 'region': regions[1].pk, 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, + 'asns': [asns[0].pk, asns[1].pk], }, { 'name': 'Site 5', @@ -157,6 +166,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase): 'region': regions[1].pk, 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, + 'asns': [asns[2].pk, asns[3].pk], }, { 'name': 'Site 6', @@ -164,6 +174,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase): 'region': regions[1].pk, 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, + 'asns': [asns[4].pk, asns[5].pk], }, ] @@ -584,6 +595,12 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + power_port_templates = ( + PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'), + PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'), + ) + PowerPortTemplate.objects.bulk_create(power_port_templates) + power_outlet_templates = ( PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'), PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'), @@ -595,14 +612,17 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): { 'device_type': devicetype.pk, 'name': 'Power Outlet Template 4', + 'power_port': power_port_templates[0].pk, }, { 'device_type': devicetype.pk, 'name': 'Power Outlet Template 5', + 'power_port': power_port_templates[1].pk, }, { 'device_type': devicetype.pk, 'name': 'Power Outlet Template 6', + 'power_port': None, }, ] @@ -1033,14 +1053,17 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa { 'device': device.pk, 'name': 'Console Port 4', + 'speed': 9600, }, { 'device': device.pk, 'name': 'Console Port 5', + 'speed': 115200, }, { 'device': device.pk, 'name': 'Console Port 6', + 'speed': None, }, ] @@ -1072,14 +1095,17 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView { 'device': device.pk, 'name': 'Console Server Port 4', + 'speed': 9600, }, { 'device': device.pk, 'name': 'Console Server Port 5', + 'speed': 115200, }, { 'device': device.pk, 'name': 'Console Server Port 6', + 'speed': None, }, ] @@ -1139,6 +1165,12 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000') device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site) + power_ports = ( + PowerPort(device=device, name='Power Port 1'), + PowerPort(device=device, name='Power Port 2'), + ) + PowerPort.objects.bulk_create(power_ports) + power_outlets = ( PowerOutlet(device=device, name='Power Outlet 1'), PowerOutlet(device=device, name='Power Outlet 2'), @@ -1150,14 +1182,17 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa { 'device': device.pk, 'name': 'Power Outlet 4', + 'power_port': power_ports[0].pk, }, { 'device': device.pk, 'name': 'Power Outlet 5', + 'power_port': power_ports[1].pk, }, { 'device': device.pk, 'name': 'Power Outlet 6', + 'power_port': None, }, ] @@ -1192,31 +1227,44 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase ) VLAN.objects.bulk_create(vlans) + wireless_lans = ( + WirelessLAN(ssid='WLAN1'), + WirelessLAN(ssid='WLAN2'), + ) + WirelessLAN.objects.bulk_create(wireless_lans) + cls.create_data = [ { 'device': device.pk, 'name': 'Interface 4', 'type': '1000base-t', 'mode': InterfaceModeChoices.MODE_TAGGED, + 'tx_power': 10, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, + 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], }, { 'device': device.pk, 'name': 'Interface 5', 'type': '1000base-t', 'mode': InterfaceModeChoices.MODE_TAGGED, + 'bridge': interfaces[0].pk, + 'tx_power': 10, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, + 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], }, { 'device': device.pk, 'name': 'Interface 6', 'type': 'virtual', 'mode': InterfaceModeChoices.MODE_TAGGED, - 'parent': interfaces[0].pk, + 'parent': interfaces[1].pk, + 'tx_power': 10, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, + 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], }, ] @@ -1524,7 +1572,7 @@ class ConnectedDeviceTest(APITestCase): class VirtualChassisTest(APIViewTestCases.APIViewTestCase): model = VirtualChassis - brief_fields = ['id', 'master', 'member_count', 'name', 'url'] + brief_fields = ['display', 'id', 'master', 'member_count', 'name', 'url'] @classmethod def setUpTestData(cls): diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index c0fc89f83..6849df012 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -2,7 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from circuits.models import * -from dcim.choices import CableStatusChoices +from dcim.choices import LinkStatusChoices from dcim.models import * from dcim.utils import object_to_path_node @@ -1142,7 +1142,7 @@ class CablePathTestCase(TestCase): self.assertEqual(CablePath.objects.count(), 2) # Change cable 2's status to "planned" - cable2.status = CableStatusChoices.STATUS_PLANNED + cable2.status = LinkStatusChoices.STATUS_PLANNED cable2.save() self.assertPathExists( origin=interface1, @@ -1160,7 +1160,7 @@ class CablePathTestCase(TestCase): # Change cable 2's status to "connected" cable2 = Cable.objects.get(pk=cable2.pk) - cable2.status = CableStatusChoices.STATUS_CONNECTED + cable2.status = LinkStatusChoices.STATUS_CONNECTED cable2.save() self.assertPathExists( origin=interface1, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index fb94bde08..6bca25d50 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4,11 +4,12 @@ from django.test import TestCase from dcim.choices import * from dcim.filtersets import * from dcim.models import * -from ipam.models import IPAddress +from ipam.models import ASN, IPAddress, RIR from tenancy.models import Tenant, TenantGroup from utilities.choices import ColorChoices from utilities.testing import ChangeLoggedFilterSetTests from virtualization.models import Cluster, ClusterType +from wireless.choices import WirelessChannelChoices, WirelessRoleChoices class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): @@ -141,12 +142,23 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): ) Tenant.objects.bulk_create(tenants) + rir = RIR.objects.create(name='RFC 6996', is_private=True) + asns = ( + ASN(asn=64512, rir=rir, tenant=tenants[0]), + ASN(asn=64513, rir=rir, tenant=tenants[0]), + ASN(asn=64514, rir=rir, tenant=tenants[0]), + ) + ASN.objects.bulk_create(asns) + sites = ( Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'), Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'), ) Site.objects.bulk_create(sites) + sites[0].asns.set([asns[0]]) + sites[1].asns.set([asns[1]]) + sites[2].asns.set([asns[2]]) def test_name(self): params = {'name': ['Site 1', 'Site 2']} @@ -164,6 +176,11 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'asn': [65001, 65002]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_asn_id(self): + asns = ASN.objects.all()[:2] + params = {'asn_id': [asns[0].pk, asns[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_latitude(self): params = {'latitude': [10, 20]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -638,8 +655,8 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): device_types = ( DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True), - DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT), - DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD), + DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR), + DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT), ) DeviceType.objects.bulk_create(device_types) @@ -704,6 +721,10 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'subdevice_role': SubdeviceRoleChoices.ROLE_PARENT} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_airflow(self): + params = {'airflow': DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_manufacturer(self): manufacturers = Manufacturer.objects.all()[:2] params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]} @@ -1235,8 +1256,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): devices = ( Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}), - Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]), - Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]), ) Device.objects.bulk_create(devices) @@ -1390,6 +1411,10 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'is_full_depth': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_airflow(self): + params = {'airflow': DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_mac_address(self): params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2048,13 +2073,20 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) Device.objects.bulk_create(devices) + # VirtualChassis assignment for filtering + virtual_chassis = VirtualChassis.objects.create(master=devices[0]) + Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1) + Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2) + interfaces = ( Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'), Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'), Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third'), - Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), - Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), - Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False), + Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40), + Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40), + Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40), + Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22), + Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20), ) Interface.objects.bulk_create(interfaces) @@ -2075,11 +2107,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_enabled(self): params = {'enabled': 'true'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'enabled': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2091,7 +2123,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mgmt_only': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'mgmt_only': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_mode(self): params = {'mode': InterfaceModeChoices.MODE_ACCESS} @@ -2114,6 +2146,19 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'parent_id': [parent_interface.pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_bridge(self): + # Create bridged interfaces + bridge_interface = Interface.objects.first() + bridged_interfaces = ( + Interface(device=bridge_interface.device, name='Bridged 1', bridge=bridge_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=bridge_interface.device, name='Bridged 2', bridge=bridge_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=bridge_interface.device, name='Bridged 3', bridge=bridge_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + ) + Interface.objects.bulk_create(bridged_interfaces) + + params = {'bridge_id': [bridge_interface.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_lag(self): # Create LAG members device = Device.objects.first() @@ -2157,6 +2202,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'location': [locations[0].slug, locations[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_virtual_chassis_id(self): + params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_device(self): devices = Device.objects.all()[:2] params = {'device_id': [devices[0].pk, devices[1].pk]} @@ -2168,7 +2217,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'cabled': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'cabled': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_kind(self): params = {'kind': 'physical'} @@ -2184,6 +2233,26 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_rf_role(self): + params = {'rf_role': [WirelessRoleChoices.ROLE_AP, WirelessRoleChoices.ROLE_STATION]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rf_channel(self): + params = {'rf_channel': [WirelessChannelChoices.CHANNEL_24G_1, WirelessChannelChoices.CHANNEL_5G_32]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rf_channel_frequency(self): + params = {'rf_channel_frequency': [2412, 5160]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rf_channel_width(self): + params = {'rf_channel_width': [22, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_tx_power(self): + params = {'tx_power': [40]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = FrontPort.objects.all() @@ -2811,6 +2880,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): tenants = ( Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), ) Tenant.objects.bulk_create(tenants) @@ -2826,9 +2896,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1, tenant=tenants[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2, tenant=tenants[0]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1, tenant=tenants[1]), + Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1), + Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2), + Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1), Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2), Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1), Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2), @@ -2855,12 +2925,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1') # Cables - Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() - Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() + Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() + Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save() def test_label(self): @@ -2880,9 +2950,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_status(self): - params = {'status': [CableStatusChoices.STATUS_CONNECTED]} + params = {'status': [LinkStatusChoices.STATUS_CONNECTED]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'status': [CableStatusChoices.STATUS_PLANNED]} + params = {'status': [LinkStatusChoices.STATUS_PLANNED]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) def test_color(self): @@ -2913,9 +2983,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): def test_tenant(self): tenant = Tenant.objects.all()[:2] params = {'tenant_id': [tenant[0].pk, tenant[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'tenant': [tenant[0].slug, tenant[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_termination_types(self): params = {'termination_a_type': 'dcim.consoleport'} diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index ae280365e..1042057de 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -494,9 +494,9 @@ class CableTestCase(TestCase): interface1 = Interface.objects.get(pk=self.interface1.pk) interface2 = Interface.objects.get(pk=self.interface2.pk) self.assertEqual(self.cable.termination_a, interface1) - self.assertEqual(interface1._cable_peer, interface2) + self.assertEqual(interface1._link_peer, interface2) self.assertEqual(self.cable.termination_b, interface2) - self.assertEqual(interface2._cable_peer, interface1) + self.assertEqual(interface2._link_peer, interface1) def test_cable_deletion(self): """ @@ -508,10 +508,10 @@ class CableTestCase(TestCase): self.assertNotEqual(str(self.cable), '#None') interface1 = Interface.objects.get(pk=self.interface1.pk) self.assertIsNone(interface1.cable) - self.assertIsNone(interface1._cable_peer) + self.assertIsNone(interface1._link_peer) interface2 = Interface.objects.get(pk=self.interface2.pk) self.assertIsNone(interface2.cable) - self.assertIsNone(interface2._cable_peer) + self.assertIsNone(interface2._link_peer) def test_cabletermination_deletion(self): """ diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 18eaeec3b..154ea03c3 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -11,8 +11,10 @@ from netaddr import EUI from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import VLAN +from ipam.models import ASN, RIR, VLAN +from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device +from wireless.models import WirelessLAN class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @@ -30,11 +32,14 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for region in regions: region.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Region X', 'slug': 'region-x', 'parent': regions[2].pk, 'description': 'A new region', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -64,11 +69,14 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for sitegroup in sitegroups: sitegroup.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Site Group X', 'slug': 'site-group-x', 'parent': sitegroups[2].pk, 'description': 'A new site group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -103,11 +111,21 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): for group in groups: group.save() - Site.objects.bulk_create([ + rir = RIR.objects.create(name='RFC 6996', is_private=True) + + asns = [ + ASN(asn=65000 + i, rir=rir) for i in range(8) + ] + ASN.objects.bulk_create(asns) + + sites = Site.objects.bulk_create([ Site(name='Site 1', slug='site-1', region=regions[0], group=groups[1]), Site(name='Site 2', slug='site-2', region=regions[0], group=groups[1]), Site(name='Site 3', slug='site-3', region=regions[0], group=groups[1]), ]) + sites[0].asns.set([asns[0], asns[1]]) + sites[1].asns.set([asns[2], asns[3]]) + sites[2].asns.set([asns[4], asns[5]]) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -119,7 +137,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'group': groups[1].pk, 'tenant': None, 'facility': 'Facility X', - 'asn': 65001, + 'asns': [asns[6].pk, asns[7].pk], 'time_zone': pytz.UTC, 'description': 'Site description', 'physical_address': '742 Evergreen Terrace, Springfield, USA', @@ -145,7 +163,6 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'region': regions[1].pk, 'group': groups[1].pk, 'tenant': None, - 'asn': 65009, 'time_zone': pytz.timezone('US/Eastern'), 'description': 'New description', } @@ -157,29 +174,33 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - site = Site(name='Site 1', slug='site-1') - site.save() + site = Site.objects.create(name='Site 1', slug='site-1') + tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1') locations = ( - Location(name='Location 1', slug='location-1', site=site), - Location(name='Location 2', slug='location-2', site=site), - Location(name='Location 3', slug='location-3', site=site), + Location(name='Location 1', slug='location-1', site=site, tenant=tenant), + Location(name='Location 2', slug='location-2', site=site, tenant=tenant), + Location(name='Location 3', slug='location-3', site=site, tenant=tenant), ) for location in locations: location.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Location X', 'slug': 'location-x', 'site': site.pk, + 'tenant': tenant.pk, 'description': 'A new location', + 'tags': [t.pk for t in tags], } cls.csv_data = ( - "site,name,slug,description", - "Site 1,Location 4,location-4,Fourth location", - "Site 1,Location 5,location-5,Fifth location", - "Site 1,Location 6,location-6,Sixth location", + "site,tenant,name,slug,description", + "Site 1,Tenant 1,Location 4,location-4,Fourth location", + "Site 1,Tenant 1,Location 5,location-5,Fifth location", + "Site 1,Tenant 1,Location 6,location-6,Sixth location", ) cls.bulk_edit_data = { @@ -199,11 +220,14 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): RackRole(name='Rack Role 3', slug='rack-role-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Rack Role X', 'slug': 'rack-role-x', 'color': 'c0c0c0', 'description': 'New role', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -366,10 +390,13 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Manufacturer X', 'slug': 'manufacturer-x', 'description': 'A new manufacturer', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -433,6 +460,116 @@ class DeviceTypeTestCase( 'is_full_depth': False, } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_consoleports(self): + devicetype = DeviceType.objects.first() + console_ports = ( + ConsolePortTemplate(device_type=devicetype, name='Console Port 1'), + ConsolePortTemplate(device_type=devicetype, name='Console Port 2'), + ConsolePortTemplate(device_type=devicetype, name='Console Port 3'), + ) + ConsolePortTemplate.objects.bulk_create(console_ports) + + url = reverse('dcim:devicetype_consoleports', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_consoleserverports(self): + devicetype = DeviceType.objects.first() + console_server_ports = ( + ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port 1'), + ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port 2'), + ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port 3'), + ) + ConsoleServerPortTemplate.objects.bulk_create(console_server_ports) + + url = reverse('dcim:devicetype_consoleserverports', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_powerports(self): + devicetype = DeviceType.objects.first() + power_ports = ( + PowerPortTemplate(device_type=devicetype, name='Power Port 1'), + PowerPortTemplate(device_type=devicetype, name='Power Port 2'), + PowerPortTemplate(device_type=devicetype, name='Power Port 3'), + ) + PowerPortTemplate.objects.bulk_create(power_ports) + + url = reverse('dcim:devicetype_powerports', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_poweroutlets(self): + devicetype = DeviceType.objects.first() + power_outlets = ( + PowerOutletTemplate(device_type=devicetype, name='Power Outlet 1'), + PowerOutletTemplate(device_type=devicetype, name='Power Outlet 2'), + PowerOutletTemplate(device_type=devicetype, name='Power Outlet 3'), + ) + PowerOutletTemplate.objects.bulk_create(power_outlets) + + url = reverse('dcim:devicetype_poweroutlets', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_interfaces(self): + devicetype = DeviceType.objects.first() + interfaces = ( + InterfaceTemplate(device_type=devicetype, name='Interface 1'), + InterfaceTemplate(device_type=devicetype, name='Interface 2'), + InterfaceTemplate(device_type=devicetype, name='Interface 3'), + ) + InterfaceTemplate.objects.bulk_create(interfaces) + + url = reverse('dcim:devicetype_interfaces', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_rearports(self): + devicetype = DeviceType.objects.first() + rear_ports = ( + RearPortTemplate(device_type=devicetype, name='Rear Port 1'), + RearPortTemplate(device_type=devicetype, name='Rear Port 2'), + RearPortTemplate(device_type=devicetype, name='Rear Port 3'), + ) + RearPortTemplate.objects.bulk_create(rear_ports) + + url = reverse('dcim:devicetype_rearports', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_frontports(self): + devicetype = DeviceType.objects.first() + rear_ports = ( + RearPortTemplate(device_type=devicetype, name='Rear Port 1'), + RearPortTemplate(device_type=devicetype, name='Rear Port 2'), + RearPortTemplate(device_type=devicetype, name='Rear Port 3'), + ) + RearPortTemplate.objects.bulk_create(rear_ports) + front_ports = ( + FrontPortTemplate(device_type=devicetype, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1), + FrontPortTemplate(device_type=devicetype, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1), + FrontPortTemplate(device_type=devicetype, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1), + ) + FrontPortTemplate.objects.bulk_create(front_ports) + + url = reverse('dcim:devicetype_frontports', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_devicebays(self): + devicetype = DeviceType.objects.first() + device_bays = ( + DeviceBayTemplate(device_type=devicetype, name='Device Bay 1'), + DeviceBayTemplate(device_type=devicetype, name='Device Bay 2'), + DeviceBayTemplate(device_type=devicetype, name='Device Bay 3'), + ) + DeviceBayTemplate.objects.bulk_create(device_bays) + + url = reverse('dcim:devicetype_devicebays', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_import_objects(self): """ @@ -922,12 +1059,15 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): DeviceRole(name='Device Role 3', slug='device-role-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Devie Role X', 'slug': 'device-role-x', 'color': 'c0c0c0', 'vm_role': False, 'description': 'New device role', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -957,6 +1097,8 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Platform X', 'slug': 'platform-x', @@ -964,6 +1106,7 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'napalm_driver': 'junos', 'napalm_args': None, 'description': 'A new platform', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -1448,6 +1591,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): Interface(device=device, name='Interface 2'), Interface(device=device, name='Interface 3'), Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG), + Interface(device=device, name='_BRIDGE', type=InterfaceTypeChoices.TYPE_VIRTUAL), # Must be ordered last ) Interface.objects.bulk_create(interfaces) @@ -1459,22 +1603,31 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): ) VLAN.objects.bulk_create(vlans) + wireless_lans = ( + WirelessLAN(ssid='WLAN1'), + WirelessLAN(ssid='WLAN2'), + ) + WirelessLAN.objects.bulk_create(wireless_lans) + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, - 'virtual_machine': None, 'name': 'Interface X', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'enabled': False, + 'bridge': interfaces[4].pk, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), + 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 65000, 'mgmt_only': True, 'description': 'A front port', 'mode': InterfaceModeChoices.MODE_TAGGED, + 'tx_power': 10, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], 'tags': [t.pk for t in tags], } @@ -1483,14 +1636,17 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'name_pattern': 'Interface [4-6]', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'enabled': False, + 'bridge': interfaces[4].pk, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), + 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 2000, 'mgmt_only': True, 'description': 'A front port', 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], 'tags': [t.pk for t in tags], } @@ -1499,10 +1655,12 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'enabled': True, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), + 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 2000, 'mgmt_only': True, 'description': 'New description', 'mode': InterfaceModeChoices.MODE_TAGGED, + 'tx_power': 10, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], } @@ -1808,7 +1966,7 @@ class CableTestCase( 'termination_b_type': interface_ct.pk, 'termination_b_id': interfaces[3].pk, 'type': CableTypeChoices.TYPE_CAT6, - 'status': CableStatusChoices.STATUS_PLANNED, + 'status': LinkStatusChoices.STATUS_PLANNED, 'label': 'Label', 'color': 'c0c0c0', 'length': 100, @@ -1825,7 +1983,7 @@ class CableTestCase( cls.bulk_edit_data = { 'type': CableTypeChoices.TYPE_CAT5E, - 'status': CableStatusChoices.STATUS_CONNECTED, + 'status': LinkStatusChoices.STATUS_CONNECTED, 'label': 'New label', 'color': '00ff00', 'length': 50, diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 01e470e5c..dd81ca2ba 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -109,6 +109,14 @@ urlpatterns = [ path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), path('device-types//', views.DeviceTypeView.as_view(), name='devicetype'), + path('device-types//console-ports/', views.DeviceTypeConsolePortsView.as_view(), name='devicetype_consoleports'), + path('device-types//console-server-ports/', views.DeviceTypeConsoleServerPortsView.as_view(), name='devicetype_consoleserverports'), + path('device-types//power-ports/', views.DeviceTypePowerPortsView.as_view(), name='devicetype_powerports'), + path('device-types//power-outlets/', views.DeviceTypePowerOutletsView.as_view(), name='devicetype_poweroutlets'), + path('device-types//interfaces/', views.DeviceTypeInterfacesView.as_view(), name='devicetype_interfaces'), + path('device-types//front-ports/', views.DeviceTypeFrontPortsView.as_view(), name='devicetype_frontports'), + path('device-types//rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'), + path('device-types//device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'), path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), path('device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 91c5c7c77..ec3a44603 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from django.db import transaction def compile_path_node(ct_id, object_id): @@ -26,3 +27,29 @@ def path_node_to_object(repr): ct_id, object_id = decompile_path_node(repr) ct = ContentType.objects.get_for_id(ct_id) return ct.model_class().objects.get(pk=object_id) + + +def create_cablepath(node): + """ + Create CablePaths for all paths originating from the specified node. + """ + from dcim.models import CablePath + + cp = CablePath.from_origin(node) + if cp: + cp.save() + + +def rebuild_paths(obj): + """ + Rebuild all CablePaths which traverse the specified node + """ + from dcim.models import CablePath + + 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) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 16f88b9c3..b1a53e93c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -14,8 +14,8 @@ from django.views.generic import View from circuits.models import Circuit from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView -from ipam.models import IPAddress, Prefix, Service, VLAN -from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable +from ipam.models import ASN, IPAddress, Prefix, Service, VLAN +from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -36,6 +36,37 @@ from .models import ( ) +class DeviceComponentsView(generic.ObjectView): + queryset = Device.objects.all() + model = None + table = None + + def get_components(self, request, instance): + return self.model.objects.restrict(request.user, 'view').filter(device=instance) + + def get_extra_context(self, request, instance): + components = self.get_components(request, instance) + table = self.table(data=components, user=request.user) + change_perm = f'{self.model._meta.app_label}.change_{self.model._meta.model_name}' + delete_perm = f'{self.model._meta.app_label}.delete_{self.model._meta.model_name}' + if request.user.has_perm(change_perm) or request.user.has_perm(delete_perm): + table.columns.show('pk') + paginate_table(table, request) + + return { + 'table': table, + 'active_tab': f"{self.model._meta.verbose_name_plural.replace(' ', '-')}", + } + + +class DeviceTypeComponentsView(DeviceComponentsView): + queryset = DeviceType.objects.all() + template_name = 'dcim/devicetype/component_templates.html' + + def get_components(self, request, instance): + return self.model.objects.restrict(request.user, 'view').filter(device_type=instance) + + class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ An extendable view for disconnection console/power/interface components in bulk. @@ -126,6 +157,7 @@ class RegionView(generic.ObjectView): parent__in=instance.get_descendants(include_self=True) ) child_regions_table = tables.RegionTable(child_regions) + child_regions_table.columns.hide('actions') sites = Site.objects.restrict(request.user, 'view').filter( region=instance @@ -210,6 +242,7 @@ class SiteGroupView(generic.ObjectView): parent__in=instance.get_descendants(include_self=True) ) child_groups_table = tables.SiteGroupTable(child_groups) + child_groups_table.columns.hide('actions') sites = Site.objects.restrict(request.user, 'view').filter( group=instance @@ -279,6 +312,7 @@ class SiteView(generic.ObjectView): def get_extra_context(self, request, instance): stats = { + 'location_count': Location.objects.restrict(request.user, 'view').filter(site=instance).count(), 'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(), 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(), @@ -301,9 +335,15 @@ class SiteView(generic.ObjectView): cumulative=True ).restrict(request.user, 'view').filter(site=instance) + asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance) + asn_count = asns.count() + + stats.update({'asn_count': asn_count}) + return { 'stats': stats, 'locations': locations, + 'asns': asns, } @@ -759,62 +799,52 @@ class DeviceTypeView(generic.ObjectView): def get_extra_context(self, request, instance): instance_count = Device.objects.restrict(request.user).filter(device_type=instance).count() - # Component tables - consoleport_table = tables.ConsolePortTemplateTable( - ConsolePortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance), - orderable=False - ) - consoleserverport_table = tables.ConsoleServerPortTemplateTable( - ConsoleServerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance), - orderable=False - ) - powerport_table = tables.PowerPortTemplateTable( - PowerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance), - orderable=False - ) - poweroutlet_table = tables.PowerOutletTemplateTable( - PowerOutletTemplate.objects.restrict(request.user, 'view').filter(device_type=instance), - orderable=False - ) - interface_table = tables.InterfaceTemplateTable( - list(InterfaceTemplate.objects.restrict(request.user, 'view').filter(device_type=instance)), - orderable=False - ) - front_port_table = tables.FrontPortTemplateTable( - FrontPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance), - orderable=False - ) - rear_port_table = tables.RearPortTemplateTable( - RearPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance), - orderable=False - ) - devicebay_table = tables.DeviceBayTemplateTable( - DeviceBayTemplate.objects.restrict(request.user, 'view').filter(device_type=instance), - orderable=False - ) - if request.user.has_perm('dcim.change_devicetype'): - consoleport_table.columns.show('pk') - consoleserverport_table.columns.show('pk') - powerport_table.columns.show('pk') - poweroutlet_table.columns.show('pk') - interface_table.columns.show('pk') - front_port_table.columns.show('pk') - rear_port_table.columns.show('pk') - devicebay_table.columns.show('pk') - return { 'instance_count': instance_count, - 'consoleport_table': consoleport_table, - 'consoleserverport_table': consoleserverport_table, - 'powerport_table': powerport_table, - 'poweroutlet_table': poweroutlet_table, - 'interface_table': interface_table, - 'front_port_table': front_port_table, - 'rear_port_table': rear_port_table, - 'devicebay_table': devicebay_table, + 'active_tab': 'devicetype', } +class DeviceTypeConsolePortsView(DeviceTypeComponentsView): + model = ConsolePortTemplate + table = tables.ConsolePortTemplateTable + + +class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView): + model = ConsoleServerPortTemplate + table = tables.ConsoleServerPortTemplateTable + + +class DeviceTypePowerPortsView(DeviceTypeComponentsView): + model = PowerPortTemplate + table = tables.PowerPortTemplateTable + + +class DeviceTypePowerOutletsView(DeviceTypeComponentsView): + model = PowerOutletTemplate + table = tables.PowerOutletTemplateTable + + +class DeviceTypeInterfacesView(DeviceTypeComponentsView): + model = InterfaceTemplate + table = tables.InterfaceTemplateTable + + +class DeviceTypeFrontPortsView(DeviceTypeComponentsView): + model = FrontPortTemplate + table = tables.FrontPortTemplateTable + + +class DeviceTypeRearPortsView(DeviceTypeComponentsView): + model = RearPortTemplate + table = tables.RearPortTemplateTable + + +class DeviceTypeDeviceBaysView(DeviceTypeComponentsView): + model = DeviceBayTemplate + table = tables.DeviceBayTemplateTable + + class DeviceTypeEditView(generic.ObjectEditView): queryset = DeviceType.objects.all() model_form = forms.DeviceTypeForm @@ -1306,206 +1336,65 @@ class DeviceView(generic.ObjectView): } -class DeviceConsolePortsView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceConsolePortsView(DeviceComponentsView): + model = ConsolePort + table = tables.DeviceConsolePortTable template_name = 'dcim/device/consoleports.html' - def get_extra_context(self, request, instance): - consoleports = ConsolePort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related( - 'cable', '_path__destination', - ) - consoleport_table = tables.DeviceConsolePortTable( - data=consoleports, - user=request.user - ) - if request.user.has_perm('dcim.change_consoleport') or request.user.has_perm('dcim.delete_consoleport'): - consoleport_table.columns.show('pk') - paginate_table(consoleport_table, request) - return { - 'consoleport_table': consoleport_table, - 'active_tab': 'console-ports', - } - - -class DeviceConsoleServerPortsView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceConsoleServerPortsView(DeviceComponentsView): + model = ConsoleServerPort + table = tables.DeviceConsoleServerPortTable template_name = 'dcim/device/consoleserverports.html' - def get_extra_context(self, request, instance): - consoleserverports = ConsoleServerPort.objects.restrict(request.user, 'view').filter( - device=instance - ).prefetch_related( - 'cable', '_path__destination', - ) - consoleserverport_table = tables.DeviceConsoleServerPortTable( - data=consoleserverports, - user=request.user - ) - if request.user.has_perm('dcim.change_consoleserverport') or \ - request.user.has_perm('dcim.delete_consoleserverport'): - consoleserverport_table.columns.show('pk') - paginate_table(consoleserverport_table, request) - return { - 'consoleserverport_table': consoleserverport_table, - 'active_tab': 'console-server-ports', - } - - -class DevicePowerPortsView(generic.ObjectView): - queryset = Device.objects.all() +class DevicePowerPortsView(DeviceComponentsView): + model = PowerPort + table = tables.DevicePowerPortTable template_name = 'dcim/device/powerports.html' - def get_extra_context(self, request, instance): - powerports = PowerPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related( - 'cable', '_path__destination', - ) - powerport_table = tables.DevicePowerPortTable( - data=powerports, - user=request.user - ) - if request.user.has_perm('dcim.change_powerport') or request.user.has_perm('dcim.delete_powerport'): - powerport_table.columns.show('pk') - paginate_table(powerport_table, request) - return { - 'powerport_table': powerport_table, - 'active_tab': 'power-ports', - } - - -class DevicePowerOutletsView(generic.ObjectView): - queryset = Device.objects.all() +class DevicePowerOutletsView(DeviceComponentsView): + model = PowerOutlet + table = tables.DevicePowerOutletTable template_name = 'dcim/device/poweroutlets.html' - def get_extra_context(self, request, instance): - poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related( - 'cable', 'power_port', '_path__destination', - ) - poweroutlet_table = tables.DevicePowerOutletTable( - data=poweroutlets, - user=request.user - ) - if request.user.has_perm('dcim.change_poweroutlet') or request.user.has_perm('dcim.delete_poweroutlet'): - poweroutlet_table.columns.show('pk') - paginate_table(poweroutlet_table, request) - return { - 'poweroutlet_table': poweroutlet_table, - 'active_tab': 'power-outlets', - } - - -class DeviceInterfacesView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceInterfacesView(DeviceComponentsView): + model = Interface + table = tables.DeviceInterfaceTable template_name = 'dcim/device/interfaces.html' - def get_extra_context(self, request, instance): - interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( + def get_components(self, request, instance): + return instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), - Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), - 'lag', 'cable', '_path__destination', 'tags', + Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)) ) - interface_table = tables.DeviceInterfaceTable( - data=interfaces, - user=request.user - ) - if request.user.has_perm('dcim.change_interface') or request.user.has_perm('dcim.delete_interface'): - interface_table.columns.show('pk') - paginate_table(interface_table, request) - - return { - 'interface_table': interface_table, - 'active_tab': 'interfaces', - } -class DeviceFrontPortsView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceFrontPortsView(DeviceComponentsView): + model = FrontPort + table = tables.DeviceFrontPortTable template_name = 'dcim/device/frontports.html' - def get_extra_context(self, request, instance): - frontports = FrontPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related( - 'rear_port', 'cable', - ) - frontport_table = tables.DeviceFrontPortTable( - data=frontports, - user=request.user - ) - if request.user.has_perm('dcim.change_frontport') or request.user.has_perm('dcim.delete_frontport'): - frontport_table.columns.show('pk') - paginate_table(frontport_table, request) - return { - 'frontport_table': frontport_table, - 'active_tab': 'front-ports', - } - - -class DeviceRearPortsView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceRearPortsView(DeviceComponentsView): + model = RearPort + table = tables.DeviceRearPortTable template_name = 'dcim/device/rearports.html' - def get_extra_context(self, request, instance): - rearports = RearPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related('cable') - rearport_table = tables.DeviceRearPortTable( - data=rearports, - user=request.user - ) - if request.user.has_perm('dcim.change_rearport') or request.user.has_perm('dcim.delete_rearport'): - rearport_table.columns.show('pk') - paginate_table(rearport_table, request) - return { - 'rearport_table': rearport_table, - 'active_tab': 'rear-ports', - } - - -class DeviceDeviceBaysView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceDeviceBaysView(DeviceComponentsView): + model = DeviceBay + table = tables.DeviceDeviceBayTable template_name = 'dcim/device/devicebays.html' - def get_extra_context(self, request, instance): - devicebays = DeviceBay.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related( - 'installed_device__device_type__manufacturer', - ) - devicebay_table = tables.DeviceDeviceBayTable( - data=devicebays, - user=request.user - ) - if request.user.has_perm('dcim.change_devicebay') or request.user.has_perm('dcim.delete_devicebay'): - devicebay_table.columns.show('pk') - paginate_table(devicebay_table, request) - return { - 'devicebay_table': devicebay_table, - 'active_tab': 'device-bays', - } - - -class DeviceInventoryView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceInventoryView(DeviceComponentsView): + model = InventoryItem + table = tables.DeviceInventoryItemTable template_name = 'dcim/device/inventory.html' - def get_extra_context(self, request, instance): - inventoryitems = InventoryItem.objects.restrict(request.user, 'view').filter( - device=instance - ).prefetch_related('manufacturer') - inventoryitem_table = tables.DeviceInventoryItemTable( - data=inventoryitems, - user=request.user - ) - if request.user.has_perm('dcim.change_inventoryitem') or request.user.has_perm('dcim.delete_inventoryitem'): - inventoryitem_table.columns.show('pk') - paginate_table(inventoryitem_table, request) - - return { - 'inventoryitem_table': inventoryitem_table, - 'active_tab': 'inventory', - } - class DeviceStatusView(generic.ObjectView): additional_permissions = ['dcim.napalm_read_device'] @@ -1861,7 +1750,7 @@ class InterfaceView(generic.ObjectView): def get_extra_context(self, request, instance): # Get assigned IP addresses - ipaddress_table = InterfaceIPAddressTable( + ipaddress_table = AssignedIPAddressesTable( data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), orderable=False ) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index dae21c2c9..b6ee01db9 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,10 +1,131 @@ from django.contrib import admin +from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +from django.urls import path, reverse +from django.utils.html import format_html -from .models import JobResult +from netbox.config import get_config, PARAMS +from .forms import ConfigRevisionForm +from .models import ConfigRevision, JobResult + + +@admin.register(ConfigRevision) +class ConfigRevisionAdmin(admin.ModelAdmin): + fieldsets = [ + ('Rack Elevations', { + 'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'), + }), + ('IPAM', { + 'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'), + }), + ('Security', { + 'fields': ('ALLOWED_URL_SCHEMES',), + }), + ('Banners', { + 'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'), + }), + ('Pagination', { + 'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'), + }), + ('Validation', { + 'fields': ('CUSTOM_VALIDATORS',), + }), + ('NAPALM', { + 'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'), + }), + ('Miscellaneous', { + 'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'MAPS_URL'), + }), + ('Config Revision', { + 'fields': ('comment',), + }) + ] + form = ConfigRevisionForm + list_display = ('id', 'is_active', 'created', 'comment', 'restore_link') + ordering = ('-id',) + readonly_fields = ('data',) + + def get_changeform_initial_data(self, request): + """ + Populate initial form data from the most recent ConfigRevision. + """ + latest_revision = ConfigRevision.objects.last() + initial = latest_revision.data if latest_revision else {} + initial.update(super().get_changeform_initial_data(request)) + + return initial + + # Permissions + + def has_add_permission(self, request): + # Only superusers may modify the configuration. + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + # ConfigRevisions cannot be modified once created. + return False + + def has_delete_permission(self, request, obj=None): + # Only inactive ConfigRevisions may be deleted (must be superuser). + return request.user.is_superuser and ( + obj is None or not obj.is_active() + ) + + # List display methods + + def restore_link(self, obj): + if obj.is_active(): + return '' + return format_html( + 'Restore', + url=reverse('admin:extras_configrevision_restore', args=(obj.pk,)) + ) + restore_link.short_description = "Actions" + + # URLs + + def get_urls(self): + urls = [ + path('/restore/', self.admin_site.admin_view(self.restore), name='extras_configrevision_restore'), + ] + + return urls + super().get_urls() + + # Views + + def restore(self, request, pk): + # Get the ConfigRevision being restored + candidate_config = get_object_or_404(ConfigRevision, pk=pk) + + if request.method == 'POST': + candidate_config.activate() + self.message_user(request, f"Restored configuration revision #{pk}") + + return redirect(reverse('admin:extras_configrevision_changelist')) + + # Get the current ConfigRevision + config_version = get_config().version + current_config = ConfigRevision.objects.filter(pk=config_version).first() + + params = [] + for param in PARAMS: + params.append(( + param.name, + current_config.data.get(param.name, None), + candidate_config.data.get(param.name, None) + )) + + context = self.admin_site.each_context(request) + context.update({ + 'object': candidate_config, + 'params': params, + }) + + return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context) # -# Reports +# Reports & scripts # @admin.register(JobResult) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index b2049e836..89fd00929 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -61,7 +61,7 @@ class WebhookSerializer(ValidatedModelSerializer): fields = [ 'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', - 'ssl_verification', 'ca_file_path', + 'conditions', 'ssl_verification', 'ca_file_path', ] @@ -150,7 +150,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): model = ImageAttachment fields = [ 'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', - 'image_width', 'created', + 'image_width', 'created', 'last_updated', ] def validate(self, data): diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 4452b5aad..7503b4110 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -8,19 +8,23 @@ from utilities.choices import ChoiceSet class CustomFieldTypeChoices(ChoiceSet): TYPE_TEXT = 'text' + TYPE_LONGTEXT = 'longtext' TYPE_INTEGER = 'integer' TYPE_BOOLEAN = 'boolean' TYPE_DATE = 'date' TYPE_URL = 'url' + TYPE_JSON = 'json' TYPE_SELECT = 'select' TYPE_MULTISELECT = 'multiselect' CHOICES = ( (TYPE_TEXT, 'Text'), + (TYPE_LONGTEXT, 'Text (long)'), (TYPE_INTEGER, 'Integer'), (TYPE_BOOLEAN, 'Boolean (true/false)'), (TYPE_DATE, 'Date'), (TYPE_URL, 'URL'), + (TYPE_JSON, 'JSON'), (TYPE_SELECT, 'Selection'), (TYPE_MULTISELECT, 'Multiple selection'), ) diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py new file mode 100644 index 000000000..965488c3a --- /dev/null +++ b/netbox/extras/conditions.py @@ -0,0 +1,148 @@ +import functools +import re + +__all__ = ( + 'Condition', + 'ConditionSet', +) + + +AND = 'and' +OR = 'or' + + +def is_ruleset(data): + """ + Determine whether the given dictionary looks like a rule set. + """ + return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR) + + +class Condition: + """ + An individual conditional rule that evaluates a single attribute and its value. + + :param attr: The name of the attribute being evaluated + :param value: The value being compared + :param op: The logical operation to use when evaluating the value (default: 'eq') + """ + EQ = 'eq' + GT = 'gt' + GTE = 'gte' + LT = 'lt' + LTE = 'lte' + IN = 'in' + CONTAINS = 'contains' + REGEX = 'regex' + + OPERATORS = ( + EQ, GT, GTE, LT, LTE, IN, CONTAINS, REGEX + ) + + TYPES = { + str: (EQ, CONTAINS, REGEX), + bool: (EQ, CONTAINS), + int: (EQ, GT, GTE, LT, LTE, CONTAINS), + float: (EQ, GT, GTE, LT, LTE, CONTAINS), + list: (EQ, IN, CONTAINS) + } + + def __init__(self, attr, value, op=EQ, negate=False): + if op not in self.OPERATORS: + raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}") + if type(value) not in self.TYPES: + raise ValueError(f"Unsupported value type: {type(value)}") + if op not in self.TYPES[type(value)]: + raise ValueError(f"Invalid type for {op} operation: {type(value)}") + + self.attr = attr + self.value = value + self.eval_func = getattr(self, f'eval_{op}') + self.negate = negate + + def eval(self, data): + """ + Evaluate the provided data to determine whether it matches the condition. + """ + try: + value = functools.reduce(dict.get, self.attr.split('.'), data) + except TypeError: + # Invalid key path + value = None + result = self.eval_func(value) + + if self.negate: + return not result + return result + + # Equivalency + + def eval_eq(self, value): + return value == self.value + + def eval_neq(self, value): + return value != self.value + + # Numeric comparisons + + def eval_gt(self, value): + return value > self.value + + def eval_gte(self, value): + return value >= self.value + + def eval_lt(self, value): + return value < self.value + + def eval_lte(self, value): + return value <= self.value + + # Membership + + def eval_in(self, value): + return value in self.value + + def eval_contains(self, value): + return self.value in value + + # Regular expressions + + def eval_regex(self, value): + return re.match(self.value, value) is not None + + +class ConditionSet: + """ + A set of one or more Condition to be evaluated per the prescribed logic (AND or OR). Example: + + {"and": [ + {"attr": "foo", "op": "eq", "value": 1}, + {"attr": "bar", "op": "eq", "value": 2, "negate": true} + ]} + + :param ruleset: A dictionary mapping a logical operator to a list of conditional rules + """ + def __init__(self, ruleset): + if type(ruleset) is not dict: + raise ValueError(f"Ruleset must be a dictionary, not {type(ruleset)}.") + if len(ruleset) != 1: + raise ValueError(f"Ruleset must have exactly one logical operator (found {len(ruleset)})") + + # Determine the logic type + logic = list(ruleset.keys())[0] + if type(logic) is not str or logic.lower() not in (AND, OR): + raise ValueError(f"Invalid logic type: {logic} (must be '{AND}' or '{OR}')") + self.logic = logic.lower() + + # Compile the set of Conditions + self.conditions = [ + ConditionSet(rule) if is_ruleset(rule) else Condition(**rule) + for rule in ruleset[self.logic] + ] + + def eval(self, data): + """ + Evaluate the provided data to determine whether it matches this set of conditions. + """ + func = any if self.logic == 'or' else all + return func(d.eval(data) for d in self.conditions) diff --git a/netbox/extras/context_managers.py b/netbox/extras/context_managers.py index 66b5ff94d..9f73fe9c3 100644 --- a/netbox/extras/context_managers.py +++ b/netbox/extras/context_managers.py @@ -2,8 +2,9 @@ from contextlib import contextmanager from django.db.models.signals import m2m_changed, pre_delete, post_save -from extras.signals import clear_webhooks, _clear_webhook_queue, _handle_changed_object, _handle_deleted_object -from utilities.utils import curry +from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object +from netbox import thread_locals +from netbox.request_context import set_request from .webhooks import flush_webhooks @@ -15,12 +16,8 @@ def change_logging(request): :param request: WSGIRequest object with a unique `id` set """ - webhook_queue = [] - - # Curry signals receivers to pass the current request - handle_changed_object = curry(_handle_changed_object, request, webhook_queue) - handle_deleted_object = curry(_handle_deleted_object, request, webhook_queue) - clear_webhook_queue = curry(_clear_webhook_queue, webhook_queue) + set_request(request) + thread_locals.webhook_queue = [] # Connect our receivers to the post_save and post_delete signals. post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object') @@ -38,5 +35,8 @@ def change_logging(request): clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue') # Flush queued webhooks to RQ - flush_webhooks(webhook_queue) - del webhook_queue + flush_webhooks(thread_locals.webhook_queue) + del thread_locals.webhook_queue + + # Clear the request from thread-local storage + set_request(None) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index b37aaf40e..de739aa59 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -1,47 +1,11 @@ import django_filters -from django.forms import DateField, IntegerField, NullBooleanField from .models import Tag -from .choices import * __all__ = ( - 'CustomFieldFilter', 'TagFilter', ) -EXACT_FILTER_TYPES = ( - CustomFieldTypeChoices.TYPE_BOOLEAN, - CustomFieldTypeChoices.TYPE_DATE, - CustomFieldTypeChoices.TYPE_INTEGER, - CustomFieldTypeChoices.TYPE_SELECT, - CustomFieldTypeChoices.TYPE_MULTISELECT, -) - - -class CustomFieldFilter(django_filters.Filter): - """ - Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name. - """ - def __init__(self, custom_field, *args, **kwargs): - self.custom_field = custom_field - - if custom_field.type == CustomFieldTypeChoices.TYPE_INTEGER: - self.field_class = IntegerField - elif custom_field.type == CustomFieldTypeChoices.TYPE_BOOLEAN: - self.field_class = NullBooleanField - elif custom_field.type == CustomFieldTypeChoices.TYPE_DATE: - self.field_class = DateField - - super().__init__(*args, **kwargs) - - self.field_name = f'custom_field_data__{self.field_name}' - - if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT: - self.lookup_expr = 'has_key' - elif custom_field.type not in EXACT_FILTER_TYPES: - if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE: - self.lookup_expr = 'icontains' - class TagFilter(django_filters.ModelMultipleChoiceFilter): """ diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index af8d904f4..6233ca442 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -26,15 +26,12 @@ __all__ = ( 'WebhookFilterSet', ) -EXACT_FILTER_TYPES = ( - CustomFieldTypeChoices.TYPE_BOOLEAN, - CustomFieldTypeChoices.TYPE_DATE, - CustomFieldTypeChoices.TYPE_INTEGER, - CustomFieldTypeChoices.TYPE_SELECT, -) - class WebhookFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) content_types = ContentTypeFilter() http_method = django_filters.MultipleChoiceFilter( choices=WebhookHttpMethodChoices @@ -47,30 +44,81 @@ class WebhookFilterSet(BaseFilterSet): 'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path', ] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(payload_url__icontains=value) + ) + class CustomFieldFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) content_types = ContentTypeFilter() class Meta: model = CustomField fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(label__icontains=value) | + Q(description__icontains=value) + ) + class CustomLinkFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) class Meta: model = CustomLink fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(link_text__icontains=value) | + Q(link_url__icontains=value) | + Q(group_name__icontains=value) + ) + class ExportTemplateFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) class Meta: model = ExportTemplate fields = ['id', 'content_type', 'name'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + class ImageAttachmentFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) created = django_filters.DateTimeFilter() content_type = ContentTypeFilter() @@ -78,6 +126,11 @@ class ImageAttachmentFilterSet(BaseFilterSet): model = ImageAttachment fields = ['id', 'content_type_id', 'object_id', 'name'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(name__icontains=value) + class JournalEntryFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py index 1584e2f51..b470650da 100644 --- a/netbox/extras/forms/__init__.py +++ b/netbox/extras/forms/__init__.py @@ -3,4 +3,5 @@ from .filtersets import * from .bulk_edit import * from .bulk_import import * from .customfields import * +from .config import * from .scripts import * diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index b85a74a5b..1b87256a5 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -4,9 +4,7 @@ from django.contrib.contenttypes.models import ContentType from extras.choices import * from extras.models import * from extras.utils import FeatureQuery -from utilities.forms import ( - BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect, -) +from utilities.forms import BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect __all__ = ( 'ConfigContextBulkEditForm', @@ -19,7 +17,7 @@ __all__ = ( ) -class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm): +class CustomFieldBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=CustomField.objects.all(), widget=forms.MultipleHiddenInput @@ -39,7 +37,7 @@ class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm): nullable_fields = [] -class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm): +class CustomLinkBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=CustomLink.objects.all(), widget=forms.MultipleHiddenInput @@ -66,7 +64,7 @@ class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm): nullable_fields = [] -class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm): +class ExportTemplateBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ExportTemplate.objects.all(), widget=forms.MultipleHiddenInput @@ -97,7 +95,7 @@ class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm): nullable_fields = ['description', 'mime_type', 'file_extension'] -class WebhookBulkEditForm(BootstrapMixin, BulkEditForm): +class WebhookBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Webhook.objects.all(), widget=forms.MultipleHiddenInput @@ -137,10 +135,10 @@ class WebhookBulkEditForm(BootstrapMixin, BulkEditForm): ) class Meta: - nullable_fields = ['secret', 'ca_file_path'] + nullable_fields = ['secret', 'conditions', 'ca_file_path'] -class TagBulkEditForm(BootstrapMixin, BulkEditForm): +class TagBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Tag.objects.all(), widget=forms.MultipleHiddenInput @@ -157,7 +155,7 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm): nullable_fields = ['description'] -class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): +class ConfigContextBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ConfigContext.objects.all(), widget=forms.MultipleHiddenInput @@ -181,7 +179,7 @@ class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): ] -class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm): +class JournalEntryBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=JournalEntry.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/extras/forms/config.py b/netbox/extras/forms/config.py new file mode 100644 index 000000000..4a7dba614 --- /dev/null +++ b/netbox/extras/forms/config.py @@ -0,0 +1,82 @@ +from django import forms +from django.conf import settings + +from netbox.config import get_config, PARAMS + +__all__ = ( + 'ConfigRevisionForm', +) + + +EMPTY_VALUES = ('', None, [], ()) + + +class FormMetaclass(forms.models.ModelFormMetaclass): + + def __new__(mcs, name, bases, attrs): + + # Emulate a declared field for each supported configuration parameter + param_fields = {} + for param in PARAMS: + field_kwargs = { + 'required': False, + 'label': param.label, + 'help_text': param.description, + } + field_kwargs.update(**param.field_kwargs) + param_fields[param.name] = param.field(**field_kwargs) + attrs.update(param_fields) + + return super().__new__(mcs, name, bases, attrs) + + +class ConfigRevisionForm(forms.BaseModelForm, metaclass=FormMetaclass): + """ + Form for creating a new ConfigRevision. + """ + class Meta: + widgets = { + 'comment': forms.Textarea(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Append current parameter values to form field help texts and check for static configurations + config = get_config() + for param in PARAMS: + value = getattr(config, param.name) + is_static = hasattr(settings, param.name) + if value: + help_text = self.fields[param.name].help_text + if help_text: + help_text += '
' # Line break + help_text += f'Current value: {value}' + if is_static: + help_text += ' (defined statically)' + elif value == param.default: + help_text += ' (default)' + self.fields[param.name].help_text = help_text + if is_static: + self.fields[param.name].disabled = True + + def save(self, commit=True): + instance = super().save(commit=False) + + # Populate JSON data on the instance + instance.data = self.render_json() + + if commit: + instance.save() + + return instance + + def render_json(self): + json = {} + + # Iterate through each field and populate non-empty values + for field_name in self.declared_fields: + if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES: + json[field_name] = self.cleaned_data[field_name] + + return json diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index 9f68467fa..d58e6ce65 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -1,9 +1,10 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.db.models import Q from extras.choices import * from extras.models import * -from utilities.forms import BulkEditForm, CSVModelForm +from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm, FilterForm __all__ = ( 'CustomFieldModelCSVForm', @@ -51,7 +52,7 @@ class CustomFieldsMixin: self.custom_fields.append(field_name) -class CustomFieldModelForm(CustomFieldsMixin, forms.ModelForm): +class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): """ Extend ModelForm to include custom field support. """ @@ -104,7 +105,7 @@ class CustomFieldModelBulkEditForm(BulkEditForm): self.custom_fields.append(cf.name) -class CustomFieldModelFilterForm(forms.Form): +class CustomFieldModelFilterForm(FilterForm): def __init__(self, *args, **kwargs): @@ -115,9 +116,10 @@ class CustomFieldModelFilterForm(forms.Form): # Add all applicable CustomFields to the form self.custom_field_filters = [] custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude( - filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED + Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | + Q(type=CustomFieldTypeChoices.TYPE_JSON) ) for cf in custom_fields: - field_name = 'cf_{}'.format(cf.name) + field_name = f'cf_{cf.name}' self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False) self.custom_field_filters.append(field_name) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 6196ba8da..07375a203 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -9,9 +9,8 @@ from extras.models import * from extras.utils import FeatureQuery from tenancy.models import Tenant, TenantGroup from utilities.forms import ( - add_blank_choice, APISelectMultiple, BootstrapMixin, ContentTypeChoiceField, - ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, StaticSelect, - StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, + add_blank_choice, APISelectMultiple, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DateTimePicker, + DynamicModelMultipleChoiceField, FilterForm, StaticSelect, StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup @@ -28,17 +27,12 @@ __all__ = ( ) -class CustomFieldFilterForm(BootstrapMixin, forms.Form): +class CustomFieldFilterForm(FilterForm): field_groups = [ ['q'], ['type', 'content_types'], ['weight', 'required'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), @@ -61,16 +55,11 @@ class CustomFieldFilterForm(BootstrapMixin, forms.Form): ) -class CustomLinkFilterForm(BootstrapMixin, forms.Form): +class CustomLinkFilterForm(FilterForm): field_groups = [ ['q'], ['content_type', 'weight', 'new_window'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) content_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), @@ -87,16 +76,11 @@ class CustomLinkFilterForm(BootstrapMixin, forms.Form): ) -class ExportTemplateFilterForm(BootstrapMixin, forms.Form): +class ExportTemplateFilterForm(FilterForm): field_groups = [ ['q'], ['content_type', 'mime_type', 'file_extension', 'as_attachment'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) content_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), @@ -117,17 +101,12 @@ class ExportTemplateFilterForm(BootstrapMixin, forms.Form): ) -class WebhookFilterForm(BootstrapMixin, forms.Form): +class WebhookFilterForm(FilterForm): field_groups = [ ['q'], ['content_types', 'http_method', 'enabled'], ['type_create', 'type_update', 'type_delete'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), @@ -165,12 +144,8 @@ class WebhookFilterForm(BootstrapMixin, forms.Form): ) -class TagFilterForm(BootstrapMixin, forms.Form): +class TagFilterForm(FilterForm): model = Tag - q = forms.CharField( - required=False, - label=_('Search') - ) content_type_id = ContentTypeMultipleChoiceField( queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), required=False, @@ -178,7 +153,7 @@ class TagFilterForm(BootstrapMixin, forms.Form): ) -class ConfigContextFilterForm(BootstrapMixin, forms.Form): +class ConfigContextFilterForm(FilterForm): field_groups = [ ['q', 'tag'], ['region_id', 'site_group_id', 'site_id'], @@ -186,11 +161,6 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): ['cluster_group_id', 'cluster_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, @@ -270,18 +240,13 @@ class LocalConfigContextFilterForm(forms.Form): ) -class JournalEntryFilterForm(BootstrapMixin, forms.Form): +class JournalEntryFilterForm(FilterForm): model = JournalEntry field_groups = [ ['q'], ['created_before', 'created_after', 'created_by_id'], ['assigned_object_type_id', 'kind'] ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) created_after = forms.DateTimeField( required=False, label=_('After'), @@ -317,18 +282,13 @@ class JournalEntryFilterForm(BootstrapMixin, forms.Form): ) -class ObjectChangeFilterForm(BootstrapMixin, forms.Form): +class ObjectChangeFilterForm(FilterForm): model = ObjectChange field_groups = [ ['q'], ['time_before', 'time_after', 'action'], ['user_id', 'changed_object_type_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) time_after = forms.DateTimeField( required=False, label=_('After'), diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 7e462e62b..1e619ebec 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -70,7 +70,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): class ExportTemplateForm(BootstrapMixin, forms.ModelForm): content_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links') + limit_choices_to=FeatureQuery('export_templates') ) class Meta: @@ -102,6 +102,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): ('HTTP Request', ( 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', )), + ('Conditions', ('conditions',)), ('SSL', ('ssl_verification', 'ca_file_path')), ) widgets = { diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py index a4d617c9a..0607a16c2 100644 --- a/netbox/extras/management/commands/housekeeping.py +++ b/netbox/extras/management/commands/housekeeping.py @@ -10,12 +10,14 @@ from django.utils import timezone from packaging import version from extras.models import ObjectChange +from netbox.config import Config class Command(BaseCommand): help = "Perform nightly housekeeping tasks. (This command can be run at any time.)" def handle(self, *args, **options): + config = Config() # Clear expired authentication sessions (essentially replicating the `clearsessions` command) if options['verbosity']: @@ -37,10 +39,10 @@ class Command(BaseCommand): # Delete expired ObjectRecords if options['verbosity']: self.stdout.write("[*] Checking for expired changelog records") - if settings.CHANGELOG_RETENTION: - cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) + if config.CHANGELOG_RETENTION: + cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION) if options['verbosity'] >= 2: - self.stdout.write(f"\tRetention period: {settings.CHANGELOG_RETENTION} days") + self.stdout.write(f"\tRetention period: {config.CHANGELOG_RETENTION} days") self.stdout.write(f"\tCut-off time: {cutoff}") expired_records = ObjectChange.objects.filter(time__lt=cutoff).count() if expired_records: @@ -58,7 +60,7 @@ class Command(BaseCommand): self.stdout.write("\tNo expired records found.", self.style.SUCCESS) elif options['verbosity']: self.stdout.write( - f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {settings.CHANGELOG_RETENTION})" + f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})" ) # Check for new releases (if enabled) diff --git a/netbox/extras/migrations/0063_webhook_conditions.py b/netbox/extras/migrations/0063_webhook_conditions.py new file mode 100644 index 000000000..8cc5b1bd3 --- /dev/null +++ b/netbox/extras/migrations/0063_webhook_conditions.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.8 on 2021-10-22 20:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ] + + operations = [ + migrations.AddField( + model_name='webhook', + name='conditions', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/netbox/extras/migrations/0064_configrevision.py b/netbox/extras/migrations/0064_configrevision.py new file mode 100644 index 000000000..c3fce8abe --- /dev/null +++ b/netbox/extras/migrations/0064_configrevision.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0063_webhook_conditions'), + ] + + operations = [ + migrations.CreateModel( + name='ConfigRevision', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('comment', models.CharField(blank=True, max_length=200)), + ('data', models.JSONField(blank=True, null=True)), + ], + ), + ] diff --git a/netbox/extras/migrations/0065_imageattachment_change_logging.py b/netbox/extras/migrations/0065_imageattachment_change_logging.py new file mode 100644 index 000000000..dc623e46c --- /dev/null +++ b/netbox/extras/migrations/0065_imageattachment_change_logging.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0064_configrevision'), + ] + + operations = [ + migrations.AddField( + model_name='imageattachment', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 84676453f..3cb6372be 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,12 +1,13 @@ from .change_logging import ObjectChange from .configcontexts import ConfigContext, ConfigContextModel from .customfields import CustomField -from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook +from .models import * from .tags import Tag, TaggedItem __all__ = ( 'ConfigContext', 'ConfigContextModel', + 'ConfigRevision', 'CustomField', 'CustomLink', 'ExportTemplate', diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index c74bb0cde..1c511a852 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,6 +1,7 @@ import re from datetime import datetime, date +import django_filters from django import forms from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField @@ -13,6 +14,7 @@ from django.utils.safestring import mark_safe from extras.choices import * from extras.utils import FeatureQuery, extras_features from netbox.models import ChangeLoggedModel +from utilities import filters from utilities.forms import ( CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice, ) @@ -31,7 +33,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): return self.get_queryset().filter(content_types=content_type) -@extras_features('webhooks') +@extras_features('webhooks', 'export_templates') class CustomField(ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, @@ -167,7 +169,10 @@ class CustomField(ChangeLoggedModel): # Validate the field's default value (if any) if self.default is not None: try: - default_value = str(self.default) if self.type == CustomFieldTypeChoices.TYPE_TEXT else self.default + if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT): + default_value = str(self.default) + else: + default_value = self.default self.validate(default_value) except ValidationError as err: raise ValidationError({ @@ -185,7 +190,11 @@ class CustomField(ChangeLoggedModel): }) # Regex validation can be set only for text fields - regex_types = (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_URL) + regex_types = ( + CustomFieldTypeChoices.TYPE_TEXT, + CustomFieldTypeChoices.TYPE_LONGTEXT, + CustomFieldTypeChoices.TYPE_URL, + ) if self.validation_regex and self.type not in regex_types: raise ValidationError({ 'validation_regex': "Regular expression validation is supported only for text and URL fields" @@ -274,9 +283,19 @@ class CustomField(ChangeLoggedModel): elif self.type == CustomFieldTypeChoices.TYPE_URL: field = LaxURLField(required=required, initial=initial) + # JSON + elif self.type == CustomFieldTypeChoices.TYPE_JSON: + field = forms.JSONField(required=required, initial=initial) + # Text else: - field = forms.CharField(max_length=255, required=required, initial=initial) + if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT: + max_length = None + widget = forms.Textarea + else: + max_length = 255 + widget = None + field = forms.CharField(max_length=max_length, required=required, initial=initial, widget=widget) if self.validation_regex: field.validators = [ RegexValidator( @@ -292,6 +311,58 @@ class CustomField(ChangeLoggedModel): return field + def to_filter(self, lookup_expr=None): + """ + Return a django_filters Filter instance suitable for this field type. + + :param lookup_expr: Custom lookup expression (optional) + """ + kwargs = { + 'field_name': f'custom_field_data__{self.name}' + } + if lookup_expr is not None: + kwargs['lookup_expr'] = lookup_expr + + # Text/URL + if self.type in ( + CustomFieldTypeChoices.TYPE_TEXT, + CustomFieldTypeChoices.TYPE_LONGTEXT, + CustomFieldTypeChoices.TYPE_URL, + ): + filter_class = filters.MultiValueCharFilter + if self.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE: + kwargs['lookup_expr'] = 'icontains' + + # Integer + elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: + filter_class = filters.MultiValueNumberFilter + + # Boolean + elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: + filter_class = django_filters.BooleanFilter + + # Date + elif self.type == CustomFieldTypeChoices.TYPE_DATE: + filter_class = filters.MultiValueDateFilter + + # Select + elif self.type == CustomFieldTypeChoices.TYPE_SELECT: + filter_class = filters.MultiValueCharFilter + + # Multiselect + elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: + filter_class = filters.MultiValueCharFilter + kwargs['lookup_expr'] = 'has_key' + + # Unsupported custom field type + else: + return None + + filter_instance = filter_class(**kwargs) + filter_instance.custom_field = self + + return filter_instance + def validate(self, value): """ Validate a value according to the field's type validation rules. @@ -299,7 +370,7 @@ class CustomField(ChangeLoggedModel): if value not in [None, '']: # Validate text field - if self.type == CustomFieldTypeChoices.TYPE_TEXT: + if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT): if type(value) is not str: raise ValidationError(f"Value must be a string.") if self.validation_regex and not re.match(self.validation_regex, value): diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 75f5242d3..47da21e19 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -1,26 +1,29 @@ import json import uuid +from django.contrib import admin from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache from django.core.validators import ValidationError from django.db import models from django.http import HttpResponse from django.urls import reverse from django.utils import timezone -from django.utils.formats import date_format, time_format +from django.utils.formats import date_format from rest_framework.utils.encoders import JSONEncoder from extras.choices import * from extras.constants import * +from extras.conditions import ConditionSet from extras.utils import extras_features, FeatureQuery, image_upload from netbox.models import BigIDModel, ChangeLoggedModel from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 - __all__ = ( + 'ConfigRevision', 'CustomLink', 'ExportTemplate', 'ImageAttachment', @@ -32,11 +35,7 @@ __all__ = ( ) -# -# Webhooks -# - -@extras_features('webhooks') +@extras_features('webhooks', 'export_templates') class Webhook(ChangeLoggedModel): """ A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or @@ -107,6 +106,11 @@ class Webhook(ChangeLoggedModel): "the secret as the key. The secret is not transmitted in " "the request." ) + conditions = models.JSONField( + blank=True, + null=True, + help_text="A set of conditions which determine whether the webhook will be generated." + ) ssl_verification = models.BooleanField( default=True, verbose_name='SSL verification', @@ -121,8 +125,6 @@ class Webhook(ChangeLoggedModel): 'Leave blank to use the system defaults.' ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ('name',) unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',) @@ -138,9 +140,13 @@ class Webhook(ChangeLoggedModel): # At least one action type must be selected if not self.type_create and not self.type_delete and not self.type_update: - raise ValidationError( - "You must select at least one type: create, update, and/or delete." - ) + raise ValidationError("At least one type must be selected: create, update, and/or delete.") + + if self.conditions: + try: + ConditionSet(self.conditions) + except ValueError as e: + raise ValidationError({'conditions': e}) # CA file path requires SSL verification enabled if not self.ssl_verification and self.ca_file_path: @@ -171,11 +177,7 @@ class Webhook(ChangeLoggedModel): return json.dumps(context, cls=JSONEncoder) -# -# Custom links -# - -@extras_features('webhooks') +@extras_features('webhooks', 'export_templates') class CustomLink(ChangeLoggedModel): """ A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template @@ -218,8 +220,6 @@ class CustomLink(ChangeLoggedModel): help_text="Force link to open in a new window" ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['group_name', 'weight', 'name'] @@ -230,11 +230,7 @@ class CustomLink(ChangeLoggedModel): return reverse('extras:customlink', args=[self.pk]) -# -# Export templates -# - -@extras_features('webhooks') +@extras_features('webhooks', 'export_templates') class ExportTemplate(ChangeLoggedModel): content_type = models.ForeignKey( to=ContentType, @@ -268,8 +264,6 @@ class ExportTemplate(ChangeLoggedModel): help_text="Download file as attachment" ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['content_type', 'name'] unique_together = [ @@ -323,11 +317,8 @@ class ExportTemplate(ChangeLoggedModel): return response -# -# Image attachments -# - -class ImageAttachment(BigIDModel): +@extras_features('webhooks') +class ImageAttachment(ChangeLoggedModel): """ An uploaded image which is associated with an object. """ @@ -351,12 +342,15 @@ class ImageAttachment(BigIDModel): max_length=50, blank=True ) + # ChangeLoggingMixin.created is a DateField created = models.DateTimeField( auto_now_add=True ) objects = RestrictedQuerySet.as_manager() + clone_fields = ('content_type', 'object_id') + class Meta: ordering = ('name', 'pk') # name may be non-unique @@ -398,10 +392,8 @@ class ImageAttachment(BigIDModel): except tuple(expected_exceptions): return None - -# -# Journal entries -# + def to_objectchange(self, action): + return super().to_objectchange(action, related_object=self.parent) @extras_features('webhooks') @@ -436,8 +428,6 @@ class JournalEntry(ChangeLoggedModel): ) comments = models.TextField() - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ('-created',) verbose_name_plural = 'journal entries' @@ -453,36 +443,6 @@ class JournalEntry(ChangeLoggedModel): return JournalEntryKindChoices.CSS_CLASSES.get(self.kind) -# -# Custom scripts -# - -@extras_features('job_results') -class Script(models.Model): - """ - Dummy model used to generate permissions for custom scripts. Does not exist in the database. - """ - class Meta: - managed = False - - -# -# Reports -# - -@extras_features('job_results') -class Report(models.Model): - """ - Dummy model used to generate permissions for reports. Does not exist in the database. - """ - class Meta: - managed = False - - -# -# Job results -# - class JobResult(BigIDModel): """ This model stores the results from running a user-defined report. @@ -572,3 +532,66 @@ class JobResult(BigIDModel): func.delay(*args, job_id=str(job_result.job_id), job_result=job_result, **kwargs) return job_result + + +class ConfigRevision(models.Model): + """ + An atomic revision of NetBox's configuration. + """ + created = models.DateTimeField( + auto_now_add=True + ) + comment = models.CharField( + max_length=200, + blank=True + ) + data = models.JSONField( + blank=True, + null=True, + verbose_name='Configuration data' + ) + + def __str__(self): + return f'Config revision #{self.pk} ({self.created})' + + def __getattr__(self, item): + if item in self.data: + return self.data[item] + return super().__getattribute__(item) + + def activate(self): + """ + Cache the configuration data. + """ + cache.set('config', self.data, None) + cache.set('config_version', self.pk, None) + + @admin.display(boolean=True) + def is_active(self): + return cache.get('config_version') == self.pk + + +# +# Custom scripts & reports +# + +@extras_features('job_results') +class Script(models.Model): + """ + Dummy model used to generate permissions for custom scripts. Does not exist in the database. + """ + class Meta: + managed = False + + +# +# Reports +# + +@extras_features('job_results') +class Report(models.Model): + """ + Dummy model used to generate permissions for reports. Does not exist in the database. + """ + class Meta: + managed = False diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index afeeee53d..2925da652 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -7,14 +7,13 @@ from extras.utils import extras_features from netbox.models import BigIDModel, ChangeLoggedModel from utilities.choices import ColorChoices from utilities.fields import ColorField -from utilities.querysets import RestrictedQuerySet # # Tags # -@extras_features('webhooks') +@extras_features('webhooks', 'export_templates') class Tag(ChangeLoggedModel, TagBase): color = ColorField( default=ColorChoices.COLOR_GREY @@ -24,8 +23,6 @@ class Tag(ChangeLoggedModel, TagBase): blank=True, ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 9c46278ae..b128f7461 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -3,6 +3,7 @@ import json import logging import os import pkgutil +import sys import traceback from collections import OrderedDict @@ -477,6 +478,10 @@ def get_scripts(use_names=False): # Iterate through all modules within the reports path. These are the user-created files in which reports are # defined. for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]): + # Remove cached module to ensure consistency with filesystem + if module_name in sys.modules: + del sys.modules[module_name] + module = importer.find_module(module_name).load_module(module_name) if use_names and hasattr(module, 'name'): module_name = module.name diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 4f09706be..aff350cc4 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -1,17 +1,20 @@ +import importlib import logging -from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.db.models.signals import m2m_changed, post_save, pre_delete from django.dispatch import receiver, Signal from django_prometheus.models import model_deletes, model_inserts, model_updates +from extras.validators import CustomValidator +from netbox import thread_locals +from netbox.config import get_config +from netbox.request_context import get_request from netbox.signals import post_clean from .choices import ObjectChangeActionChoices -from .models import CustomField, ObjectChange +from .models import ConfigRevision, CustomField, ObjectChange from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook - # # Change logging/webhooks # @@ -20,10 +23,16 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook clear_webhooks = Signal() -def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs): +def handle_changed_object(sender, instance, **kwargs): """ Fires when an object is created or updated. """ + if not hasattr(instance, 'to_objectchange'): + return + + request = get_request() + m2m_changed = False + def is_same_object(instance, webhook_data): return ( ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and @@ -31,11 +40,6 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs): request.id == webhook_data['request_id'] ) - if not hasattr(instance, 'to_objectchange'): - return - - m2m_changed = False - # Determine the type of change being made if kwargs.get('created'): action = ObjectChangeActionChoices.ACTION_CREATE @@ -65,6 +69,7 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs): objectchange.save() # If this is an M2M change, update the previously queued webhook (from post_save) + webhook_queue = thread_locals.webhook_queue if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]): instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments webhook_queue[-1]['data'] = serialize_for_webhook(instance) @@ -79,13 +84,15 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs): model_updates.labels(instance._meta.model_name).inc() -def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs): +def handle_deleted_object(sender, instance, **kwargs): """ Fires when an object is deleted. """ if not hasattr(instance, 'to_objectchange'): return + request = get_request() + # Record an ObjectChange if applicable if hasattr(instance, 'to_objectchange'): objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE) @@ -94,19 +101,21 @@ def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs): objectchange.save() # Enqueue webhooks + webhook_queue = thread_locals.webhook_queue enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE) # Increment metric counters model_deletes.labels(instance._meta.model_name).inc() -def _clear_webhook_queue(webhook_queue, sender, **kwargs): +def clear_webhook_queue(sender, **kwargs): """ Delete any queued webhooks (e.g. because of an aborted bulk transaction) """ logger = logging.getLogger('webhooks') - logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})") + webhook_queue = thread_locals.webhook_queue + logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})") webhook_queue.clear() @@ -157,7 +166,31 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type @receiver(post_clean) def run_custom_validators(sender, instance, **kwargs): + config = get_config() model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' - validators = settings.CUSTOM_VALIDATORS.get(model_name, []) + validators = config.CUSTOM_VALIDATORS.get(model_name, []) + for validator in validators: + + # Loading a validator class by dotted path + if type(validator) is str: + module, cls = validator.rsplit('.', 1) + validator = getattr(importlib.import_module(module), cls)() + + # Constructing a new instance on the fly from a ruleset + elif type(validator) is dict: + validator = CustomValidator(validator) + validator(instance) + + +# +# Dynamic configuration +# + +@receiver(post_save, sender=ConfigRevision) +def update_config(sender, instance, **kwargs): + """ + Update the cached NetBox configuration when a new ConfigRevision is created. + """ + instance.activate() diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index 33251473f..e0be8c3bd 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -73,7 +73,7 @@ class ChangeLogViewTest(ModelViewTestCase): site = Site(name='Site 1', slug='site-1') site.save() tags = create_tags('Tag 1', 'Tag 2', 'Tag 3') - site.tags.set('Tag 1', 'Tag 2') + site.tags.set(['Tag 1', 'Tag 2']) form_data = { 'name': 'Site X', @@ -117,7 +117,7 @@ class ChangeLogViewTest(ModelViewTestCase): ) site.save() create_tags('Tag 1', 'Tag 2') - site.tags.set('Tag 1', 'Tag 2') + site.tags.set(['Tag 1', 'Tag 2']) request = { 'path': self._get_url('delete', instance=site), @@ -310,7 +310,7 @@ class ChangeLogAPITest(APITestCase): } ) site.save() - site.tags.set(*Tag.objects.all()[:2]) + site.tags.set(Tag.objects.all()[:2]) self.assertEqual(ObjectChange.objects.count(), 0) self.add_permissions('dcim.delete_site') url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py new file mode 100644 index 000000000..8e02eb75d --- /dev/null +++ b/netbox/extras/tests/test_conditions.py @@ -0,0 +1,209 @@ +from django.test import TestCase + +from extras.conditions import Condition, ConditionSet + + +class ConditionTestCase(TestCase): + + def test_dotted_path_access(self): + c = Condition('a.b.c', 1, 'eq') + self.assertTrue(c.eval({'a': {'b': {'c': 1}}})) + self.assertFalse(c.eval({'a': {'b': {'c': 2}}})) + self.assertFalse(c.eval({'a': {'b': {'x': 1}}})) + + def test_undefined_attr(self): + c = Condition('x', 1, 'eq') + self.assertFalse(c.eval({})) + self.assertTrue(c.eval({'x': 1})) + + # + # Validation tests + # + + def test_invalid_op(self): + with self.assertRaises(ValueError): + # 'blah' is not a valid operator + Condition('x', 1, 'blah') + + def test_invalid_type(self): + with self.assertRaises(ValueError): + # dict type is unsupported + Condition('x', 1, dict()) + + def test_invalid_op_type(self): + with self.assertRaises(ValueError): + # 'gt' supports only numeric values + Condition('x', 'foo', 'gt') + + # + # Nested attrs tests + # + + def test_nested(self): + c = Condition('x.y.z', 1) + self.assertTrue(c.eval({'x': {'y': {'z': 1}}})) + self.assertFalse(c.eval({'x': {'y': {'z': 2}}})) + self.assertFalse(c.eval({'a': {'b': {'c': 1}}})) + + # + # Operator tests + # + + def test_default_operator(self): + c = Condition('x', 1) + self.assertEqual(c.eval_func, c.eval_eq) + + def test_eq(self): + c = Condition('x', 1, 'eq') + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 2})) + + def test_eq_negated(self): + c = Condition('x', 1, 'eq', negate=True) + self.assertFalse(c.eval({'x': 1})) + self.assertTrue(c.eval({'x': 2})) + + def test_gt(self): + c = Condition('x', 1, 'gt') + self.assertTrue(c.eval({'x': 2})) + self.assertFalse(c.eval({'x': 1})) + + def test_gte(self): + c = Condition('x', 1, 'gte') + self.assertTrue(c.eval({'x': 2})) + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 0})) + + def test_lt(self): + c = Condition('x', 2, 'lt') + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 2})) + + def test_lte(self): + c = Condition('x', 2, 'lte') + self.assertTrue(c.eval({'x': 1})) + self.assertTrue(c.eval({'x': 2})) + self.assertFalse(c.eval({'x': 3})) + + def test_in(self): + c = Condition('x', [1, 2, 3], 'in') + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 9})) + + def test_in_negated(self): + c = Condition('x', [1, 2, 3], 'in', negate=True) + self.assertFalse(c.eval({'x': 1})) + self.assertTrue(c.eval({'x': 9})) + + def test_contains(self): + c = Condition('x', 1, 'contains') + self.assertTrue(c.eval({'x': [1, 2, 3]})) + self.assertFalse(c.eval({'x': [2, 3, 4]})) + + def test_contains_negated(self): + c = Condition('x', 1, 'contains', negate=True) + self.assertFalse(c.eval({'x': [1, 2, 3]})) + self.assertTrue(c.eval({'x': [2, 3, 4]})) + + def test_regex(self): + c = Condition('x', '[a-z]+', 'regex') + self.assertTrue(c.eval({'x': 'abc'})) + self.assertFalse(c.eval({'x': '123'})) + + def test_regex_negated(self): + c = Condition('x', '[a-z]+', 'regex', negate=True) + self.assertFalse(c.eval({'x': 'abc'})) + self.assertTrue(c.eval({'x': '123'})) + + +class ConditionSetTest(TestCase): + + def test_empty(self): + with self.assertRaises(ValueError): + ConditionSet({}) + + def test_invalid_logic(self): + with self.assertRaises(ValueError): + ConditionSet({'foo': []}) + + def test_and_single_depth(self): + cs = ConditionSet({ + 'and': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'attr': 'b', 'value': 1, 'op': 'eq', 'negate': True}, + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2})) + self.assertFalse(cs.eval({'a': 1, 'b': 1})) + + def test_or_single_depth(self): + cs = ConditionSet({ + 'or': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'attr': 'b', 'value': 1, 'op': 'eq'}, + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2})) + self.assertTrue(cs.eval({'a': 2, 'b': 1})) + self.assertFalse(cs.eval({'a': 2, 'b': 2})) + + def test_and_multi_depth(self): + cs = ConditionSet({ + 'and': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'and': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 3})) + self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 9, 'c': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 2, 'c': 9})) + + def test_or_multi_depth(self): + cs = ConditionSet({ + 'or': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'or': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 9})) + self.assertTrue(cs.eval({'a': 9, 'b': 2, 'c': 9})) + self.assertTrue(cs.eval({'a': 9, 'b': 9, 'c': 3})) + self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 9})) + + def test_mixed_and(self): + cs = ConditionSet({ + 'and': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'or': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9})) + self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 9, 'c': 9})) + self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 3})) + + def test_mixed_or(self): + cs = ConditionSet({ + 'or': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'and': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 9})) + self.assertTrue(cs.eval({'a': 9, 'b': 2, 'c': 3})) + self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9})) + self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9})) + self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3})) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 32c473678..5a9c4257f 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -24,13 +24,51 @@ class CustomFieldTest(TestCase): def test_simple_fields(self): DATA = ( - {'field_type': CustomFieldTypeChoices.TYPE_TEXT, 'field_value': 'Foobar!', 'empty_value': ''}, - {'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 0, 'empty_value': None}, - {'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 42, 'empty_value': None}, - {'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': True, 'empty_value': None}, - {'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': False, 'empty_value': None}, - {'field_type': CustomFieldTypeChoices.TYPE_DATE, 'field_value': '2016-06-23', 'empty_value': None}, - {'field_type': CustomFieldTypeChoices.TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''}, + { + 'field_type': CustomFieldTypeChoices.TYPE_TEXT, + 'field_value': 'Foobar!', + 'empty_value': '', + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_LONGTEXT, + 'field_value': 'Text with **Markdown**', + 'empty_value': '', + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_INTEGER, + 'field_value': 0, + 'empty_value': None, + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_INTEGER, + 'field_value': 42, + 'empty_value': None, + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, + 'field_value': True, + 'empty_value': None, + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, + 'field_value': False, + 'empty_value': None, + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_DATE, + 'field_value': '2016-06-23', + 'empty_value': None, + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_URL, + 'field_value': 'http://example.com/', + 'empty_value': '', + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_JSON, + 'field_value': '{"foo": 1, "bar": 2}', + 'empty_value': 'null', + }, ) obj_type = ContentType.objects.get_for_model(Site) @@ -149,6 +187,11 @@ class CustomFieldAPITest(APITestCase): cls.cf_text.save() cls.cf_text.content_types.set([content_type]) + # Long text custom field + cls.cf_longtext = CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC') + cls.cf_longtext.save() + cls.cf_longtext.content_types.set([content_type]) + # Integer custom field cls.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123) cls.cf_integer.save() @@ -169,6 +212,11 @@ class CustomFieldAPITest(APITestCase): cls.cf_url.save() cls.cf_url.content_types.set([content_type]) + # JSON custom field + cls.cf_json = CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}') + cls.cf_json.save() + cls.cf_json.content_types.set([content_type]) + # Select custom field cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', choices=['Foo', 'Bar', 'Baz']) cls.cf_select.default = 'Foo' @@ -185,10 +233,12 @@ class CustomFieldAPITest(APITestCase): # Assign custom field values for site 2 cls.sites[1].custom_field_data = { cls.cf_text.name: 'bar', + cls.cf_longtext.name: 'DEF', cls.cf_integer.name: 456, cls.cf_boolean.name: True, cls.cf_date.name: '2020-01-02', cls.cf_url.name: 'http://example.com/2', + cls.cf_json.name: '{"foo": 1, "bar": 2}', cls.cf_select.name: 'Bar', } cls.sites[1].save() @@ -204,10 +254,12 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['name'], self.sites[0].name) self.assertEqual(response.data['custom_fields'], { 'text_field': None, + 'longtext_field': None, 'number_field': None, 'boolean_field': None, 'date_field': None, 'url_field': None, + 'json_field': None, 'choice_field': None, }) @@ -222,10 +274,12 @@ class CustomFieldAPITest(APITestCase): response = self.client.get(url, **self.header) self.assertEqual(response.data['name'], self.sites[1].name) self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field']) + self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field']) self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field']) self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field']) self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field']) self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) + self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field']) self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field']) def test_create_single_object_with_defaults(self): @@ -245,19 +299,23 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data['custom_fields'] self.assertEqual(response_cf['text_field'], self.cf_text.default) + self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) self.assertEqual(response_cf['number_field'], self.cf_integer.default) self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) self.assertEqual(response_cf['date_field'], self.cf_date.default) self.assertEqual(response_cf['url_field'], self.cf_url.default) + self.assertEqual(response_cf['json_field'], self.cf_json.default) self.assertEqual(response_cf['choice_field'], self.cf_select.default) # Validate database data site = Site.objects.get(pk=response.data['id']) self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default) + self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) + self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default) self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) def test_create_single_object_with_values(self): @@ -269,10 +327,12 @@ class CustomFieldAPITest(APITestCase): 'slug': 'site-3', 'custom_fields': { 'text_field': 'bar', + 'longtext_field': 'blah blah blah', 'number_field': 456, 'boolean_field': True, 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', + 'json_field': '{"foo": 1, "bar": 2}', 'choice_field': 'Bar', }, } @@ -286,19 +346,23 @@ class CustomFieldAPITest(APITestCase): response_cf = response.data['custom_fields'] data_cf = data['custom_fields'] self.assertEqual(response_cf['text_field'], data_cf['text_field']) + self.assertEqual(response_cf['longtext_field'], data_cf['longtext_field']) self.assertEqual(response_cf['number_field'], data_cf['number_field']) self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field']) self.assertEqual(response_cf['date_field'], data_cf['date_field']) self.assertEqual(response_cf['url_field'], data_cf['url_field']) + self.assertEqual(response_cf['json_field'], data_cf['json_field']) self.assertEqual(response_cf['choice_field'], data_cf['choice_field']) # Validate database data site = Site.objects.get(pk=response.data['id']) self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field']) + self.assertEqual(site.custom_field_data['longtext_field'], data_cf['longtext_field']) self.assertEqual(site.custom_field_data['number_field'], data_cf['number_field']) self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field']) self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) + self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field']) self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field']) def test_create_multiple_objects_with_defaults(self): @@ -332,19 +396,23 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data[i]['custom_fields'] self.assertEqual(response_cf['text_field'], self.cf_text.default) + self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) self.assertEqual(response_cf['number_field'], self.cf_integer.default) self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) self.assertEqual(response_cf['date_field'], self.cf_date.default) self.assertEqual(response_cf['url_field'], self.cf_url.default) + self.assertEqual(response_cf['json_field'], self.cf_json.default) self.assertEqual(response_cf['choice_field'], self.cf_select.default) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default) + self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) + self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default) self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) def test_create_multiple_objects_with_values(self): @@ -353,10 +421,12 @@ class CustomFieldAPITest(APITestCase): """ custom_field_data = { 'text_field': 'bar', + 'longtext_field': 'abcdefghij', 'number_field': 456, 'boolean_field': True, 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', + 'json_field': '{"foo": 1, "bar": 2}', 'choice_field': 'Bar', } data = ( @@ -388,19 +458,23 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data[i]['custom_fields'] self.assertEqual(response_cf['text_field'], custom_field_data['text_field']) + self.assertEqual(response_cf['longtext_field'], custom_field_data['longtext_field']) self.assertEqual(response_cf['number_field'], custom_field_data['number_field']) self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field']) self.assertEqual(response_cf['date_field'], custom_field_data['date_field']) self.assertEqual(response_cf['url_field'], custom_field_data['url_field']) + self.assertEqual(response_cf['json_field'], custom_field_data['json_field']) self.assertEqual(response_cf['choice_field'], custom_field_data['choice_field']) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field']) + self.assertEqual(site.custom_field_data['longtext_field'], custom_field_data['longtext_field']) self.assertEqual(site.custom_field_data['number_field'], custom_field_data['number_field']) self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field']) self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field']) + self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field']) self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field']) def test_update_single_object_with_values(self): @@ -426,18 +500,22 @@ class CustomFieldAPITest(APITestCase): response_cf = response.data['custom_fields'] self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field']) self.assertEqual(response_cf['number_field'], data['custom_fields']['number_field']) + self.assertEqual(response_cf['longtext_field'], original_cfvs['longtext_field']) self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(response_cf['date_field'], original_cfvs['date_field']) self.assertEqual(response_cf['url_field'], original_cfvs['url_field']) + self.assertEqual(response_cf['json_field'], original_cfvs['json_field']) self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field']) # Validate database data site.refresh_from_db() self.assertEqual(site.custom_field_data['text_field'], data['custom_fields']['text_field']) self.assertEqual(site.custom_field_data['number_field'], data['custom_fields']['number_field']) + self.assertEqual(site.custom_field_data['longtext_field'], original_cfvs['longtext_field']) self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field']) self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field']) + self.assertEqual(site.custom_field_data['json_field'], original_cfvs['json_field']) self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field']) def test_minimum_maximum_values_validation(self): @@ -491,11 +569,15 @@ class CustomFieldImportTest(TestCase): custom_fields = ( CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT), + CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT), CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER), CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN), CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE), CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), - CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Choice A', 'Choice B', 'Choice C']), + CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON), + CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[ + 'Choice A', 'Choice B', 'Choice C', + ]), ) for cf in custom_fields: cf.save() @@ -506,10 +588,10 @@ class CustomFieldImportTest(TestCase): Import a Site in CSV format, including a value for each CustomField. """ data = ( - ('name', 'slug', 'status', 'cf_text', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_select'), - ('Site 1', 'site-1', 'active', 'ABC', '123', 'True', '2020-01-01', 'http://example.com/1', 'Choice A'), - ('Site 2', 'site-2', 'active', 'DEF', '456', 'False', '2020-01-02', 'http://example.com/2', 'Choice B'), - ('Site 3', 'site-3', 'active', '', '', '', '', '', ''), + ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select'), + ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A'), + ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B'), + ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', ''), ) csv_data = '\n'.join(','.join(row) for row in data) @@ -518,22 +600,26 @@ class CustomFieldImportTest(TestCase): # Validate data for site 1 site1 = Site.objects.get(name='Site 1') - self.assertEqual(len(site1.custom_field_data), 6) + self.assertEqual(len(site1.custom_field_data), 8) self.assertEqual(site1.custom_field_data['text'], 'ABC') + self.assertEqual(site1.custom_field_data['longtext'], 'Foo') self.assertEqual(site1.custom_field_data['integer'], 123) self.assertEqual(site1.custom_field_data['boolean'], True) self.assertEqual(site1.custom_field_data['date'], '2020-01-01') self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') + self.assertEqual(site1.custom_field_data['json'], {"foo": 123}) self.assertEqual(site1.custom_field_data['select'], 'Choice A') # Validate data for site 2 site2 = Site.objects.get(name='Site 2') - self.assertEqual(len(site2.custom_field_data), 6) + self.assertEqual(len(site2.custom_field_data), 8) self.assertEqual(site2.custom_field_data['text'], 'DEF') + self.assertEqual(site2.custom_field_data['longtext'], 'Bar') self.assertEqual(site2.custom_field_data['integer'], 456) self.assertEqual(site2.custom_field_data['boolean'], False) self.assertEqual(site2.custom_field_data['date'], '2020-01-02') self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') + self.assertEqual(site2.custom_field_data['json'], {"bar": 456}) self.assertEqual(site2.custom_field_data['select'], 'Choice B') # No custom field data should be set for site 3 @@ -633,7 +719,7 @@ class CustomFieldModelTest(TestCase): site.clean() -class CustomFieldFilterTest(TestCase): +class CustomFieldModelFilterTest(TestCase): queryset = Site.objects.all() filterset = SiteFilterSet @@ -686,7 +772,7 @@ class CustomFieldFilterTest(TestCase): cf.content_types.set([obj_type]) # Multiselect filtering - cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'AA', 'B', 'C']) + cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'B', 'C', 'X']) cf.save() cf.content_types.set([obj_type]) @@ -697,49 +783,88 @@ class CustomFieldFilterTest(TestCase): 'cf3': 'foo', 'cf4': 'foo', 'cf5': '2016-06-26', - 'cf6': 'http://foo.example.com/', - 'cf7': 'http://foo.example.com/', + 'cf6': 'http://a.example.com', + 'cf7': 'http://a.example.com', 'cf8': 'Foo', - 'cf9': ['A', 'B'], + 'cf9': ['A', 'X'], }), Site(name='Site 2', slug='site-2', custom_field_data={ 'cf1': 200, - 'cf2': False, + 'cf2': True, 'cf3': 'foobar', 'cf4': 'foobar', 'cf5': '2016-06-27', - 'cf6': 'http://bar.example.com/', - 'cf7': 'http://bar.example.com/', + 'cf6': 'http://b.example.com', + 'cf7': 'http://b.example.com', 'cf8': 'Bar', - 'cf9': ['AA', 'B'], + 'cf9': ['B', 'X'], + }), + Site(name='Site 3', slug='site-3', custom_field_data={ + 'cf1': 300, + 'cf2': False, + 'cf3': 'bar', + 'cf4': 'bar', + 'cf5': '2016-06-28', + 'cf6': 'http://c.example.com', + 'cf7': 'http://c.example.com', + 'cf8': 'Baz', + 'cf9': ['C', 'X'], }), - Site(name='Site 3', slug='site-3'), ]) def test_filter_integer(self): - self.assertEqual(self.filterset({'cf_cf1': 100}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf1': [100, 200]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf1__n': [200]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf1__gt': [200]}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf1__gte': [200]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2) def test_filter_boolean(self): - self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1) - def test_filter_text(self): - self.assertEqual(self.filterset({'cf_cf3': 'foo'}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf4': 'foo'}, self.queryset).qs.count(), 2) + def test_filter_text_strict(self): + self.assertEqual(self.filterset({'cf_cf3': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf3__n': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf3__ic': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf3__nic': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf3__isw': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf3__nisw': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf3__iew': ['bar']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf3__niew': ['bar']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf3__ie': ['FOO']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf3__nie': ['FOO']}, self.queryset).qs.count(), 2) + + def test_filter_text_loose(self): + self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 2) def test_filter_date(self): - self.assertEqual(self.filterset({'cf_cf5': '2016-06-26'}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf5': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf5__n': ['2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf5__gt': ['2016-06-27']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf5__gte': ['2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf5__lt': ['2016-06-27']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf5__lte': ['2016-06-27']}, self.queryset).qs.count(), 2) - def test_filter_url(self): - self.assertEqual(self.filterset({'cf_cf6': 'http://foo.example.com/'}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf7': 'example.com'}, self.queryset).qs.count(), 2) + def test_filter_url_strict(self): + self.assertEqual(self.filterset({'cf_cf6': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__n': ['http://b.example.com']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__ic': ['b']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf6__nic': ['b']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__isw': ['http://']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf6__nisw': ['http://']}, self.queryset).qs.count(), 0) + self.assertEqual(self.filterset({'cf_cf6__iew': ['.com']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf6__niew': ['.com']}, self.queryset).qs.count(), 0) + self.assertEqual(self.filterset({'cf_cf6__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf6__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2) + + def test_filter_url_loose(self): + self.assertEqual(self.filterset({'cf_cf7': ['example.com']}, self.queryset).qs.count(), 3) def test_filter_select(self): - self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf8': 'Bar'}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf8': 'Baz'}, self.queryset).qs.count(), 0) + self.assertEqual(self.filterset({'cf_cf8': ['Foo', 'Bar']}, self.queryset).qs.count(), 2) def test_filter_multiselect(self): - self.assertEqual(self.filterset({'cf_cf9': 'A'}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf9': 'B'}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf9': 'C'}, self.queryset).qs.count(), 0) + self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf9': ['X']}, self.queryset).qs.count(), 3) diff --git a/netbox/extras/tests/test_customvalidator.py b/netbox/extras/tests/test_customvalidator.py index 373303fb1..89857b615 100644 --- a/netbox/extras/tests/test_customvalidator.py +++ b/netbox/extras/tests/test_customvalidator.py @@ -119,3 +119,38 @@ class CustomValidatorTest(TestCase): @override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_validator]}) def test_custom_valid(self): Site(name='foo', slug='foo').clean() + + +class CustomValidatorConfigTest(TestCase): + + @override_settings( + CUSTOM_VALIDATORS={ + 'dcim.site': [ + {'name': {'min_length': 5}} + ] + } + ) + def test_plain_data(self): + """ + Test custom validator configuration using plain data (as opposed to a CustomValidator + class) + """ + with self.assertRaises(ValidationError): + Site(name='abcd', slug='abcd').clean() + Site(name='abcde', slug='abcde').clean() + + @override_settings( + CUSTOM_VALIDATORS={ + 'dcim.site': ( + 'extras.tests.test_customvalidator.MyValidator', + ) + } + ) + def test_dotted_path(self): + """ + Test custom validator configuration using a dotted path (string) reference to a + CustomValidator class. + """ + Site(name='foo', slug='foo').clean() + with self.assertRaises(ValidationError): + Site(name='bar', slug='bar').clean() diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 656c3efdc..0f4b35cf6 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -542,8 +542,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): site = Site.objects.create(name='Site 1', slug='site-1') provider = Provider.objects.create(name='Provider 1', slug='provider-1') - site.tags.set(tags[0]) - provider.tags.set(tags[1]) + site.tags.set([tags[0]]) + provider.tags.set([tags[1]]) def test_name(self): params = {'name': ['Tag 1', 'Tag 2']} diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index cb0a9c081..cf28a46e7 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -17,6 +17,9 @@ class CustomFieldModelFormTest(TestCase): cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT) cf_text.content_types.set([obj_type]) + cf_longtext = CustomField.objects.create(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT) + cf_longtext.content_types.set([obj_type]) + cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER) cf_integer.content_types.set([obj_type]) @@ -29,6 +32,9 @@ class CustomFieldModelFormTest(TestCase): cf_url = CustomField.objects.create(name='url', type=CustomFieldTypeChoices.TYPE_URL) cf_url.content_types.set([obj_type]) + cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON) + cf_json.content_types.set([obj_type]) + cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES) cf_select.content_types.set([obj_type]) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 72d965fd0..9ce324a5c 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -145,6 +145,7 @@ class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'payload_url': 'http://example.com/?x', 'http_method': 'GET', 'http_content_type': 'application/foo', + 'conditions': None, } cls.csv_data = ( diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index 57db6dd02..111ec6353 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -9,11 +9,12 @@ from django.urls import reverse from requests import Session from rest_framework import status +from dcim.choices import SiteStatusChoices from dcim.models import Site from extras.choices import ObjectChangeActionChoices from extras.models import Tag, Webhook -from extras.webhooks import enqueue_object, flush_webhooks, generate_signature -from extras.webhooks_worker import process_webhook +from extras.webhooks import enqueue_object, flush_webhooks, generate_signature, serialize_for_webhook +from extras.webhooks_worker import eval_conditions, process_webhook from utilities.testing import APITestCase @@ -122,7 +123,7 @@ class WebhookTest(APITestCase): def test_enqueue_webhook_update(self): site = Site.objects.create(name='Site 1', slug='site-1') - site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar'])) + site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar'])) # Update an object via the REST API data = { @@ -158,7 +159,7 @@ class WebhookTest(APITestCase): ) Site.objects.bulk_create(sites) for site in sites: - site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar'])) + site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar'])) # Update three objects via the REST API data = [ @@ -204,7 +205,7 @@ class WebhookTest(APITestCase): def test_enqueue_webhook_delete(self): site = Site.objects.create(name='Site 1', slug='site-1') - site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar'])) + site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar'])) # Delete an object via the REST API url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) @@ -230,7 +231,7 @@ class WebhookTest(APITestCase): ) Site.objects.bulk_create(sites) for site in sites: - site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar'])) + site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar'])) # Delete three objects via the REST API data = [ @@ -251,6 +252,37 @@ class WebhookTest(APITestCase): self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name) self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo']) + def test_webhook_conditions(self): + # Create a conditional Webhook + webhook = Webhook( + name='Conditional Webhook', + type_create=True, + type_update=True, + payload_url='http://localhost/', + conditions={ + 'and': [ + { + 'attr': 'status.value', + 'value': 'active', + } + ] + } + ) + + # Create a Site to evaluate + site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_STAGING) + data = serialize_for_webhook(site) + + # Evaluate the conditions (status='staging') + self.assertFalse(eval_conditions(webhook, data)) + + # Change the site's status + site.status = SiteStatusChoices.STATUS_ACTIVE + data = serialize_for_webhook(site) + + # Evaluate the conditions (status='active') + self.assertTrue(eval_conditions(webhook, data)) + def test_webhooks_worker(self): request_id = uuid.uuid4() diff --git a/netbox/extras/views.py b/netbox/extras/views.py index d39f50c79..ab9e3ba52 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -11,7 +11,7 @@ from rq import Worker from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.tables import paginate_table -from utilities.utils import copy_safe_request, count_related, shallow_compare_dict +from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin from . import filtersets, forms, tables from .choices import JobResultStatusChoices @@ -475,11 +475,7 @@ class ImageAttachmentEditView(generic.ObjectEditView): def alter_obj(self, instance, request, args, kwargs): if not instance.pk: # Assign the parent object based on URL kwargs - try: - app_label, model = request.GET.get('content_type').split('.') - except (AttributeError, ValueError): - raise Http404("Content type not specified") - content_type = get_object_or_404(ContentType, app_label=app_label, model=model) + content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type')) instance.parent = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id')) return instance @@ -758,7 +754,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View): def get(self, request, module, name): script = self._get_script(name, module) - form = script.as_form(initial=request.GET) + form = script.as_form(initial=normalize_querydict(request.GET)) # Look for a pending JobResult (use the latest one by creation timestamp) script_content_type = ContentType.objects.get(app_label='extras', model='script') diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index ce63e14ce..1f0a66b8a 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -6,16 +6,37 @@ from django_rq import job from jinja2.exceptions import TemplateError from .choices import ObjectChangeActionChoices +from .conditions import ConditionSet from .webhooks import generate_signature logger = logging.getLogger('netbox.webhooks_worker') +def eval_conditions(webhook, data): + """ + Test whether the given data meets the conditions of the webhook (if any). Return True + if met or no conditions are specified. + """ + if not webhook.conditions: + return True + + logger.debug(f'Evaluating webhook conditions: {webhook.conditions}') + if ConditionSet(webhook.conditions).eval(data): + return True + + return False + + @job('default') def process_webhook(webhook, model_name, event, data, snapshots, timestamp, username, request_id): """ Make a POST request to the defined Webhook """ + # Evaluate webhook conditions (if any) + if not eval_conditions(webhook, data): + return + + # Prepare context data for headers & body templates context = { 'event': dict(ObjectChangeActionChoices)[event].lower(), 'timestamp': timestamp, @@ -33,14 +54,14 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user try: headers.update(webhook.render_headers(context)) except (TemplateError, ValueError) as e: - logger.error("Error parsing HTTP headers for webhook {}: {}".format(webhook, e)) + logger.error(f"Error parsing HTTP headers for webhook {webhook}: {e}") raise e # Render the request body try: body = webhook.render_body(context) except TemplateError as e: - logger.error("Error rendering request body for webhook {}: {}".format(webhook, e)) + logger.error(f"Error rendering request body for webhook {webhook}: {e}") raise e # Prepare the HTTP request @@ -51,15 +72,13 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user 'data': body.encode('utf8'), } logger.info( - "Sending {} request to {} ({} {})".format( - params['method'], params['url'], context['model'], context['event'] - ) + f"Sending {params['method']} request to {params['url']} ({context['model']} {context['event']})" ) logger.debug(params) try: prepared_request = requests.Request(**params).prepare() except requests.exceptions.RequestException as e: - logger.error("Error forming HTTP request: {}".format(e)) + logger.error(f"Error forming HTTP request: {e}") raise e # If a secret key is defined, sign the request with a hash of the key and its content @@ -74,12 +93,10 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user response = session.send(prepared_request, proxies=settings.HTTP_PROXIES) if 200 <= response.status_code <= 299: - logger.info("Request succeeded; response status {}".format(response.status_code)) - return 'Status {} returned, webhook successfully processed.'.format(response.status_code) + logger.info(f"Request succeeded; response status {response.status_code}") + return f"Status {response.status_code} returned, webhook successfully processed." else: - logger.warning("Request failed; response status {}: {}".format(response.status_code, response.content)) + logger.warning(f"Request failed; response status {response.status_code}: {response.content}") raise requests.exceptions.RequestException( - "Status {} returned with content '{}', webhook FAILED to process.".format( - response.status_code, response.content - ) + f"Status {response.status_code} returned with content '{response.content}', webhook FAILED to process." ) diff --git a/netbox/ipam/api/mixins.py b/netbox/ipam/api/mixins.py index c09494d48..552c77d57 100644 --- a/netbox/ipam/api/mixins.py +++ b/netbox/ipam/api/mixins.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction from django.shortcuts import get_object_or_404 @@ -9,6 +8,7 @@ from rest_framework.decorators import action from rest_framework.response import Response from ipam.models import * +from netbox.config import get_config from utilities.constants import ADVISORY_LOCK_KEYS from . import serializers @@ -160,12 +160,15 @@ class AvailableIPsMixin: # Determine the maximum number of IPs to return else: + config = get_config() + PAGINATE_COUNT = config.PAGINATE_COUNT + MAX_PAGE_SIZE = config.MAX_PAGE_SIZE try: - limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT)) + limit = int(request.query_params.get('limit', PAGINATE_COUNT)) except ValueError: - limit = settings.PAGINATE_COUNT - if settings.MAX_PAGE_SIZE: - limit = min(limit, settings.MAX_PAGE_SIZE) + limit = PAGINATE_COUNT + if MAX_PAGE_SIZE: + limit = min(limit, MAX_PAGE_SIZE) # Calculate available IPs within the parent ip_list = [] diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index a52a6a03c..1eb66743b 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -5,6 +5,9 @@ from netbox.api import WritableNestedSerializer __all__ = [ 'NestedAggregateSerializer', + 'NestedASNSerializer', + 'NestedFHRPGroupSerializer', + 'NestedFHRPGroupAssignmentSerializer', 'NestedIPAddressSerializer', 'NestedIPRangeSerializer', 'NestedPrefixSerializer', @@ -18,6 +21,18 @@ __all__ = [ ] +# +# ASNs +# + +class NestedASNSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') + + class Meta: + model = models.ASN + fields = ['id', 'url', 'display', 'asn'] + + # # VRFs # @@ -65,6 +80,26 @@ class NestedAggregateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'family', 'prefix'] +# +# FHRP groups +# + +class NestedFHRPGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail') + + class Meta: + model = models.FHRPGroup + fields = ['id', 'url', 'display', 'protocol', 'group_id'] + + +class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail') + + class Meta: + model = models.FHRPGroupAssignment + fields = ['id', 'url', 'display', 'interface_type', 'interface_id', 'group_id', 'priority'] + + # # VLANs # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 9f3139793..aa1d2834a 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -3,14 +3,12 @@ from collections import OrderedDict 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 dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES from ipam.models import * from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField -from netbox.api.serializers import OrganizationalModelSerializer from netbox.api.serializers import PrimaryModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import get_serializer_for_model @@ -18,6 +16,23 @@ from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from .nested_serializers import * +# +# ASNs +# + +class ASNSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') + tenant = NestedTenantSerializer(required=False, allow_null=True) + site_count = serializers.IntegerField(read_only=True) + + class Meta: + model = ASN + fields = [ + 'id', 'url', 'display', 'asn', 'site_count', 'rir', 'tenant', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + + # # VRFs # @@ -67,14 +82,14 @@ class RouteTargetSerializer(PrimaryModelSerializer): # RIRs/aggregates # -class RIRSerializer(OrganizationalModelSerializer): +class RIRSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') aggregate_count = serializers.IntegerField(read_only=True) class Meta: model = RIR fields = [ - 'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'custom_fields', 'created', + 'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'aggregate_count', ] @@ -94,11 +109,51 @@ class AggregateSerializer(PrimaryModelSerializer): read_only_fields = ['family'] +# +# FHRP Groups +# + +class FHRPGroupSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail') + ip_addresses = NestedIPAddressSerializer(many=True, read_only=True) + + class Meta: + model = FHRPGroup + fields = [ + 'id', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_addresses', + 'tags', 'custom_fields', 'created', 'last_updated', + ] + + +class FHRPGroupAssignmentSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail') + group = NestedFHRPGroupSerializer() + interface_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + interface = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = FHRPGroupAssignment + fields = [ + 'id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'interface', 'priority', 'created', + 'last_updated', + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_interface(self, obj): + if obj.interface is None: + return None + serializer = get_serializer_for_model(obj.interface, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.interface, context=context).data + + # # VLANs # -class RoleSerializer(OrganizationalModelSerializer): +class RoleSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') prefix_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) @@ -106,43 +161,32 @@ class RoleSerializer(OrganizationalModelSerializer): class Meta: model = Role fields = [ - 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'custom_fields', 'created', 'last_updated', - 'prefix_count', 'vlan_count', + 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'prefix_count', 'vlan_count', ] -class VLANGroupSerializer(OrganizationalModelSerializer): +class VLANGroupSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') scope_type = ContentTypeField( queryset=ContentType.objects.filter( model__in=VLANGROUP_SCOPE_TYPES ), - required=False + required=False, + default=None ) + scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) scope = serializers.SerializerMethodField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) class Meta: model = VLANGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'custom_fields', - 'created', 'last_updated', 'vlan_count', + 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'vlan_count', ] validators = [] - def validate(self, data): - - # Validate uniqueness of name and slug if a site has been assigned. - if data.get('site', None): - for field in ['name', 'slug']: - validator = UniqueTogetherValidator(queryset=VLANGroup.objects.all(), fields=('site', field)) - validator(data, self) - - # Enforce model validation - super().validate(data) - - return data - def get_scope(self, obj): if obj.scope_id is None: return None @@ -155,7 +199,7 @@ class VLANGroupSerializer(OrganizationalModelSerializer): class VLANSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') site = NestedSiteSerializer(required=False, allow_null=True) - group = NestedVLANGroupSerializer(required=False, allow_null=True) + group = NestedVLANGroupSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=VLANStatusChoices, required=False) role = NestedRoleSerializer(required=False, allow_null=True) @@ -167,20 +211,6 @@ class VLANSerializer(PrimaryModelSerializer): 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', ] - validators = [] - - def validate(self, data): - - # Validate uniqueness of vid and name if a group has been assigned. - if data.get('group', None): - for field in ['vid', 'name']: - validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('group', field)) - validator(data, self) - - # Enforce model validation - super().validate(data) - - return data # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 06c4ab0ea..e465fbd89 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -5,6 +5,9 @@ from . import views router = OrderedDefaultRouter() router.APIRootView = views.IPAMRootView +# ASNs +router.register('asns', views.ASNViewSet) + # VRFs router.register('vrfs', views.VRFViewSet) @@ -27,6 +30,10 @@ router.register('ip-ranges', views.IPRangeViewSet) # IP addresses router.register('ip-addresses', views.IPAddressViewSet) +# FHRP groups +router.register('fhrp-groups', views.FHRPGroupViewSet) +router.register('fhrp-group-assignments', views.FHRPGroupAssignmentViewSet) + # VLANs router.register('vlan-groups', views.VLANGroupViewSet) router.register('vlans', views.VLANViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 69b6d97f0..cdb40333d 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,5 +1,6 @@ from rest_framework.routers import APIRootView +from dcim.models import Site from extras.api.views import CustomFieldModelViewSet from ipam import filtersets from ipam.models import * @@ -16,6 +17,16 @@ class IPAMRootView(APIRootView): return 'IPAM' +# +# ASNs +# + +class ASNViewSet(CustomFieldModelViewSet): + queryset = ASN.objects.prefetch_related('tenant', 'rir').annotate(site_count=count_related(Site, 'asns')) + serializer_class = serializers.ASNSerializer + filterset_class = filtersets.ASNFilterSet + + # # VRFs # @@ -48,7 +59,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet): class RIRViewSet(CustomFieldModelViewSet): queryset = RIR.objects.annotate( aggregate_count=count_related(Aggregate, 'rir') - ) + ).prefetch_related('tags') serializer_class = serializers.RIRSerializer filterset_class = filtersets.RIRFilterSet @@ -71,7 +82,7 @@ class RoleViewSet(CustomFieldModelViewSet): queryset = Role.objects.annotate( prefix_count=count_related(Prefix, 'role'), vlan_count=count_related(VLAN, 'role') - ) + ).prefetch_related('tags') serializer_class = serializers.RoleSerializer filterset_class = filtersets.RoleFilterSet @@ -119,6 +130,23 @@ class IPAddressViewSet(CustomFieldModelViewSet): filterset_class = filtersets.IPAddressFilterSet +# +# FHRP groups +# + +class FHRPGroupViewSet(CustomFieldModelViewSet): + queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags') + serializer_class = serializers.FHRPGroupSerializer + filterset_class = filtersets.FHRPGroupFilterSet + brief_prefetch_fields = ('ip_addresses',) + + +class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet): + queryset = FHRPGroupAssignment.objects.prefetch_related('group', 'interface') + serializer_class = serializers.FHRPGroupAssignmentSerializer + filterset_class = filtersets.FHRPGroupAssignmentFilterSet + + # # VLAN groups # @@ -126,7 +154,7 @@ class IPAddressViewSet(CustomFieldModelViewSet): class VLANGroupViewSet(CustomFieldModelViewSet): queryset = VLANGroup.objects.annotate( vlan_count=count_related(VLAN, 'group') - ) + ).prefetch_related('tags') serializer_class = serializers.VLANGroupSerializer filterset_class = filtersets.VLANGroupFilterSet diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index e3a45f577..638ef62f6 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -124,6 +124,38 @@ class IPAddressRoleChoices(ChoiceSet): } +# +# FHRP +# + +class FHRPGroupProtocolChoices(ChoiceSet): + + PROTOCOL_VRRP2 = 'vrrp2' + PROTOCOL_VRRP3 = 'vrrp3' + PROTOCOL_HSRP = 'hsrp' + PROTOCOL_GLBP = 'glbp' + PROTOCOL_CARP = 'carp' + + CHOICES = ( + (PROTOCOL_VRRP2, 'VRRPv2'), + (PROTOCOL_VRRP3, 'VRRPv3'), + (PROTOCOL_HSRP, 'HSRP'), + (PROTOCOL_GLBP, 'GLBP'), + (PROTOCOL_CARP, 'CARP'), + ) + + +class FHRPGroupAuthTypeChoices(ChoiceSet): + + AUTHENTICATION_PLAINTEXT = 'plaintext' + AUTHENTICATION_MD5 = 'md5' + + CHOICES = ( + (AUTHENTICATION_PLAINTEXT, 'Plaintext'), + (AUTHENTICATION_MD5, 'MD5'), + ) + + # # VLANs # diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 9dd9328b8..b19d4061b 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -1,6 +1,6 @@ from django.db.models import Q -from .choices import IPAddressRoleChoices +from .choices import FHRPGroupProtocolChoices, IPAddressRoleChoices # BGP ASN bounds BGP_ASN_MIN = 1 @@ -34,6 +34,7 @@ PREFIX_LENGTH_MAX = 127 # IPv6 IPADDRESS_ASSIGNMENT_MODELS = Q( Q(app_label='dcim', model='interface') | + Q(app_label='ipam', model='fhrpgroup') | Q(app_label='virtualization', model='vminterface') ) @@ -51,6 +52,22 @@ IPADDRESS_ROLES_NONUNIQUE = ( ) +# +# FHRP groups +# + +FHRPGROUPASSIGNMENT_PRIORITY_MIN = 0 +FHRPGROUPASSIGNMENT_PRIORITY_MAX = 255 + +FHRP_PROTOCOL_ROLE_MAPPINGS = { + FHRPGroupProtocolChoices.PROTOCOL_VRRP2: IPAddressRoleChoices.ROLE_VRRP, + FHRPGroupProtocolChoices.PROTOCOL_VRRP3: IPAddressRoleChoices.ROLE_VRRP, + FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP, + FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP, + FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP, +} + + # # VLANs # diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 37a9299dc..df6ee1055 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -7,7 +7,7 @@ from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup from extras.filters import TagFilter -from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, @@ -19,6 +19,9 @@ from .models import * __all__ = ( 'AggregateFilterSet', + 'ASNFilterSet', + 'FHRPGroupAssignmentFilterSet', + 'FHRPGroupFilterSet', 'IPAddressFilterSet', 'IPRangeFilterSet', 'PrefixFilterSet', @@ -118,6 +121,7 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class RIRFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = RIR @@ -174,11 +178,46 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.none() +class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): + rir_id = django_filters.ModelMultipleChoiceFilter( + queryset=RIR.objects.all(), + label='RIR (ID)', + ) + rir = django_filters.ModelMultipleChoiceFilter( + field_name='rir__slug', + queryset=RIR.objects.all(), + to_field_name='slug', + label='RIR (slug)', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + field_name='sites', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='sites__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) + + class Meta: + model = ASN + fields = ['id', 'asn'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(description__icontains=value) + return queryset.filter(qs_filter) + + class RoleFilterSet(OrganizationalModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', ) + tag = TagFilter() class Meta: model = Role @@ -609,6 +648,67 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.exclude(assigned_object_id__isnull=value) +class FHRPGroupFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + protocol = django_filters.MultipleChoiceFilter( + choices=FHRPGroupProtocolChoices + ) + auth_type = django_filters.MultipleChoiceFilter( + choices=FHRPGroupAuthTypeChoices + ) + related_ip = django_filters.ModelMultipleChoiceFilter( + queryset=IPAddress.objects.all(), + method='filter_related_ip' + ) + tag = TagFilter() + + class Meta: + model = FHRPGroup + fields = ['id', 'group_id', 'auth_key'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(description__icontains=value) + ) + + def filter_related_ip(self, queryset, name, value): + """ + Filter by VRF & prefix of assigned IP addresses. + """ + ip_filter = Q() + for ipaddress in value: + if ipaddress.vrf: + q = Q( + ip_addresses__address__net_contained_or_equal=ipaddress.address, + ip_addresses__vrf=ipaddress.vrf + ) + else: + q = Q( + ip_addresses__address__net_contained_or_equal=ipaddress.address, + ip_addresses__vrf__isnull=True + ) + ip_filter |= q + + return queryset.filter(ip_filter) + + +class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): + interface_type = ContentTypeFilter() + group_id = django_filters.ModelMultipleChoiceFilter( + queryset=FHRPGroup.objects.all(), + label='Group (ID)', + ) + + class Meta: + model = FHRPGroupAssignment + fields = ['id', 'group_id', 'interface_type', 'interface_id', 'priority'] + + class VLANGroupFilterSet(OrganizationalModelFilterSet): q = django_filters.CharFilter( method='search', @@ -636,6 +736,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): cluster = django_filters.NumberFilter( method='filter_scope' ) + tag = TagFilter() class Meta: model = VLANGroup diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 895dbe200..edb14a25c 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -5,14 +5,17 @@ from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.choices import * from ipam.constants import * from ipam.models import * +from ipam.models import ASN from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField, - StaticSelect, + add_blank_choice, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField, StaticSelect, + DynamicModelMultipleChoiceField, ) __all__ = ( 'AggregateBulkEditForm', + 'ASNBulkEditForm', + 'FHRPGroupBulkEditForm', 'IPAddressBulkEditForm', 'IPRangeBulkEditForm', 'PrefixBulkEditForm', @@ -26,7 +29,7 @@ __all__ = ( ) -class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class VRFBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput() @@ -51,7 +54,7 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEdi ] -class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class RouteTargetBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RouteTarget.objects.all(), widget=forms.MultipleHiddenInput() @@ -71,7 +74,7 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode ] -class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RIRBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RIR.objects.all(), widget=forms.MultipleHiddenInput @@ -89,7 +92,39 @@ class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['is_private', 'description'] -class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ASNBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ASN.objects.all(), + widget=forms.MultipleHiddenInput() + ) + sites = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False + ) + rir = DynamicModelChoiceField( + queryset=RIR.objects.all(), + required=False, + label='RIR' + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'date_added', 'description', + ] + widgets = { + 'date_added': DatePicker(), + } + + +class AggregateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput() @@ -120,7 +155,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB } -class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Role.objects.all(), widget=forms.MultipleHiddenInput @@ -137,7 +172,7 @@ class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['description'] -class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class PrefixBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput() @@ -202,7 +237,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk ] -class IPRangeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class IPRangeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=IPRange.objects.all(), widget=forms.MultipleHiddenInput() @@ -236,7 +271,7 @@ class IPRangeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBul ] -class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput() @@ -280,7 +315,42 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB ] -class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class FHRPGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=FHRPGroup.objects.all(), + widget=forms.MultipleHiddenInput() + ) + protocol = forms.ChoiceField( + choices=add_blank_choice(FHRPGroupProtocolChoices), + required=False, + widget=StaticSelect() + ) + group_id = forms.IntegerField( + min_value=0, + required=False, + label='Group ID' + ) + auth_type = forms.ChoiceField( + choices=add_blank_choice(FHRPGroupAuthTypeChoices), + required=False, + widget=StaticSelect(), + label='Authentication type' + ) + auth_key = forms.CharField( + max_length=255, + required=False, + label='Authentication key' + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['auth_type', 'auth_key', 'description'] + + +class VLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VLANGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -298,7 +368,7 @@ class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['site', 'description'] -class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class VLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput() @@ -350,7 +420,7 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd ] -class ServiceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ServiceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Service.objects.all(), widget=forms.MultipleHiddenInput() diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 3d7c829d6..65fc35c34 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -12,6 +12,8 @@ from virtualization.models import VirtualMachine, VMInterface __all__ = ( 'AggregateCSVForm', + 'ASNCSVForm', + 'FHRPGroupCSVForm', 'IPAddressCSVForm', 'IPRangeCSVForm', 'PrefixCSVForm', @@ -80,6 +82,25 @@ class AggregateCSVForm(CustomFieldModelCSVForm): fields = ('prefix', 'rir', 'tenant', 'date_added', 'description') +class ASNCSVForm(CustomFieldModelCSVForm): + rir = CSVModelChoiceField( + queryset=RIR.objects.all(), + to_field_name='name', + help_text='Assigned RIR' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + model = ASN + fields = ('asn', 'rir', 'tenant', 'description') + help_texts = {} + + class RoleCSVForm(CustomFieldModelCSVForm): slug = SlugField() @@ -290,6 +311,20 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): return ipaddress +class FHRPGroupCSVForm(CustomFieldModelCSVForm): + protocol = CSVChoiceField( + choices=FHRPGroupProtocolChoices + ) + auth_type = CSVChoiceField( + choices=FHRPGroupAuthTypeChoices, + required=False + ) + + class Meta: + model = FHRPGroup + fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'description') + + class VLANGroupCSVForm(CustomFieldModelCSVForm): slug = SlugField() scope_type = CSVContentTypeField( diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 8bc0f10fb..75953001b 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -6,14 +6,17 @@ from extras.forms import CustomFieldModelFilterForm from ipam.choices import * from ipam.constants import * from ipam.models import * +from ipam.models import ASN from tenancy.forms import TenancyFilterForm from utilities.forms import ( - add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, - StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, + TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) __all__ = ( 'AggregateFilterForm', + 'ASNFilterForm', + 'FHRPGroupFilterForm', 'IPAddressFilterForm', 'IPRangeFilterForm', 'PrefixFilterForm', @@ -35,18 +38,13 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ ]) -class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): +class VRFFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = VRF field_groups = [ ['q', 'tag'], ['import_target_id', 'export_target_id'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) import_target_id = DynamicModelMultipleChoiceField( queryset=RouteTarget.objects.all(), required=False, @@ -62,18 +60,13 @@ class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFor tag = TagFilterField(model) -class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): +class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = RouteTarget field_groups = [ ['q', 'tag'], ['importing_vrf_id', 'exporting_vrf_id'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) importing_vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), required=False, @@ -89,17 +82,8 @@ class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelF tag = TagFilterField(model) -class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm): +class RIRFilterForm(CustomFieldModelFilterForm): model = RIR - field_groups = [ - ['q'], - ['is_private'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) is_private = forms.NullBooleanField( required=False, label=_('Private'), @@ -107,20 +91,16 @@ class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + tag = TagFilterField(model) -class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): +class AggregateFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = Aggregate field_groups = [ ['q', 'tag'], ['family', 'rir_id'], ['tenant_group_id', 'tenant_id'] ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) family = forms.ChoiceField( required=False, choices=add_blank_choice(IPAddressFamilyChoices), @@ -136,19 +116,34 @@ class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil tag = TagFilterField(model) -class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = Role +class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): + model = ASN field_groups = [ ['q'], + ['rir_id'], + ['tenant_group_id', 'tenant_id'], + ['site_id'], ] - q = forms.CharField( + rir_id = DynamicModelMultipleChoiceField( + queryset=RIR.objects.all(), required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') + label=_('RIR'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + label=_('Site'), + fetch_trigger='open' ) -class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): +class RoleFilterForm(CustomFieldModelFilterForm): + model = Role + tag = TagFilterField(model) + + +class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = Prefix field_groups = [ ['q', 'tag'], @@ -157,11 +152,6 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter ['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') - ) mask_length__lte = forms.IntegerField( widget=forms.HiddenInput() ) @@ -250,18 +240,13 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter tag = TagFilterField(model) -class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): +class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = IPRange field_groups = [ ['q', 'tag'], ['family', 'vrf_id', 'status', 'role_id'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) family = forms.ChoiceField( required=False, choices=add_blank_choice(IPAddressFamilyChoices), @@ -290,7 +275,7 @@ class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte tag = TagFilterField(model) -class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): +class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = IPAddress field_order = [ 'q', 'parent', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'role', @@ -302,11 +287,6 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil ['vrf_id', 'present_in_vrf_id'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) parent = forms.CharField( required=False, widget=forms.TextInput( @@ -361,17 +341,42 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil tag = TagFilterField(model) -class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): +class FHRPGroupFilterForm(CustomFieldModelFilterForm): + model = FHRPGroup + field_groups = ( + ('q', 'tag'), + ('protocol', 'group_id'), + ('auth_type', 'auth_key'), + ) + protocol = forms.MultipleChoiceField( + choices=FHRPGroupProtocolChoices, + required=False, + widget=StaticSelectMultiple() + ) + group_id = forms.IntegerField( + min_value=0, + required=False, + label='Group ID' + ) + auth_type = forms.MultipleChoiceField( + choices=FHRPGroupAuthTypeChoices, + required=False, + widget=StaticSelectMultiple(), + label='Authentication type' + ) + auth_key = forms.CharField( + required=False, + label='Authentication key' + ) + tag = TagFilterField(model) + + +class VLANGroupFilterForm(CustomFieldModelFilterForm): field_groups = [ - ['q'], + ['q', 'tag'], ['region', 'sitegroup', 'site', 'location', 'rack'] ] model = VLANGroup - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -402,21 +407,17 @@ class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Rack'), fetch_trigger='open' ) + tag = TagFilterField(model) -class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): +class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = VLAN field_groups = [ ['q', 'tag'], ['region_id', 'site_group_id', 'site_id'], - ['group_id', 'status', 'role_id'], + ['group_id', 'status', 'role_id', 'vid'], ['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, @@ -461,20 +462,19 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo label=_('Role'), fetch_trigger='open' ) + vid = forms.IntegerField( + required=False, + label='VLAN ID' + ) tag = TagFilterField(model) -class ServiceFilterForm(BootstrapMixin, CustomFieldModelFilterForm): +class ServiceFilterForm(CustomFieldModelFilterForm): model = Service field_groups = ( ('q', 'tag'), ('protocol', 'port'), ) - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) protocol = forms.ChoiceField( choices=add_blank_choice(ServiceProtocolChoices), required=False, diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index d28f7b3ae..aa2fa3214 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -4,17 +4,24 @@ from django.contrib.contenttypes.models import ContentType from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup from extras.forms import CustomFieldModelForm from extras.models import Tag +from ipam.choices import * from ipam.constants import * +from ipam.formfields import IPNetworkFormField from ipam.models import * +from ipam.models import ASN from tenancy.forms import TenancyForm +from utilities.exceptions import PermissionsViolation from utilities.forms import ( - BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple, + add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface __all__ = ( 'AggregateForm', + 'ASNForm', + 'FHRPGroupForm', + 'FHRPGroupAssignmentForm', 'IPAddressAssignForm', 'IPAddressBulkAddForm', 'IPAddressForm', @@ -30,7 +37,7 @@ __all__ = ( ) -class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): +class VRFForm(TenancyForm, CustomFieldModelForm): import_targets = DynamicModelMultipleChoiceField( queryset=RouteTarget.objects.all(), required=False @@ -63,7 +70,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } -class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): +class RouteTargetForm(TenancyForm, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -80,17 +87,21 @@ class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) -class RIRForm(BootstrapMixin, CustomFieldModelForm): +class RIRForm(CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = RIR fields = [ - 'name', 'slug', 'is_private', 'description', + 'name', 'slug', 'is_private', 'description', 'tags', ] -class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): +class AggregateForm(TenancyForm, CustomFieldModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), label='RIR' @@ -118,17 +129,65 @@ class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } -class RoleForm(BootstrapMixin, CustomFieldModelForm): +class ASNForm(TenancyForm, CustomFieldModelForm): + rir = DynamicModelChoiceField( + queryset=RIR.objects.all(), + label='RIR', + ) + sites = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + label='Sites', + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = ASN + fields = [ + 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'tags' + ] + fieldsets = ( + ('ASN', ('asn', 'rir', 'sites', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + help_texts = { + 'asn': "AS number", + 'rir': "Regional Internet Registry responsible for this prefix", + } + widgets = { + 'date_added': DatePicker(), + } + + def __init__(self, data=None, instance=None, *args, **kwargs): + super().__init__(data=data, instance=instance, *args, **kwargs) + + if self.instance and self.instance.pk is not None: + self.fields['sites'].initial = self.instance.sites.all().values_list('id', flat=True) + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + instance.sites.set(self.cleaned_data['sites']) + return instance + + +class RoleForm(CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Role fields = [ - 'name', 'slug', 'weight', 'description', + 'name', 'slug', 'weight', 'description', 'tags', ] -class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): +class PrefixForm(TenancyForm, CustomFieldModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -203,7 +262,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } -class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): +class IPRangeForm(TenancyForm, CustomFieldModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -232,7 +291,7 @@ class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } -class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): +class IPAddressForm(TenancyForm, CustomFieldModelForm): device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False, @@ -262,6 +321,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'virtual_machine_id': '$virtual_machine' } ) + fhrpgroup = DynamicModelChoiceField( + queryset=FHRPGroup.objects.all(), + required=False, + label='FHRP Group' + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -369,6 +433,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): initial['interface'] = instance.assigned_object elif type(instance.assigned_object) is VMInterface: initial['vminterface'] = instance.assigned_object + elif type(instance.assigned_object) is FHRPGroup: + initial['fhrpgroup'] = instance.assigned_object if instance.nat_inside: nat_inside_parent = instance.nat_inside.assigned_object if type(nat_inside_parent) is Interface: @@ -385,8 +451,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): # Initialize primary_for_parent if IP address is already assigned if self.instance.pk and self.instance.assigned_object: - parent = self.instance.assigned_object.parent_object - if ( + parent = getattr(self.instance.assigned_object, 'parent_object', None) + if parent and ( self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk ): @@ -395,10 +461,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): def clean(self): super().clean() - # Cannot select both a device interface and a VM interface - if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'): - raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface") - self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') + # Handle object assignment + if self.cleaned_data['interface']: + self.instance.assigned_object = self.cleaned_data['interface'] + elif self.cleaned_data['vminterface']: + self.instance.assigned_object = self.cleaned_data['vminterface'] + elif self.cleaned_data['fhrpgroup']: + self.instance.assigned_object = self.cleaned_data['fhrpgroup'] # Primary IP assignment is only available if an interface has been assigned. interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') @@ -412,7 +481,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. interface = self.instance.assigned_object - if interface: + if type(interface) in (Interface, VMInterface): parent = interface.parent_object if self.cleaned_data['primary_for_parent']: if ipaddress.address.version == 4: @@ -430,7 +499,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): return ipaddress -class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): +class IPAddressBulkAddForm(TenancyForm, CustomFieldModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -464,7 +533,87 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): ) -class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): +class FHRPGroupForm(CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + # Optionally create a new IPAddress along with the FHRPGroup + ip_vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + ip_address = IPNetworkFormField( + required=False, + label='Address' + ) + ip_status = forms.ChoiceField( + choices=add_blank_choice(IPAddressStatusChoices), + required=False, + label='Status' + ) + + class Meta: + model = FHRPGroup + fields = ( + 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags', + ) + fieldsets = ( + ('FHRP Group', ('protocol', 'group_id', 'description', 'tags')), + ('Authentication', ('auth_type', 'auth_key')), + ('Virtual IP Address', ('ip_vrf', 'ip_address', 'ip_status')) + ) + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + + # Check if we need to create a new IPAddress for the group + if self.cleaned_data.get('ip_address'): + ipaddress = IPAddress( + vrf=self.cleaned_data['ip_vrf'], + address=self.cleaned_data['ip_address'], + status=self.cleaned_data['ip_status'], + assigned_object=instance + ) + ipaddress.role = FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']] + ipaddress.save() + + # Check that the new IPAddress conforms with any assigned object-level permissions + if not IPAddress.objects.filter(pk=ipaddress.pk).first(): + raise PermissionsViolation() + + return instance + + def clean(self): + ip_address = self.cleaned_data.get('ip_address') + ip_status = self.cleaned_data.get('ip_status') + + if ip_address and not ip_status: + raise forms.ValidationError({ + 'ip_status': "Status must be set when creating a new IP address." + }) + + +class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm): + group = DynamicModelChoiceField( + queryset=FHRPGroup.objects.all() + ) + + class Meta: + model = FHRPGroupAssignment + fields = ('group', 'priority') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + ipaddresses = self.instance.interface.ip_addresses.all() + for ipaddress in ipaddresses: + self.fields['group'].widget.add_query_param('related_ip', ipaddress.pk) + + +class VLANGroupForm(CustomFieldModelForm): scope_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), required=False, @@ -530,15 +679,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): } ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = VLANGroup fields = [ 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', - 'clustergroup', 'cluster', + 'clustergroup', 'cluster', 'tags', ] fieldsets = ( - ('VLAN Group', ('name', 'slug', 'description')), + ('VLAN Group', ('name', 'slug', 'description', 'tags')), ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), ) widgets = { @@ -567,7 +720,7 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): self.instance.scope_id = None -class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): +class VLANForm(TenancyForm, CustomFieldModelForm): # VLANGroup assignment fields scope_type = forms.ChoiceField( choices=( @@ -648,7 +801,7 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } -class ServiceForm(BootstrapMixin, CustomFieldModelForm): +class ServiceForm(CustomFieldModelForm): ports = NumericArrayField( base_field=forms.IntegerField( min_value=SERVICE_PORT_MIN, diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 58909e57f..9609d1434 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -5,6 +5,9 @@ from .types import * class IPAMQuery(graphene.ObjectType): + asn = ObjectField(ASNType) + asn_list = ObjectListField(ASNType) + aggregate = ObjectField(AggregateType) aggregate_list = ObjectListField(AggregateType) @@ -29,6 +32,12 @@ class IPAMQuery(graphene.ObjectType): service = ObjectField(ServiceType) service_list = ObjectListField(ServiceType) + fhrp_group = ObjectField(FHRPGroupType) + fhrp_group_list = ObjectListField(FHRPGroupType) + + fhrp_group_assignment = ObjectField(FHRPGroupAssignmentType) + fhrp_group_assignment_list = ObjectListField(FHRPGroupAssignmentType) + vlan = ObjectField(VLANType) vlan_list = ObjectListField(VLANType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index c822dab6b..d9aec66b3 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -1,8 +1,14 @@ +import graphene + from ipam import filtersets, models -from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.scalars import BigInt +from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType __all__ = ( + 'ASNType', 'AggregateType', + 'FHRPGroupType', + 'FHRPGroupAssignmentType', 'IPAddressType', 'IPRangeType', 'PrefixType', @@ -16,6 +22,15 @@ __all__ = ( ) +class ASNType(PrimaryObjectType): + asn = graphene.Field(BigInt) + + class Meta: + model = models.ASN + fields = '__all__' + filterset_class = filtersets.ASNFilterSet + + class AggregateType(PrimaryObjectType): class Meta: @@ -24,6 +39,25 @@ class AggregateType(PrimaryObjectType): filterset_class = filtersets.AggregateFilterSet +class FHRPGroupType(PrimaryObjectType): + + class Meta: + model = models.FHRPGroup + fields = '__all__' + filterset_class = filtersets.FHRPGroupFilterSet + + def resolve_auth_type(self, info): + return self.auth_type or None + + +class FHRPGroupAssignmentType(BaseObjectType): + + class Meta: + model = models.FHRPGroupAssignment + fields = '__all__' + filterset_class = filtersets.FHRPGroupAssignmentFilterSet + + class IPAddressType(PrimaryObjectType): class Meta: diff --git a/netbox/ipam/migrations/0051_extend_tag_support.py b/netbox/ipam/migrations/0051_extend_tag_support.py new file mode 100644 index 000000000..ea31a6645 --- /dev/null +++ b/netbox/ipam/migrations/0051_extend_tag_support.py @@ -0,0 +1,30 @@ +# 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'), + ('ipam', '0050_iprange'), + ] + + operations = [ + migrations.AddField( + model_name='rir', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='role', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='vlangroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/ipam/migrations/0052_fhrpgroup.py b/netbox/ipam/migrations/0052_fhrpgroup.py new file mode 100644 index 000000000..70219543f --- /dev/null +++ b/netbox/ipam/migrations/0052_fhrpgroup.py @@ -0,0 +1,58 @@ +import django.core.serializers.json +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0064_configrevision'), + ('ipam', '0051_extend_tag_support'), + ] + + operations = [ + migrations.CreateModel( + name='FHRPGroup', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('group_id', models.PositiveSmallIntegerField()), + ('protocol', models.CharField(max_length=50)), + ('auth_type', models.CharField(blank=True, max_length=50)), + ('auth_key', models.CharField(blank=True, max_length=255)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'FHRP group', + 'ordering': ['protocol', 'group_id', 'pk'], + }, + ), + migrations.AlterField( + model_name='ipaddress', + name='assigned_object_type', + field=models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'fhrpgroup')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype'), + ), + migrations.CreateModel( + name='FHRPGroupAssignment', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('interface_id', models.PositiveIntegerField()), + ('priority', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(255)])), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ipam.fhrpgroup')), + ('interface_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ], + options={ + 'verbose_name': 'FHRP group assignment', + 'ordering': ('-priority', 'pk'), + 'unique_together': {('interface_type', 'interface_id', 'group')}, + }, + ), + ] diff --git a/netbox/ipam/migrations/0053_asn_model.py b/netbox/ipam/migrations/0053_asn_model.py new file mode 100644 index 000000000..1c7ee8e23 --- /dev/null +++ b/netbox/ipam/migrations/0053_asn_model.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.8 on 2021-11-02 16:16 + +import dcim.fields +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0004_extend_tag_support'), + ('extras', '0064_configrevision'), + ('ipam', '0052_fhrpgroup'), + ] + + operations = [ + migrations.CreateModel( + name='ASN', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('asn', dcim.fields.ASNField(unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('rir', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='asns', to='ipam.rir')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='asns', to='tenancy.tenant')), + ], + options={ + 'verbose_name': 'ASN', + 'verbose_name_plural': 'ASNs', + 'ordering': ['asn'], + }, + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index cb8b4b932..ab0e4b6ca 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -1,12 +1,16 @@ +from .fhrp import * from .ip import * from .services import * from .vlans import * from .vrfs import * __all__ = ( + 'ASN', 'Aggregate', 'IPAddress', 'IPRange', + 'FHRPGroup', + 'FHRPGroupAssignment', 'Prefix', 'RIR', 'Role', diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py new file mode 100644 index 000000000..0a099499f --- /dev/null +++ b/netbox/ipam/models/fhrp.py @@ -0,0 +1,111 @@ +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.urls import reverse + +from extras.utils import extras_features +from netbox.models import ChangeLoggedModel, PrimaryModel +from ipam.choices import * +from ipam.constants import * + +__all__ = ( + 'FHRPGroup', + 'FHRPGroupAssignment', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class FHRPGroup(PrimaryModel): + """ + A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.) + """ + group_id = models.PositiveSmallIntegerField( + verbose_name='Group ID' + ) + protocol = models.CharField( + max_length=50, + choices=FHRPGroupProtocolChoices + ) + auth_type = models.CharField( + max_length=50, + choices=FHRPGroupAuthTypeChoices, + blank=True, + verbose_name='Authentication type' + ) + auth_key = models.CharField( + max_length=255, + blank=True, + verbose_name='Authentication key' + ) + description = models.CharField( + max_length=200, + blank=True + ) + ip_addresses = GenericRelation( + to='ipam.IPAddress', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='fhrpgroup' + ) + + clone_fields = ('protocol', 'auth_type', 'auth_key') + + class Meta: + ordering = ['protocol', 'group_id', 'pk'] + verbose_name = 'FHRP group' + + def __str__(self): + name = f'{self.get_protocol_display()}: {self.group_id}' + + # Append the list of assigned IP addresses to serve as an additional identifier + if self.pk: + ip_addresses = [ + str(ip.address) for ip in self.ip_addresses.all() + ] + if ip_addresses: + return f"{name} ({', '.join(ip_addresses)})" + + return name + + def get_absolute_url(self): + return reverse('ipam:fhrpgroup', args=[self.pk]) + + +@extras_features('webhooks') +class FHRPGroupAssignment(ChangeLoggedModel): + interface_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) + interface_id = models.PositiveIntegerField() + interface = GenericForeignKey( + ct_field='interface_type', + fk_field='interface_id' + ) + group = models.ForeignKey( + to='ipam.FHRPGroup', + on_delete=models.CASCADE + ) + priority = models.PositiveSmallIntegerField( + validators=( + MinValueValidator(FHRPGROUPASSIGNMENT_PRIORITY_MIN), + MaxValueValidator(FHRPGROUPASSIGNMENT_PRIORITY_MAX) + ) + ) + + clone_fields = ('interface_type', 'interface_id') + + class Meta: + ordering = ('-priority', 'pk') + unique_together = ('interface_type', 'interface_id', 'group') + verbose_name = 'FHRP group assignment' + + def __str__(self): + return f'{self.interface}: {self.group} ({self.priority})' + + def get_absolute_url(self): + # Used primarily for redirection after creating a new assignment + if self.interface: + return self.interface.get_absolute_url() + return None diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 4fc2b5dbb..c361acd01 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,13 +1,13 @@ import netaddr -from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models -from django.db.models import F, Q +from django.db.models import F from django.urls import reverse from django.utils.functional import cached_property +from dcim.fields import ASNField from dcim.models import Device from extras.utils import extras_features from netbox.models import OrganizationalModel, PrimaryModel @@ -17,12 +17,13 @@ from ipam.fields import IPNetworkField, IPAddressField from ipam.managers import IPAddressManager from ipam.querysets import PrefixQuerySet from ipam.validators import DNSValidator -from utilities.querysets import RestrictedQuerySet +from netbox.config import get_config from virtualization.models import VirtualMachine __all__ = ( 'Aggregate', + 'ASN', 'IPAddress', 'IPRange', 'Prefix', @@ -31,7 +32,7 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RIR(OrganizationalModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address @@ -55,8 +56,6 @@ class RIR(OrganizationalModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] verbose_name = 'RIR' @@ -69,6 +68,47 @@ class RIR(OrganizationalModel): return reverse('ipam:rir', args=[self.pk]) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class ASN(PrimaryModel): + """ + An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have + one or more ASNs assigned to it. + """ + asn = ASNField( + unique=True, + verbose_name='ASN', + help_text='32-bit autonomous system number' + ) + description = models.CharField( + max_length=200, + blank=True + ) + rir = models.ForeignKey( + to='ipam.RIR', + on_delete=models.PROTECT, + related_name='asns', + verbose_name='RIR' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='asns', + blank=True, + null=True + ) + + class Meta: + ordering = ['asn'] + verbose_name = 'ASN' + verbose_name_plural = 'ASNs' + + def __str__(self): + return f'AS{self.asn}' + + def get_absolute_url(self): + return reverse('ipam:asn', args=[self.pk]) + + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Aggregate(PrimaryModel): """ @@ -98,8 +138,6 @@ class Aggregate(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'rir', 'tenant', 'date_added', 'description', ] @@ -168,7 +206,7 @@ class Aggregate(PrimaryModel): return min(utilization, 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Role(OrganizationalModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or @@ -190,8 +228,6 @@ class Role(OrganizationalModel): blank=True, ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['weight', 'name'] @@ -316,7 +352,7 @@ class Prefix(PrimaryModel): }) # Enforce unique IP space (if applicable) - if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): + if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_prefixes = self.get_duplicates() if duplicate_prefixes: raise ValidationError({ @@ -547,8 +583,6 @@ class IPRange(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'vrf', 'tenant', 'status', 'role', 'description', ] @@ -811,7 +845,7 @@ class IPAddress(PrimaryModel): }) # Enforce unique IP space (if applicable) - if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): + if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_ips = self.get_duplicates() if duplicate_ips and ( self.role not in IPADDRESS_ROLES_NONUNIQUE or diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index 9efe7fed7..5c1ebb9dd 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -8,7 +8,6 @@ from extras.utils import extras_features from ipam.choices import * from ipam.constants import * from netbox.models import PrimaryModel -from utilities.querysets import RestrictedQuerySet from utilities.utils import array_to_string @@ -65,8 +64,6 @@ class Service(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 4ba8d7041..1c1691a62 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -11,7 +11,6 @@ from ipam.choices import * from ipam.constants import * from ipam.querysets import VLANQuerySet from netbox.models import OrganizationalModel, PrimaryModel -from utilities.querysets import RestrictedQuerySet from virtualization.models import VMInterface @@ -21,7 +20,7 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VLANGroup(OrganizationalModel): """ A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. @@ -52,8 +51,6 @@ class VLANGroup(OrganizationalModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ('name', 'pk') # Name may be non-unique unique_together = [ diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index c8e703520..11fab9c44 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -4,7 +4,6 @@ from django.urls import reverse from extras.utils import extras_features from ipam.constants import * from netbox.models import PrimaryModel -from utilities.querysets import RestrictedQuerySet __all__ = ( @@ -58,8 +57,6 @@ class VRF(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'tenant', 'enforce_unique', 'description', ] @@ -100,8 +97,6 @@ class RouteTarget(PrimaryModel): null=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] diff --git a/netbox/ipam/tables/__init__.py b/netbox/ipam/tables/__init__.py index a280eac1b..6f429e27d 100644 --- a/netbox/ipam/tables/__init__.py +++ b/netbox/ipam/tables/__init__.py @@ -1,3 +1,4 @@ +from .fhrp import * from .ip import * from .services import * from .vlans import * diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py new file mode 100644 index 000000000..94bc50b93 --- /dev/null +++ b/netbox/ipam/tables/fhrp.py @@ -0,0 +1,69 @@ +import django_tables2 as tables + +from utilities.tables import BaseTable, ButtonsColumn, MarkdownColumn, TagColumn, ToggleColumn +from ipam.models import * + +__all__ = ( + 'FHRPGroupTable', + 'FHRPGroupAssignmentTable', +) + + +IPADDRESSES = """ +{% for ip in record.ip_addresses.all %} + {{ ip }}
+{% endfor %} +""" + + +class FHRPGroupTable(BaseTable): + pk = ToggleColumn() + group_id = tables.Column( + linkify=True + ) + comments = MarkdownColumn() + ip_addresses = tables.TemplateColumn( + template_code=IPADDRESSES, + orderable=False, + verbose_name='IP Addresses' + ) + interface_count = tables.Column( + verbose_name='Interfaces' + ) + tags = TagColumn( + url_name='ipam:fhrpgroup_list' + ) + + class Meta(BaseTable.Meta): + model = FHRPGroup + fields = ( + 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count', + 'tags', + ) + default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count') + + +class FHRPGroupAssignmentTable(BaseTable): + pk = ToggleColumn() + interface_parent = tables.Column( + accessor=tables.A('interface.parent_object'), + linkify=True, + orderable=False, + verbose_name='Parent' + ) + interface = tables.Column( + linkify=True, + orderable=False + ) + group = tables.Column( + linkify=True + ) + actions = ButtonsColumn( + model=FHRPGroupAssignment, + buttons=('edit', 'delete', 'foo') + ) + + class Meta(BaseTable.Meta): + model = FHRPGroupAssignment + fields = ('pk', 'group', 'interface_parent', 'interface', 'priority') + exclude = ('id',) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 3b8c55bda..3fddbf48e 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -11,7 +11,8 @@ from ipam.models import * __all__ = ( 'AggregateTable', - 'InterfaceIPAddressTable', + 'ASNTable', + 'AssignedIPAddressesTable', 'IPAddressAssignTable', 'IPAddressTable', 'IPRangeTable', @@ -85,14 +86,39 @@ class RIRTable(BaseTable): url_params={'rir_id': 'pk'}, verbose_name='Aggregates' ) + tags = TagColumn( + url_name='ipam:rir_list' + ) actions = ButtonsColumn(RIR) class Meta(BaseTable.Meta): model = RIR - fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'actions') + fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions') +# +# ASNs +# + +class ASNTable(BaseTable): + pk = ToggleColumn() + asn = tables.Column( + linkify=True + ) + site_count = LinkedCountColumn( + viewname='dcim:site_list', + url_params={'asn_id': 'pk'}, + verbose_name='Sites' + ) + actions = ButtonsColumn(ASN) + + class Meta(BaseTable.Meta): + model = ASN + fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions') + default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant', 'actions') + + # # Aggregates # @@ -144,11 +170,14 @@ class RoleTable(BaseTable): url_params={'role_id': 'pk'}, verbose_name='VLANs' ) + tags = TagColumn( + url_name='ipam:role_list' + ) actions = ButtonsColumn(Role) class Meta(BaseTable.Meta): model = Role - fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'actions') + fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions') default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions') @@ -206,6 +235,11 @@ class PrefixTable(BaseTable): site = tables.Column( linkify=True ) + vlan_group = tables.Column( + accessor='vlan__group', + linkify=True, + verbose_name='VLAN Group' + ) vlan = tables.Column( linkify=True, verbose_name='VLAN' @@ -230,8 +264,8 @@ class PrefixTable(BaseTable): class Meta(BaseTable.Meta): model = Prefix fields = ( - 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', - 'is_pool', 'mark_utilized', 'description', 'tags', + 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group', + 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', ) default_columns = ( 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description', @@ -318,7 +352,7 @@ class IPAddressTable(BaseTable): verbose_name='NAT (Inside)' ) assigned = BooleanColumn( - accessor='assigned_object', + accessor='assigned_object_id', linkify=True, verbose_name='Assigned' ) @@ -357,9 +391,9 @@ class IPAddressAssignTable(BaseTable): orderable = False -class InterfaceIPAddressTable(BaseTable): +class AssignedIPAddressesTable(BaseTable): """ - List IP addresses assigned to a specific Interface. + List IP addresses assigned to an object. """ address = tables.Column( linkify=True, diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 84b250f87..365c6119b 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -74,6 +74,9 @@ class VLANGroupTable(BaseTable): url_params={'group_id': 'pk'}, verbose_name='VLANs' ) + tags = TagColumn( + url_name='ipam:vlangroup_list' + ) actions = ButtonsColumn( model=VLANGroup, prepend_template=VLANGROUP_ADD_VLAN @@ -81,7 +84,7 @@ class VLANGroupTable(BaseTable): class Meta(BaseTable.Meta): model = VLANGroup - fields = ('pk', 'id', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions') + fields = ('pk', 'id', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions') @@ -93,7 +96,10 @@ class VLANTable(BaseTable): pk = ToggleColumn() vid = tables.TemplateColumn( template_code=VLAN_LINK, - verbose_name='ID' + verbose_name='VID' + ) + name = tables.Column( + linkify=True ) site = tables.Column( linkify=True diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 5ba45b7fd..50eb64060 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -4,10 +4,11 @@ from django.urls import reverse from netaddr import IPNetwork from rest_framework import status -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site +from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site from ipam.choices import * from ipam.models import * -from utilities.testing import APITestCase, APIViewTestCases, disable_warnings +from tenancy.models import Tenant +from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_warnings class AppTest(APITestCase): @@ -20,6 +21,58 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) +class ASNTest(APIViewTestCases.APIViewTestCase): + model = ASN + brief_fields = ['asn', 'display', 'id', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + rirs = [ + RIR.objects.create(name='RFC 6996', slug='rfc-6996', description='Private Use', is_private=True), + RIR.objects.create(name='RFC 7300', slug='rfc-7300', description='IANA Use', is_private=True), + ] + sites = [ + Site.objects.create(name='Site 1', slug='site-1'), + Site.objects.create(name='Site 2', slug='site-2') + ] + tenants = [ + Tenant.objects.create(name='Tenant 1', slug='tenant-1'), + Tenant.objects.create(name='Tenant 2', slug='tenant-2'), + ] + + asns = ( + ASN(asn=64513, rir=rirs[0], tenant=tenants[0]), + ASN(asn=65534, rir=rirs[0], tenant=tenants[1]), + ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]), + ASN(asn=4200002301, rir=rirs[1], tenant=tenants[1]), + ) + ASN.objects.bulk_create(asns) + + asns[0].sites.set([sites[0]]) + asns[1].sites.set([sites[1]]) + asns[2].sites.set([sites[0]]) + asns[3].sites.set([sites[1]]) + + cls.create_data = [ + { + 'asn': 64512, + 'rir': rirs[0].pk, + }, + { + 'asn': 65543, + 'rir': rirs[0].pk, + }, + { + 'asn': 4294967294, + 'rir': rirs[0].pk, + }, + ] + + class VRFTest(APIViewTestCases.APIViewTestCase): model = VRF brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url'] @@ -491,6 +544,126 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase): IPAddress.objects.bulk_create(ip_addresses) +class FHRPGroupTest(APIViewTestCases.APIViewTestCase): + model = FHRPGroup + brief_fields = ['display', 'group_id', 'id', 'protocol', 'url'] + bulk_update_data = { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_GLBP, + 'group_id': 200, + 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, + 'auth_key': 'foobarbaz999', + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + fhrp_groups = ( + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), + ) + FHRPGroup.objects.bulk_create(fhrp_groups) + + cls.create_data = [ + { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_VRRP2, + 'group_id': 110, + 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, + 'auth_key': 'foobar123', + }, + { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_VRRP3, + 'group_id': 120, + 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, + 'auth_key': 'barfoo456', + }, + { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_GLBP, + 'group_id': 130, + }, + ] + + +class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase): + model = FHRPGroupAssignment + brief_fields = ['display', 'group_id', 'id', 'interface_id', 'interface_type', 'priority', 'url'] + bulk_update_data = { + 'priority': 100, + } + + @classmethod + def setUpTestData(cls): + + device1 = create_test_device('device1') + device2 = create_test_device('device2') + device3 = create_test_device('device3') + + interfaces = ( + Interface(device=device1, name='eth0', type='other'), + Interface(device=device1, name='eth1', type='other'), + Interface(device=device1, name='eth2', type='other'), + Interface(device=device2, name='eth0', type='other'), + Interface(device=device2, name='eth1', type='other'), + Interface(device=device2, name='eth2', type='other'), + Interface(device=device3, name='eth0', type='other'), + Interface(device=device3, name='eth1', type='other'), + Interface(device=device3, name='eth2', type='other'), + ) + Interface.objects.bulk_create(interfaces) + + ip_addresses = ( + IPAddress(address=IPNetwork('192.168.0.2/24'), assigned_object=interfaces[0]), + IPAddress(address=IPNetwork('192.168.1.2/24'), assigned_object=interfaces[1]), + IPAddress(address=IPNetwork('192.168.2.2/24'), assigned_object=interfaces[2]), + IPAddress(address=IPNetwork('192.168.0.3/24'), assigned_object=interfaces[3]), + IPAddress(address=IPNetwork('192.168.1.3/24'), assigned_object=interfaces[4]), + IPAddress(address=IPNetwork('192.168.2.3/24'), assigned_object=interfaces[5]), + IPAddress(address=IPNetwork('192.168.0.4/24'), assigned_object=interfaces[6]), + IPAddress(address=IPNetwork('192.168.1.4/24'), assigned_object=interfaces[7]), + IPAddress(address=IPNetwork('192.168.2.4/24'), assigned_object=interfaces[8]), + ) + IPAddress.objects.bulk_create(ip_addresses) + + fhrp_groups = ( + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=20), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=30), + ) + FHRPGroup.objects.bulk_create(fhrp_groups) + + fhrp_group_assignments = ( + FHRPGroupAssignment(group=fhrp_groups[0], interface=interfaces[0], priority=10), + FHRPGroupAssignment(group=fhrp_groups[1], interface=interfaces[1], priority=10), + FHRPGroupAssignment(group=fhrp_groups[2], interface=interfaces[2], priority=10), + FHRPGroupAssignment(group=fhrp_groups[0], interface=interfaces[3], priority=20), + FHRPGroupAssignment(group=fhrp_groups[1], interface=interfaces[4], priority=20), + FHRPGroupAssignment(group=fhrp_groups[2], interface=interfaces[5], priority=20), + ) + FHRPGroupAssignment.objects.bulk_create(fhrp_group_assignments) + + cls.create_data = [ + { + 'group': fhrp_groups[0].pk, + 'interface_type': 'dcim.interface', + 'interface_id': interfaces[6].pk, + 'priority': 30, + }, + { + 'group': fhrp_groups[1].pk, + 'interface_type': 'dcim.interface', + 'interface_id': interfaces[7].pk, + 'priority': 30, + }, + { + 'group': fhrp_groups[2].pk, + 'interface_type': 'dcim.interface', + 'interface_id': interfaces[8].pk, + 'priority': 30, + }, + ] + + class VLANGroupTest(APIViewTestCases.APIViewTestCase): model = VLANGroup brief_fields = ['display', 'id', 'name', 'slug', 'url', 'vlan_count'] diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index ff9dbfece..773737dea 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1,14 +1,92 @@ from django.test import TestCase +from netaddr import IPNetwork from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup from ipam.choices import * from ipam.filtersets import * from ipam.models import * -from utilities.testing import ChangeLoggedFilterSetTests +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from tenancy.models import Tenant, TenantGroup +class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ASN.objects.all() + filterset = ASNFilterSet + + @classmethod + def setUpTestData(cls): + + rirs = [ + RIR.objects.create(name='RFC 6996', slug='rfc-6996', description='Private Use', is_private=True), + RIR.objects.create(name='RFC 7300', slug='rfc-7300', description='IANA Use', is_private=True), + ] + sites = [ + Site.objects.create(name='Site 1', slug='site-1'), + Site.objects.create(name='Site 2', slug='site-2'), + Site.objects.create(name='Site 3', slug='site-3') + ] + tenants = [ + Tenant.objects.create(name='Tenant 1', slug='tenant-1'), + Tenant.objects.create(name='Tenant 2', slug='tenant-2'), + Tenant.objects.create(name='Tenant 3', slug='tenant-3'), + Tenant.objects.create(name='Tenant 4', slug='tenant-4'), + Tenant.objects.create(name='Tenant 5', slug='tenant-5'), + ] + + asns = ( + ASN(asn=64512, rir=rirs[0], tenant=tenants[0]), + ASN(asn=64513, rir=rirs[0], tenant=tenants[0]), + ASN(asn=64514, rir=rirs[0], tenant=tenants[1]), + ASN(asn=64515, rir=rirs[0], tenant=tenants[2]), + ASN(asn=64516, rir=rirs[0], tenant=tenants[3]), + ASN(asn=65535, rir=rirs[1], tenant=tenants[4]), + ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]), + ASN(asn=4200000001, rir=rirs[0], tenant=tenants[1]), + ASN(asn=4200000002, rir=rirs[0], tenant=tenants[2]), + ASN(asn=4200000003, rir=rirs[0], tenant=tenants[3]), + ASN(asn=4200002301, rir=rirs[1], tenant=tenants[4]), + ) + ASN.objects.bulk_create(asns) + + asns[0].sites.set([sites[0]]) + asns[1].sites.set([sites[0]]) + asns[2].sites.set([sites[1]]) + asns[3].sites.set([sites[2]]) + asns[4].sites.set([sites[0]]) + asns[5].sites.set([sites[1]]) + asns[6].sites.set([sites[0]]) + asns[7].sites.set([sites[1]]) + asns[8].sites.set([sites[2]]) + asns[9].sites.set([sites[0]]) + asns[10].sites.set([sites[1]]) + + def test_asn(self): + params = {'asn': ['64512', '65535']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_tenant(self): + tenants = Tenant.objects.all()[:2] + params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + + def test_rir(self): + rirs = RIR.objects.all()[:1] + params = {'rir_id': [rirs[0].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) + params = {'rir': [rirs[0].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) + + def test_site(self): + sites = Site.objects.all()[:2] + params = {'site_id': [sites[0].pk, sites[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) + params = {'site': [sites[0].slug, sites[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) + + class VRFTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VRF.objects.all() filterset = VRFFilterSet @@ -795,6 +873,115 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) +class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = FHRPGroup.objects.all() + filterset = FHRPGroupFilterSet + + @classmethod + def setUpTestData(cls): + + ip_addresses = ( + IPAddress(address=IPNetwork('192.168.1.1/24')), + IPAddress(address=IPNetwork('192.168.2.1/24')), + IPAddress(address=IPNetwork('192.168.3.1/24')), + ) + IPAddress.objects.bulk_create(ip_addresses) + + fhrp_groups = ( + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foo123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='bar456'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), + ) + FHRPGroup.objects.bulk_create(fhrp_groups) + fhrp_groups[0].ip_addresses.set([ip_addresses[0]]) + fhrp_groups[1].ip_addresses.set([ip_addresses[1]]) + fhrp_groups[2].ip_addresses.set([ip_addresses[2]]) + + def test_protocol(self): + params = {'protocol': [FHRPGroupProtocolChoices.PROTOCOL_VRRP2, FHRPGroupProtocolChoices.PROTOCOL_VRRP3]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_group_id(self): + params = {'group_id': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_type(self): + params = {'auth_type': [FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_key(self): + params = {'auth_key': ['foo123', 'bar456']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_related_ip(self): + # Create some regular IPs to query for related IPs + ipaddresses = ( + IPAddress.objects.create(address=IPNetwork('192.168.1.2/24')), + IPAddress.objects.create(address=IPNetwork('192.168.2.2/24')), + ) + params = {'related_ip': [ipaddresses[0].pk, ipaddresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = FHRPGroupAssignment.objects.all() + filterset = FHRPGroupAssignmentFilterSet + + @classmethod + def setUpTestData(cls): + + device = create_test_device('device1') + interfaces = ( + Interface(device=device, name='eth0'), + Interface(device=device, name='eth1'), + Interface(device=device, name='eth2'), + ) + Interface.objects.bulk_create(interfaces) + + virtual_machine = create_test_virtualmachine('virtual_machine1') + vm_interfaces = ( + VMInterface(virtual_machine=virtual_machine, name='eth0'), + VMInterface(virtual_machine=virtual_machine, name='eth1'), + VMInterface(virtual_machine=virtual_machine, name='eth2'), + ) + VMInterface.objects.bulk_create(vm_interfaces) + + fhrp_groups = ( + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), + ) + FHRPGroup.objects.bulk_create(fhrp_groups) + + fhrp_group_assignments = ( + FHRPGroupAssignment(group=fhrp_groups[0], interface=interfaces[0], priority=10), + FHRPGroupAssignment(group=fhrp_groups[1], interface=interfaces[1], priority=20), + FHRPGroupAssignment(group=fhrp_groups[2], interface=interfaces[2], priority=30), + FHRPGroupAssignment(group=fhrp_groups[0], interface=vm_interfaces[0], priority=10), + FHRPGroupAssignment(group=fhrp_groups[1], interface=vm_interfaces[1], priority=20), + FHRPGroupAssignment(group=fhrp_groups[2], interface=vm_interfaces[2], priority=30), + ) + FHRPGroupAssignment.objects.bulk_create(fhrp_group_assignments) + + def test_group_id(self): + fhrp_groups = FHRPGroup.objects.all()[:2] + params = {'group_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_interface_type(self): + params = {'interface_type': 'dcim.interface'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_interface(self): + interfaces = Interface.objects.all()[:2] + params = {'interface_type': 'dcim.interface', 'interface_id': [interfaces[0].pk, interfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_priority(self): + params = {'priority': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VLANGroup.objects.all() filterset = VLANGroupFilterSet diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 2a0bfdf32..83de73bde 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -9,6 +9,61 @@ from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags +class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = ASN + + @classmethod + def setUpTestData(cls): + + rirs = [ + RIR.objects.create(name='RFC 6996', slug='rfc-6996', description='Private Use', is_private=True), + RIR.objects.create(name='RFC 7300', slug='rfc-7300', description='IANA Use', is_private=True), + ] + sites = [ + Site.objects.create(name='Site 1', slug='site-1'), + Site.objects.create(name='Site 2', slug='site-2') + ] + tenants = [ + Tenant.objects.create(name='Tenant 1', slug='tenant-1'), + Tenant.objects.create(name='Tenant 2', slug='tenant-2'), + ] + + asns = ( + ASN(asn=64513, rir=rirs[0], tenant=tenants[0]), + ASN(asn=65535, rir=rirs[1], tenant=tenants[1]), + ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]), + ASN(asn=4200002301, rir=rirs[1], tenant=tenants[1]), + ) + ASN.objects.bulk_create(asns) + + asns[0].sites.set([sites[0]]) + asns[1].sites.set([sites[1]]) + asns[2].sites.set([sites[0]]) + asns[3].sites.set([sites[1]]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'asn': 64512, + 'rir': rirs[0].pk, + 'tenant': tenants[0].pk, + 'site': sites[0].pk, + 'description': 'A new ASN', + } + + cls.csv_data = ( + "asn,rir", + "64533,RFC 6996", + "64523,RFC 6996", + "4200000002,RFC 6996", + ) + + cls.bulk_edit_data = { + 'rir': rirs[1].pk, + 'description': 'Next description', + } + + class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VRF @@ -104,11 +159,14 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): RIR(name='RIR 3', slug='rir-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'RIR X', 'slug': 'rir-x', 'is_private': True, 'description': 'A new RIR', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -177,11 +235,14 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): Role(name='Role 3', slug='role-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Role X', 'slug': 'role-x', 'weight': 200, 'description': 'A new role', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -366,6 +427,41 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class FHRPGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = FHRPGroup + + @classmethod + def setUpTestData(cls): + + FHRPGroup.objects.bulk_create(( + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), + )) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_VRRP2, + 'group_id': 99, + 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, + 'auth_key': 'abc123def456', + 'description': 'Blah blah blah', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "protocol,group_id,auth_type,auth_key,description", + "vrrp2,40,plaintext,foobar123,Foo", + "vrrp3,50,md5,foobar123,Bar", + "hsrp,60,,,", + ) + + cls.bulk_edit_data = { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_CARP, + } + + class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = VLANGroup @@ -384,10 +480,13 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'VLAN Group X', 'slug': 'vlan-group-x', 'description': 'A new VLAN group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 9d9a846bf..541acb3ac 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -7,6 +7,18 @@ from .models import * app_name = 'ipam' urlpatterns = [ + # ASNs + path('asns/', views.ASNListView.as_view(), name='asn_list'), + path('asns/add/', views.ASNEditView.as_view(), name='asn_add'), + path('asns/import/', views.ASNBulkImportView.as_view(), name='asn_import'), + path('asns/edit/', views.ASNBulkEditView.as_view(), name='asn_bulk_edit'), + path('asns/delete/', views.ASNBulkDeleteView.as_view(), name='asn_bulk_delete'), + path('asns//', views.ASNView.as_view(), name='asn'), + path('asns//edit/', views.ASNEditView.as_view(), name='asn_edit'), + path('asns//delete/', views.ASNDeleteView.as_view(), name='asn_delete'), + path('asns//changelog/', ObjectChangeLogView.as_view(), name='asn_changelog', kwargs={'model': ASN}), + path('asns//journal/', ObjectJournalView.as_view(), name='asn_journal', kwargs={'model': ASN}), + # VRFs path('vrfs/', views.VRFListView.as_view(), name='vrf_list'), path('vrfs/add/', views.VRFEditView.as_view(), name='vrf_add'), @@ -107,6 +119,23 @@ urlpatterns = [ path('ip-addresses//edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), path('ip-addresses//delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), + # FHRP groups + path('fhrp-groups/', views.FHRPGroupListView.as_view(), name='fhrpgroup_list'), + path('fhrp-groups/add/', views.FHRPGroupEditView.as_view(), name='fhrpgroup_add'), + path('fhrp-groups/import/', views.FHRPGroupBulkImportView.as_view(), name='fhrpgroup_import'), + path('fhrp-groups/edit/', views.FHRPGroupBulkEditView.as_view(), name='fhrpgroup_bulk_edit'), + path('fhrp-groups/delete/', views.FHRPGroupBulkDeleteView.as_view(), name='fhrpgroup_bulk_delete'), + path('fhrp-groups//', views.FHRPGroupView.as_view(), name='fhrpgroup'), + path('fhrp-groups//edit/', views.FHRPGroupEditView.as_view(), name='fhrpgroup_edit'), + path('fhrp-groups//delete/', views.FHRPGroupDeleteView.as_view(), name='fhrpgroup_delete'), + path('fhrp-groups//changelog/', ObjectChangeLogView.as_view(), name='fhrpgroup_changelog', kwargs={'model': FHRPGroup}), + path('fhrp-groups//journal/', ObjectJournalView.as_view(), name='fhrpgroup_journal', kwargs={'model': FHRPGroup}), + + # FHRP group assignments + path('fhrp-group-assignments/add/', views.FHRPGroupAssignmentEditView.as_view(), name='fhrpgroupassignment_add'), + path('fhrp-group-assignments//edit/', views.FHRPGroupAssignmentEditView.as_view(), name='fhrpgroupassignment_edit'), + path('fhrp-group-assignments//delete/', views.FHRPGroupAssignmentDeleteView.as_view(), name='fhrpgroupassignment_delete'), + # VLAN groups path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), path('vlan-groups/add/', views.VLANGroupEditView.as_view(), name='vlangroup_add'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 337c5cf8f..46d0131d4 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,19 +1,24 @@ +from django.contrib.contenttypes.models import ContentType from django.db.models import Prefetch from django.db.models.expressions import RawSQL +from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse -from dcim.models import Device, Interface +from dcim.models import Device, Interface, Site +from dcim.tables import SiteTable from netbox.views import generic -from utilities.forms import TableConfigForm from utilities.tables import paginate_table from utilities.utils import count_related from virtualization.models import VirtualMachine, VMInterface from . import filtersets, forms, tables from .constants import * from .models import * +from .models import ASN from .utils import add_available_ipaddresses, add_requested_prefixes, add_available_vlans + # # VRFs # @@ -195,6 +200,65 @@ class RIRBulkDeleteView(generic.BulkDeleteView): table = tables.RIRTable +# +# ASNs +# + +class ASNListView(generic.ObjectListView): + queryset = ASN.objects.annotate( + site_count=count_related(Site, 'asns'), + ) + filterset = filtersets.ASNFilterSet + filterset_form = forms.ASNFilterForm + table = tables.ASNTable + + +class ASNView(generic.ObjectView): + queryset = ASN.objects.all() + + def get_extra_context(self, request, instance): + sites = instance.sites.restrict(request.user, 'view') + sites_table = SiteTable(sites) + paginate_table(sites_table, request) + + return { + 'sites_table': sites_table, + 'sites_count': sites.count() + } + + +class ASNEditView(generic.ObjectEditView): + queryset = ASN.objects.all() + model_form = forms.ASNForm + + +class ASNDeleteView(generic.ObjectDeleteView): + queryset = ASN.objects.all() + + +class ASNBulkImportView(generic.BulkImportView): + queryset = ASN.objects.all() + model_form = forms.ASNCSVForm + table = tables.ASNTable + + +class ASNBulkEditView(generic.BulkEditView): + queryset = ASN.objects.annotate( + site_count=count_related(Site, 'asns') + ) + filterset = filtersets.ASNFilterSet + table = tables.ASNTable + form = forms.ASNBulkEditForm + + +class ASNBulkDeleteView(generic.BulkDeleteView): + queryset = ASN.objects.annotate( + site_count=count_related(Site, 'asns') + ) + filterset = filtersets.ASNFilterSet + table = tables.ASNTable + + # # Aggregates # @@ -460,9 +524,7 @@ class PrefixIPAddressesView(generic.ObjectView): def get_extra_context(self, request, instance): # Find all IPAddresses belonging to this Prefix - ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related( - 'vrf', 'primary_ip4_for', 'primary_ip6_for' - ) + ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf') # Add available IP addresses to the table if requested if request.GET.get('show_available', 'true') == 'true': @@ -541,9 +603,7 @@ class IPRangeIPAddressesView(generic.ObjectView): def get_extra_context(self, request, instance): # Find all IPAddresses within this range - ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related( - 'vrf', 'primary_ip4_for', 'primary_ip6_for' - ) + ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf') # Add available IP addresses to the table if requested # if request.GET.get('show_available', 'true') == 'true': @@ -676,6 +736,12 @@ class IPAddressEditView(generic.ObjectEditView): except (ValueError, VMInterface.DoesNotExist): pass + elif 'fhrpgroup' in request.GET: + try: + obj.assigned_object = FHRPGroup.objects.get(pk=request.GET['fhrpgroup']) + except (ValueError, FHRPGroup.DoesNotExist): + pass + return obj @@ -825,6 +891,103 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView): table = tables.VLANGroupTable +# +# FHRP groups +# + +class FHRPGroupListView(generic.ObjectListView): + queryset = FHRPGroup.objects.annotate( + member_count=count_related(FHRPGroupAssignment, 'group') + ) + filterset = filtersets.FHRPGroupFilterSet + filterset_form = forms.FHRPGroupFilterForm + table = tables.FHRPGroupTable + + +class FHRPGroupView(generic.ObjectView): + queryset = FHRPGroup.objects.all() + + def get_extra_context(self, request, instance): + # Get assigned IP addresses + ipaddress_table = tables.AssignedIPAddressesTable( + data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), + orderable=False + ) + + # Get assigned interfaces + members_table = tables.FHRPGroupAssignmentTable( + data=FHRPGroupAssignment.objects.restrict(request.user, 'view').filter(group=instance), + orderable=False + ) + members_table.columns.hide('group') + + return { + 'ipaddress_table': ipaddress_table, + 'members_table': members_table, + 'member_count': FHRPGroupAssignment.objects.filter(group=instance).count(), + } + + +class FHRPGroupEditView(generic.ObjectEditView): + queryset = FHRPGroup.objects.all() + model_form = forms.FHRPGroupForm + template_name = 'ipam/fhrpgroup_edit.html' + + def get_return_url(self, request, obj=None): + return_url = super().get_return_url(request, obj) + + # If we're redirecting the user to the FHRPGroupAssignment creation form, + # initialize the group field with the FHRPGroup we just saved. + if return_url.startswith(reverse('ipam:fhrpgroupassignment_add')): + return_url += f'&group={obj.pk}' + + return return_url + + +class FHRPGroupDeleteView(generic.ObjectDeleteView): + queryset = FHRPGroup.objects.all() + + +class FHRPGroupBulkImportView(generic.BulkImportView): + queryset = FHRPGroup.objects.all() + model_form = forms.FHRPGroupCSVForm + table = tables.FHRPGroupTable + + +class FHRPGroupBulkEditView(generic.BulkEditView): + queryset = FHRPGroup.objects.all() + filterset = filtersets.FHRPGroupFilterSet + table = tables.FHRPGroupTable + form = forms.FHRPGroupBulkEditForm + + +class FHRPGroupBulkDeleteView(generic.BulkDeleteView): + queryset = FHRPGroup.objects.all() + filterset = filtersets.FHRPGroupFilterSet + table = tables.FHRPGroupTable + + +# +# FHRP group assignments +# + +class FHRPGroupAssignmentEditView(generic.ObjectEditView): + queryset = FHRPGroupAssignment.objects.all() + model_form = forms.FHRPGroupAssignmentForm + template_name = 'ipam/fhrpgroupassignment_edit.html' + + def alter_obj(self, instance, request, args, kwargs): + if not instance.pk: + # Assign the interface based on URL kwargs + content_type = get_object_or_404(ContentType, pk=request.GET.get('interface_type')) + instance.interface = get_object_or_404(content_type.model_class(), pk=request.GET.get('interface_id')) + return instance + + +class FHRPGroupAssignmentDeleteView(generic.ObjectDeleteView): + queryset = FHRPGroupAssignment.objects.all() + + # # VLANs # diff --git a/netbox/netbox/__init__.py b/netbox/netbox/__init__.py index e69de29bb..5cf431025 100644 --- a/netbox/netbox/__init__.py +++ b/netbox/netbox/__init__.py @@ -0,0 +1,3 @@ +import threading + +thread_locals = threading.local() diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 7f8bee318..5e177bfcb 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -29,10 +29,13 @@ class TokenAuthentication(authentication.TokenAuthentication): if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend': from netbox.authentication import LDAPBackend ldap_backend = LDAPBackend() - user = ldap_backend.populate_user(token.user.username) - # If the user is found in the LDAP directory use it, if not fallback to the local user - if user: - return user, token + + # Load from LDAP if FIND_GROUP_PERMS is active + if ldap_backend.settings.FIND_GROUP_PERMS: + user = ldap_backend.populate_user(token.user.username) + # If the user is found in the LDAP directory use it, if not fallback to the local user + if user: + return user, token return token.user, token diff --git a/netbox/netbox/api/pagination.py b/netbox/netbox/api/pagination.py index e34cb27d0..d89e32124 100644 --- a/netbox/netbox/api/pagination.py +++ b/netbox/netbox/api/pagination.py @@ -1,7 +1,8 @@ -from django.conf import settings from django.db.models import QuerySet from rest_framework.pagination import LimitOffsetPagination +from netbox.config import get_config + class OptionalLimitOffsetPagination(LimitOffsetPagination): """ @@ -9,6 +10,8 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): matching a query, but retains the same format as a paginated request. The limit can only be disabled if MAX_PAGE_SIZE has been set to 0 or None. """ + def __init__(self): + self.default_limit = get_config().PAGINATE_COUNT def paginate_queryset(self, queryset, request, view=None): @@ -40,11 +43,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): if limit < 0: raise ValueError() # Enforce maximum page size, if defined - if settings.MAX_PAGE_SIZE: - if limit == 0: - return settings.MAX_PAGE_SIZE - else: - return min(limit, settings.MAX_PAGE_SIZE) + MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE + if MAX_PAGE_SIZE: + return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE) return limit except (KeyError, ValueError): pass diff --git a/netbox/netbox/api/serializers.py b/netbox/netbox/api/serializers.py index d17751e25..4c26dbada 100644 --- a/netbox/netbox/api/serializers.py +++ b/netbox/netbox/api/serializers.py @@ -147,13 +147,6 @@ class NestedTagSerializer(WritableNestedSerializer): # Base model serializers # -class OrganizationalModelSerializer(CustomFieldModelSerializer): - """ - Adds support for custom fields. - """ - pass - - class PrimaryModelSerializer(CustomFieldModelSerializer): """ Adds support for custom fields and tags. @@ -182,16 +175,16 @@ class PrimaryModelSerializer(CustomFieldModelSerializer): def _save_tags(self, instance, tags): if tags: - instance.tags.set(*[t.name for t in tags]) + instance.tags.set([t.name for t in tags]) else: instance.tags.clear() return instance -class NestedGroupModelSerializer(CustomFieldModelSerializer): +class NestedGroupModelSerializer(PrimaryModelSerializer): """ - Extends OrganizationalModelSerializer to include MPTT support. + Extends PrimaryModelSerializer to include MPTT support. """ _depth = serializers.IntegerField(source='level', read_only=True) diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 74000e978..7ad64aeae 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -308,6 +308,7 @@ class APIRootView(APIView): ('tenancy', reverse('tenancy-api:api-root', request=request, format=format)), ('users', reverse('users-api:api-root', request=request, format=format)), ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)), + ('wireless', reverse('wireless-api:api-root', request=request, format=format)), ))) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 653fad3b0..a67ec451d 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -34,7 +34,7 @@ class ObjectPermissionMixin(): object_permissions = ObjectPermission.objects.filter( self.get_permission_filter(user_obj), enabled=True - ).prefetch_related('object_types') + ).order_by('id').distinct('id').prefetch_related('object_types') # Create a dictionary mapping permissions to their constraints perms = defaultdict(list) diff --git a/netbox/netbox/config/__init__.py b/netbox/netbox/config/__init__.py new file mode 100644 index 000000000..a9a93636c --- /dev/null +++ b/netbox/netbox/config/__init__.py @@ -0,0 +1,105 @@ +import logging +import threading + +from django.conf import settings +from django.core.cache import cache +from django.db.utils import DatabaseError + +from .parameters import PARAMS + +__all__ = ( + 'clear_config', + 'ConfigItem', + 'get_config', + 'PARAMS', +) + +_thread_locals = threading.local() + +logger = logging.getLogger('netbox.config') + + +def get_config(): + """ + Return the current NetBox configuration, pulling it from cache if not already loaded in memory. + """ + if not hasattr(_thread_locals, 'config'): + _thread_locals.config = Config() + logger.debug("Initialized configuration") + return _thread_locals.config + + +def clear_config(): + """ + Delete the currently loaded configuration, if any. + """ + if hasattr(_thread_locals, 'config'): + del _thread_locals.config + logger.debug("Cleared configuration") + + +class Config: + """ + Fetch and store in memory the current NetBox configuration. This class must be instantiated prior to access, and + must be re-instantiated each time it's necessary to check for updates to the cached config. + """ + def __init__(self): + self._populate_from_cache() + if not self.config or not self.version: + self._populate_from_db() + self.defaults = {param.name: param.default for param in PARAMS} + + def __getattr__(self, item): + + # Check for hard-coded configuration in settings.py + if hasattr(settings, item): + return getattr(settings, item) + + # Return config value from cache + if item in self.config: + return self.config[item] + + # Fall back to the parameter's default value + if item in self.defaults: + return self.defaults[item] + + raise AttributeError(f"Invalid configuration parameter: {item}") + + def _populate_from_cache(self): + """Populate config data from Redis cache""" + self.config = cache.get('config') or {} + self.version = cache.get('config_version') + if self.config: + logger.debug("Loaded configuration data from cache") + + def _populate_from_db(self): + """Cache data from latest ConfigRevision, then populate from cache""" + from extras.models import ConfigRevision + + try: + revision = ConfigRevision.objects.last() + if revision is None: + logger.debug("No previous configuration found in database; proceeding with default values") + return + logger.debug("Loaded configuration data from database") + except DatabaseError: + # The database may not be available yet (e.g. when running a management command) + logger.warning(f"Skipping config initialization (database unavailable)") + return + + revision.activate() + logger.debug("Filled cache with data from latest ConfigRevision") + self._populate_from_cache() + + +class ConfigItem: + """ + A callable to retrieve a configuration parameter from the cache. This can serve as a placeholder to defer + referencing a configuration parameter. + """ + def __init__(self, item): + self.item = item + + def __call__(self): + config = get_config() + return getattr(config, self.item) diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py new file mode 100644 index 000000000..b4f16bf28 --- /dev/null +++ b/netbox/netbox/config/parameters.py @@ -0,0 +1,163 @@ +from django import forms +from django.contrib.postgres.forms import SimpleArrayField + + +class ConfigParam: + + def __init__(self, name, label, default, description='', field=None, field_kwargs=None): + self.name = name + self.label = label + self.default = default + self.field = field or forms.CharField + self.description = description + self.field_kwargs = field_kwargs or {} + + +PARAMS = ( + + # Banners + ConfigParam( + name='BANNER_LOGIN', + label='Login banner', + default='', + description="Additional content to display on the login page" + ), + ConfigParam( + name='BANNER_TOP', + label='Top banner', + default='', + description="Additional content to display at the top of every page" + ), + ConfigParam( + name='BANNER_BOTTOM', + label='Bottom banner', + default='', + description="Additional content to display at the bottom of every page" + ), + + # IPAM + ConfigParam( + name='ENFORCE_GLOBAL_UNIQUE', + label='Globally unique IP space', + default=False, + description="Enforce unique IP addressing within the global table", + field=forms.BooleanField + ), + ConfigParam( + name='PREFER_IPV4', + label='Prefer IPv4', + default=False, + description="Prefer IPv4 addresses over IPv6", + field=forms.BooleanField + ), + + # Racks + ConfigParam( + name='RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', + label='Rack unit height', + default=22, + description="Default unit height for rendered rack elevations", + field=forms.IntegerField + ), + ConfigParam( + name='RACK_ELEVATION_DEFAULT_UNIT_WIDTH', + label='Rack unit width', + default=220, + description="Default unit width for rendered rack elevations", + field=forms.IntegerField + ), + + # Security + ConfigParam( + name='ALLOWED_URL_SCHEMES', + label='Allowed URL schemes', + default=( + 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', + 'xmpp', + ), + description="Permitted schemes for URLs in user-provided content", + field=SimpleArrayField, + field_kwargs={'base_field': forms.CharField()} + ), + + # Pagination + ConfigParam( + name='PAGINATE_COUNT', + label='Default page size', + default=50, + field=forms.IntegerField + ), + ConfigParam( + name='MAX_PAGE_SIZE', + label='Maximum page size', + default=1000, + field=forms.IntegerField + ), + + # Validation + ConfigParam( + name='CUSTOM_VALIDATORS', + label='Custom validators', + default={}, + description="Custom validation rules (JSON)", + field=forms.JSONField + ), + + # NAPALM + ConfigParam( + name='NAPALM_USERNAME', + label='NAPALM username', + default='', + description="Username to use when connecting to devices via NAPALM" + ), + ConfigParam( + name='NAPALM_PASSWORD', + label='NAPALM password', + default='', + description="Password to use when connecting to devices via NAPALM" + ), + ConfigParam( + name='NAPALM_TIMEOUT', + label='NAPALM timeout', + default=30, + description="NAPALM connection timeout (in seconds)", + field=forms.IntegerField + ), + ConfigParam( + name='NAPALM_ARGS', + label='NAPALM arguments', + default={}, + description="Additional arguments to pass when invoking a NAPALM driver (as JSON data)", + field=forms.JSONField + ), + + # Miscellaneous + ConfigParam( + name='MAINTENANCE_MODE', + label='Maintenance mode', + default=False, + description="Enable maintenance mode", + field=forms.BooleanField + ), + ConfigParam( + name='GRAPHQL_ENABLED', + label='GraphQL enabled', + default=True, + description="Enable the GraphQL API", + field=forms.BooleanField + ), + ConfigParam( + name='CHANGELOG_RETENTION', + label='Changelog retention', + default=90, + description="Days to retain changelog history (set to zero for unlimited)", + field=forms.IntegerField + ), + ConfigParam( + name='MAPS_URL', + label='Maps URL', + default='https://maps.google.com/?q=', + description="Base URL for mapping geographic locations" + ), + +) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 03023740f..8130acb2e 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -72,26 +72,10 @@ ADMINS = [ # ('John Doe', 'jdoe@example.com'), ] -# URL schemes that are allowed within links in NetBox -ALLOWED_URL_SCHEMES = ( - 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', -) - -# Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same -# content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP. -BANNER_TOP = '' -BANNER_BOTTOM = '' - -# Text to include on the login page above the login form. HTML is allowed. -BANNER_LOGIN = '' - # Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set: # BASE_PATH = 'netbox/' BASE_PATH = '' -# Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90) -CHANGELOG_RETENTION = 90 - # API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be # allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or # CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers @@ -103,20 +87,6 @@ CORS_ORIGIN_REGEX_WHITELIST = [ # r'^(https?://)?(\w+\.)?example\.com$', ] -# Specify any custom validators here, as a mapping of model to a list of validators classes. Validators should be -# instances of or inherit from CustomValidator. -# from extras.validators import CustomValidator -CUSTOM_VALIDATORS = { - # 'dcim.site': [ - # CustomValidator({ - # 'name': { - # 'min_length': 10, - # 'regex': r'\d{3}$', - # } - # }) - # ], -} - # Set to True to enable server debugging. WARNING: Debugging introduces a substantial performance penalty and may reveal # sensitive information about your installation. Only enable debugging while performing testing. Never enable debugging # on a production system. @@ -134,10 +104,6 @@ EMAIL = { 'FROM_EMAIL': '', } -# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table -# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True. -ENFORCE_GLOBAL_UNIQUE = False - # Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and # by anonymous users. List models in the form `.`. Add '*' to this list to exempt all models. EXEMPT_VIEW_PERMISSIONS = [ @@ -146,9 +112,6 @@ EXEMPT_VIEW_PERMISSIONS = [ # 'ipam.prefix', ] -# Enable the GraphQL API -GRAPHQL_ENABLED = True - # HTTP proxies NetBox should use when sending outbound HTTP requests (e.g. for webhooks). # HTTP_PROXIES = { # 'http': 'http://10.10.1.10:3128', @@ -175,17 +138,6 @@ LOGIN_REQUIRED = False # re-authenticate. (Default: 1209600 [14 days]) LOGIN_TIMEOUT = None -# Setting this to True will display a "maintenance mode" banner at the top of every page. -MAINTENANCE_MODE = False - -# The URL to use when mapping physical addresses or GPS coordinates -MAPS_URL = 'https://maps.google.com/?q=' - -# An API consumer can request an arbitrary number of objects =by appending the "limit" parameter to the URL (e.g. -# "?limit=1000"). This setting defines the maximum limit. Setting it to 0 or None will allow an API consumer to request -# all objects by specifying "?limit=0". -MAX_PAGE_SIZE = 1000 - # The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that # the default value of this setting is derived from the installed location. # MEDIA_ROOT = '/opt/netbox/netbox/media' @@ -203,20 +155,6 @@ MAX_PAGE_SIZE = 1000 # Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics' METRICS_ENABLED = False -# Credentials that NetBox will uses to authenticate to devices when connecting via NAPALM. -NAPALM_USERNAME = '' -NAPALM_PASSWORD = '' - -# NAPALM timeout (in seconds). (Default: 30) -NAPALM_TIMEOUT = 30 - -# NAPALM optional arguments (see https://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must -# be provided as a dictionary. -NAPALM_ARGS = {} - -# Determine how many objects to display per page within a list. (Default: 50) -PAGINATE_COUNT = 50 - # Enable installed plugins. Add the name of each plugin to the list. PLUGINS = [] @@ -229,14 +167,6 @@ PLUGINS = [] # } # } -# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to -# prefer IPv4 instead. -PREFER_IPV4 = False - -# Rack elevation size defaults, in pixels. For best results, the ratio of width to height should be roughly 10:1. -RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = 22 -RACK_ELEVATION_DEFAULT_UNIT_WIDTH = 220 - # Remote authentication support REMOTE_AUTH_ENABLED = False REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend' diff --git a/netbox/netbox/context_processors.py b/netbox/netbox/context_processors.py index d6dd67d99..74178ceb4 100644 --- a/netbox/netbox/context_processors.py +++ b/netbox/netbox/context_processors.py @@ -1,6 +1,7 @@ from django.conf import settings as django_settings from extras.registry import registry +from netbox.config import get_config def settings_and_registry(request): @@ -9,6 +10,7 @@ def settings_and_registry(request): """ return { 'settings': django_settings, + 'config': get_config(), 'registry': registry, 'preferences': request.user.config if request.user.is_authenticated else {}, } diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 791c21d19..f42ab064b 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -2,19 +2,19 @@ import django_filters from copy import deepcopy from django.contrib.contenttypes.models import ContentType from django.db import models +from django_filters.exceptions import FieldLookupError from django_filters.utils import get_model_field, resolve_field -from dcim.forms import MACAddressField from extras.choices import CustomFieldFilterLogicChoices -from extras.filters import CustomFieldFilter, TagFilter +from extras.filters import TagFilter from extras.models import CustomField from utilities.constants import ( FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP ) +from utilities.forms import MACAddressField from utilities import filters - __all__ = ( 'BaseFilterSet', 'ChangeLoggedModelFilterSet', @@ -84,6 +84,7 @@ class BaseFilterSet(django_filters.FilterSet): def _get_filter_lookup_dict(existing_filter): # Choose the lookup expression map based on the filter type if isinstance(existing_filter, ( + django_filters.NumberFilter, filters.MultiValueDateFilter, filters.MultiValueDateTimeFilter, filters.MultiValueNumberFilter, @@ -115,6 +116,63 @@ class BaseFilterSet(django_filters.FilterSet): return None + @classmethod + def get_additional_lookups(cls, existing_filter_name, existing_filter): + new_filters = {} + + # Skip nonstandard lookup expressions + if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']: + return {} + + # Choose the lookup expression map based on the filter type + lookup_map = cls._get_filter_lookup_dict(existing_filter) + if lookup_map is None: + # Do not augment this filter type with more lookup expressions + return {} + + # Get properties of the existing filter for later use + field_name = existing_filter.field_name + field = get_model_field(cls._meta.model, field_name) + + # Create new filters for each lookup expression in the map + for lookup_name, lookup_expr in lookup_map.items(): + new_filter_name = f'{existing_filter_name}__{lookup_name}' + + try: + if existing_filter_name in cls.declared_filters: + # The filter field has been explicitly defined on the filterset class so we must manually + # create the new filter with the same type because there is no guarantee the defined type + # is the same as the default type for the field + resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid + new_filter = type(existing_filter)( + field_name=field_name, + lookup_expr=lookup_expr, + label=existing_filter.label, + exclude=existing_filter.exclude, + distinct=existing_filter.distinct, + **existing_filter.extra + ) + elif hasattr(existing_filter, 'custom_field'): + # Filter is for a custom field + custom_field = existing_filter.custom_field + new_filter = custom_field.to_filter(lookup_expr=lookup_expr) + else: + # The filter field is listed in Meta.fields so we can safely rely on default behaviour + # Will raise FieldLookupError if the lookup is invalid + new_filter = cls.filter_for_field(field, field_name, lookup_expr) + except FieldLookupError: + # The filter could not be created because the lookup expression is not supported on the field + continue + + if lookup_name.startswith('n'): + # This is a negation filter which requires a queryset.exclude() clause + # Of course setting the negation of the existing filter's exclude attribute handles both cases + new_filter.exclude = not existing_filter.exclude + + new_filters[new_filter_name] = new_filter + + return new_filters + @classmethod def get_filters(cls): """ @@ -125,59 +183,12 @@ class BaseFilterSet(django_filters.FilterSet): """ filters = super().get_filters() - new_filters = {} + additional_filters = {} for existing_filter_name, existing_filter in filters.items(): - # Loop over existing filters to extract metadata by which to create new filters + additional_filters.update(cls.get_additional_lookups(existing_filter_name, existing_filter)) - # If the filter makes use of a custom filter method or lookup expression skip it - # as we cannot sanely handle these cases in a generic mannor - if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']: - continue + filters.update(additional_filters) - # Choose the lookup expression map based on the filter type - lookup_map = cls._get_filter_lookup_dict(existing_filter) - if lookup_map is None: - # Do not augment this filter type with more lookup expressions - continue - - # Get properties of the existing filter for later use - field_name = existing_filter.field_name - field = get_model_field(cls._meta.model, field_name) - - # Create new filters for each lookup expression in the map - for lookup_name, lookup_expr in lookup_map.items(): - new_filter_name = '{}__{}'.format(existing_filter_name, lookup_name) - - try: - if existing_filter_name in cls.declared_filters: - # The filter field has been explicity defined on the filterset class so we must manually - # create the new filter with the same type because there is no guarantee the defined type - # is the same as the default type for the field - resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid - new_filter = type(existing_filter)( - field_name=field_name, - lookup_expr=lookup_expr, - label=existing_filter.label, - exclude=existing_filter.exclude, - distinct=existing_filter.distinct, - **existing_filter.extra - ) - else: - # The filter field is listed in Meta.fields so we can safely rely on default behaviour - # Will raise FieldLookupError if the lookup is invalid - new_filter = cls.filter_for_field(field, field_name, lookup_expr) - except django_filters.exceptions.FieldLookupError: - # The filter could not be created because the lookup expression is not supported on the field - continue - - if lookup_name.startswith('n'): - # This is a negation filter which requires a queryset.exclude() clause - # Of course setting the negation of the existing filter's exclude attribute handles both cases - new_filter.exclude = not existing_filter.exclude - - new_filters[new_filter_name] = new_filter - - filters.update(new_filters) return filters @@ -213,8 +224,19 @@ class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): ).exclude( filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED ) - for cf in custom_fields: - self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf) + + custom_field_filters = {} + for custom_field in custom_fields: + filter_name = f'cf_{custom_field.name}' + filter_instance = custom_field.to_filter() + if filter_instance: + custom_field_filters[filter_name] = filter_instance + + # Add relevant additional lookups + additional_lookups = self.get_additional_lookups(filter_name, filter_instance) + custom_field_filters.update(additional_lookups) + + self.filters.update(custom_field_filters) class OrganizationalModelFilterSet(PrimaryModelFilterSet): diff --git a/netbox/netbox/graphql/__init__.py b/netbox/netbox/graphql/__init__.py index 069f6a9c8..0ad25a541 100644 --- a/netbox/netbox/graphql/__init__.py +++ b/netbox/netbox/graphql/__init__.py @@ -2,7 +2,7 @@ import graphene from graphene_django.converter import convert_django_field from taggit.managers import TaggableManager -from dcim.fields import MACAddressField +from dcim.fields import MACAddressField, WWNField from ipam.fields import IPAddressField, IPNetworkField @@ -17,6 +17,7 @@ def convert_field_to_tags_list(field, registry=None): @convert_django_field.register(IPAddressField) @convert_django_field.register(IPNetworkField) @convert_django_field.register(MACAddressField) +@convert_django_field.register(WWNField) def convert_field_to_string(field, registry=None): # TODO: Update to use get_django_field_description under django_graphene v3.0 return graphene.String(description=field.help_text, required=not field.null) diff --git a/netbox/netbox/graphql/scalars.py b/netbox/netbox/graphql/scalars.py new file mode 100644 index 000000000..7d14189dd --- /dev/null +++ b/netbox/netbox/graphql/scalars.py @@ -0,0 +1,23 @@ +from graphene import Scalar +from graphql.language import ast +from graphql.type.scalars import MAX_INT, MIN_INT + + +class BigInt(Scalar): + """ + Handle any BigInts + """ + @staticmethod + def to_float(value): + num = int(value) + if num > MAX_INT or num < MIN_INT: + return float(num) + return num + + serialize = to_float + parse_value = to_float + + @staticmethod + def parse_literal(node): + if isinstance(node, ast.IntValue): + return BigInt.to_float(node.value) diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index bb752b8c4..812c1656d 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -7,6 +7,7 @@ from ipam.graphql.schema import IPAMQuery from tenancy.graphql.schema import TenancyQuery from users.graphql.schema import UsersQuery from virtualization.graphql.schema import VirtualizationQuery +from wireless.graphql.schema import WirelessQuery class Query( @@ -17,6 +18,7 @@ class Query( TenancyQuery, UsersQuery, VirtualizationQuery, + WirelessQuery, graphene.ObjectType ): pass diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 181b9a0c6..7d71bd1fb 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -41,6 +41,7 @@ class ObjectType( class OrganizationalObjectType( ChangelogMixin, CustomFieldsMixin, + TagsMixin, BaseObjectType ): """ diff --git a/netbox/netbox/graphql/views.py b/netbox/netbox/graphql/views.py index c2c0269fa..e1573dba6 100644 --- a/netbox/netbox/graphql/views.py +++ b/netbox/netbox/graphql/views.py @@ -6,6 +6,7 @@ from graphene_django.views import GraphQLView as GraphQLView_ from rest_framework.exceptions import AuthenticationFailed from netbox.api.authentication import TokenAuthentication +from netbox.config import get_config class GraphQLView(GraphQLView_): @@ -15,9 +16,10 @@ class GraphQLView(GraphQLView_): graphiql_template = 'graphiql.html' def dispatch(self, request, *args, **kwargs): + config = get_config() # Enforce GRAPHQL_ENABLED - if not settings.GRAPHQL_ENABLED: + if not config.GRAPHQL_ENABLED: return HttpResponseNotFound("The GraphQL API is not enabled.") # Attempt to authenticate the user using a DRF token, if provided diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index a8f989a2a..cc768cbdc 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -1,41 +1,33 @@ +import logging import uuid from urllib import parse -import logging from django.conf import settings -from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_ from django.contrib import auth +from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_ from django.core.exceptions import ImproperlyConfigured from django.db import ProgrammingError from django.http import Http404, HttpResponseRedirect -from django.urls import reverse from extras.context_managers import change_logging +from netbox.config import clear_config from netbox.views import server_error from utilities.api import is_api_request, rest_api_server_error -class LoginRequiredMiddleware(object): +class LoginRequiredMiddleware: """ If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page. """ - def __init__(self, get_response): self.get_response = get_response def __call__(self, request): # Redirect unauthenticated requests (except those exempted) to the login page if LOGIN_REQUIRED is true if settings.LOGIN_REQUIRED and not request.user.is_authenticated: - # Determine exempt paths - exempt_paths = [ - reverse('api-root'), - reverse('graphql'), - ] - if settings.METRICS_ENABLED: - exempt_paths.append(reverse('prometheus-django-metrics')) # Redirect unauthenticated requests - if not request.path_info.startswith(tuple(exempt_paths)) and request.path_info != settings.LOGIN_URL: + if not request.path_info.startswith(settings.EXEMPT_PATHS): login_url = f'{settings.LOGIN_URL}?next={parse.quote(request.get_full_path_info())}' return HttpResponseRedirect(login_url) @@ -114,7 +106,7 @@ class RemoteUserMiddleware(RemoteUserMiddleware_): return groups -class ObjectChangeMiddleware(object): +class ObjectChangeMiddleware: """ This middleware performs three functions in response to an object being created, updated, or deleted: @@ -144,7 +136,7 @@ class ObjectChangeMiddleware(object): return response -class APIVersionMiddleware(object): +class APIVersionMiddleware: """ If the request is for an API endpoint, include the API version as a response header. """ @@ -159,7 +151,20 @@ class APIVersionMiddleware(object): return response -class ExceptionHandlingMiddleware(object): +class DynamicConfigMiddleware: + """ + Store the cached NetBox configuration in thread-local storage for the duration of the request. + """ + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + clear_config() + return response + + +class ExceptionHandlingMiddleware: """ Intercept certain exceptions which are likely indicative of installation issues and provide helpful instructions to the user. diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index 3045e1fce..091bae7bd 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -11,6 +11,7 @@ from taggit.managers import TaggableManager from extras.choices import ObjectChangeActionChoices from netbox.signals import post_clean from utilities.mptt import TreeManager +from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object __all__ = ( @@ -138,6 +139,18 @@ class CustomValidationMixin(models.Model): post_clean.send(sender=self.__class__, instance=self) +class TagsMixin(models.Model): + """ + Enable the assignment of Tags. + """ + tags = TaggableManager( + through='extras.TaggedItem' + ) + + class Meta: + abstract = True + + # # Base model classes @@ -157,11 +170,13 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): """ Base model for all objects which support change logging. """ + objects = RestrictedQuerySet.as_manager() + class Meta: abstract = True -class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel): +class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): """ Primary models represent real objects within the infrastructure being modeled. """ @@ -170,15 +185,14 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, object_id_field='assigned_object_id', content_type_field='assigned_object_type' ) - tags = TaggableManager( - through='extras.TaggedItem' - ) + + objects = RestrictedQuerySet.as_manager() class Meta: abstract = True -class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel, MPTTModel): +class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -220,7 +234,7 @@ class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMi }) -class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel): +class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational @@ -242,6 +256,8 @@ class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidatio blank=True ) + objects = RestrictedQuerySet.as_manager() + class Meta: abstract = True ordering = ('name',) diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index a3978f16e..0bd29229f 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -120,6 +120,14 @@ ORGANIZATION_MENU = Menu( get_model_item('tenancy', 'tenantgroup', 'Tenant Groups'), ), ), + MenuGroup( + label='Contacts', + items=( + get_model_item('tenancy', 'contact', 'Contacts'), + get_model_item('tenancy', 'contactgroup', 'Contact Groups'), + get_model_item('tenancy', 'contactrole', 'Contact Roles'), + ), + ), ), ) @@ -168,6 +176,7 @@ CONNECTIONS_MENU = Menu( label='Connections', items=( get_model_item('dcim', 'cable', 'Cables', actions=['import']), + get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']), MenuItem( link='dcim:interface_connections_list', link_text='Interface Connections', @@ -188,6 +197,20 @@ CONNECTIONS_MENU = Menu( ), ) +WIRELESS_MENU = Menu( + label='Wireless', + icon_class='mdi mdi-wifi', + groups=( + MenuGroup( + label='Wireless', + items=( + get_model_item('wireless', 'wirelesslan', 'Wireless LANs'), + get_model_item('wireless', 'wirelesslangroup', 'Wireless LAN Groups'), + ), + ), + ), +) + IPAM_MENU = Menu( label='IPAM', icon_class='mdi mdi-counter', @@ -206,6 +229,12 @@ IPAM_MENU = Menu( get_model_item('ipam', 'role', 'Prefix & VLAN Roles'), ), ), + MenuGroup( + label='ASNs', + items=( + get_model_item('ipam', 'asn', 'ASNs'), + ), + ), MenuGroup( label='Aggregates', items=( @@ -228,8 +257,9 @@ IPAM_MENU = Menu( ), ), MenuGroup( - label='Services', + label='Other', items=( + get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'), get_model_item('ipam', 'service', 'Services', actions=['import']), ), ), @@ -343,6 +373,7 @@ MENUS = [ ORGANIZATION_MENU, DEVICES_MENU, CONNECTIONS_MENU, + WIRELESS_MENU, IPAM_MENU, VIRTUALIZATION_MENU, CIRCUITS_MENU, diff --git a/netbox/netbox/request_context.py b/netbox/netbox/request_context.py new file mode 100644 index 000000000..41e8283e8 --- /dev/null +++ b/netbox/netbox/request_context.py @@ -0,0 +1,9 @@ +from netbox import thread_locals + + +def set_request(request): + thread_locals.request = request + + +def get_request(): + return getattr(thread_locals, 'request', None) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index ccf3b3752..20a6d5d02 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -4,6 +4,7 @@ import os import platform import re import socket +import sys import warnings from urllib.parse import urlsplit @@ -11,12 +12,14 @@ from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator +from netbox.config import PARAMS + # # Environment setup # -VERSION = '3.0.10-dev' +VERSION = '3.1.0' # Hostname HOSTNAME = platform.node() @@ -25,9 +28,13 @@ HOSTNAME = platform.node() BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Validate Python version -if platform.python_version_tuple() < ('3', '7'): +if sys.version_info < (3, 7): raise RuntimeError( - f"NetBox requires Python 3.7 or higher (current: Python {platform.python_version()})" + f"NetBox requires Python 3.7 or later. (Currently installed: Python {platform.python_version()})" + ) +if sys.version_info < (3, 8): + warnings.warn( + f"NetBox v3.2 will require Python 3.8 or later. (Currently installed: Python {platform.python_version()})" ) @@ -68,52 +75,32 @@ DATABASE = getattr(configuration, 'DATABASE') REDIS = getattr(configuration, 'REDIS') SECRET_KEY = getattr(configuration, 'SECRET_KEY') -# Set optional parameters +# Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) -ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', ( - 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', -)) -BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '') -BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '') -BANNER_TOP = getattr(configuration, 'BANNER_TOP', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only -CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) -CUSTOM_VALIDATORS = getattr(configuration, 'CUSTOM_VALIDATORS', {}) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DEBUG = getattr(configuration, 'DEBUG', False) DEVELOPER = getattr(configuration, 'DEVELOPER', False) DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs')) EMAIL = getattr(configuration, 'EMAIL', {}) -ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) -GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) LOGGING = getattr(configuration, 'LOGGING', {}) +LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) -MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) -MAPS_URL = getattr(configuration, 'MAPS_URL', 'https://maps.google.com/?q=') -MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) -NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) -NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '') -NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30) -NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '') -PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) -LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) PLUGINS = getattr(configuration, 'PLUGINS', []) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) -PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) -RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 22) -RACK_ELEVATION_DEFAULT_UNIT_WIDTH = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', 220) +RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) @@ -127,7 +114,6 @@ REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', []) REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', []) REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', []) REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|') -RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') @@ -141,6 +127,11 @@ STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') +# Check for hard-coded dynamic config parameters +for param in PARAMS: + if hasattr(configuration, param.name): + globals()[param.name] = getattr(configuration, param.name) + # Validate update repo URL and timeout if RELEASE_CHECK_URL: validator = URLValidator( @@ -316,6 +307,7 @@ INSTALLED_APPS = [ 'graphene_django', 'mptt', 'rest_framework', + 'social_django', 'taggit', 'timezone_field', 'circuits', @@ -326,6 +318,7 @@ INSTALLED_APPS = [ 'users', 'utilities', 'virtualization', + 'wireless', 'django_rq', # Must come after extras to allow overriding management commands 'drf_yasg', ] @@ -345,6 +338,7 @@ MIDDLEWARE = [ 'netbox.middleware.ExceptionHandlingMiddleware', 'netbox.middleware.RemoteUserMiddleware', 'netbox.middleware.LoginRequiredMiddleware', + 'netbox.middleware.DynamicConfigMiddleware', 'netbox.middleware.APIVersionMiddleware', 'netbox.middleware.ObjectChangeMiddleware', 'django_prometheus.middleware.PrometheusAfterMiddleware', @@ -409,7 +403,8 @@ MESSAGE_TAGS = { } # Authentication URLs -LOGIN_URL = '/{}login/'.format(BASE_PATH) +LOGIN_URL = f'/{BASE_PATH}login/' +LOGIN_REDIRECT_URL = f'/{BASE_PATH}' CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS @@ -423,6 +418,27 @@ EXEMPT_EXCLUDE_MODELS = ( ('users', 'objectpermission'), ) +# All URLs starting with a string listed here are exempt from login enforcement +EXEMPT_PATHS = ( + f'/{BASE_PATH}api/', + f'/{BASE_PATH}graphql/', + f'/{BASE_PATH}login/', + f'/{BASE_PATH}oauth/', + f'/{BASE_PATH}metrics/', +) + + +# +# Django social auth +# + +# Load all SOCIAL_AUTH_* settings from the user configuration +for param in dir(configuration): + if param.startswith('SOCIAL_AUTH_'): + globals()[param] = getattr(configuration, param) + +SOCIAL_AUTH_JSONFIELD_ENABLED = True + # # Django Prometheus @@ -443,7 +459,7 @@ FILTERS_NULL_CHOICE_VALUE = 'null' # Django REST framework (API) # -REST_FRAMEWORK_VERSION = VERSION.rsplit('.', 1)[0] # Use major.minor as API version +REST_FRAMEWORK_VERSION = '.'.join(VERSION.split('-')[0].split('.')[:2]) # Use major.minor as API version REST_FRAMEWORK = { 'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION], 'COERCE_DECIMAL_TO_STRING': False, @@ -465,7 +481,7 @@ REST_FRAMEWORK = { ), 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', - 'PAGE_SIZE': PAGINATE_COUNT, + # 'PAGE_SIZE': PAGINATE_COUNT, 'SCHEMA_COERCE_METHOD_NAMES': { # Default mappings 'retrieve': 'read', @@ -564,23 +580,6 @@ RQ_QUEUES = { } -# -# NetBox internal settings -# - -# Pagination -if MAX_PAGE_SIZE and PAGINATE_COUNT > MAX_PAGE_SIZE: - raise ImproperlyConfigured( - f"PAGINATE_COUNT ({PAGINATE_COUNT}) must be less than or equal to MAX_PAGE_SIZE ({MAX_PAGE_SIZE}), if set." - ) -PER_PAGE_DEFAULTS = [ - 25, 50, 100, 250, 500, 1000 -] -if PAGINATE_COUNT not in PER_PAGE_DEFAULTS: - PER_PAGE_DEFAULTS.append(PAGINATE_COUNT) - PER_PAGE_DEFAULTS = sorted(PER_PAGE_DEFAULTS) - - # # Plugins # diff --git a/netbox/netbox/tests/test_config.py b/netbox/netbox/tests/test_config.py new file mode 100644 index 000000000..db401cf0c --- /dev/null +++ b/netbox/netbox/tests/test_config.py @@ -0,0 +1,68 @@ +from django.conf import settings +from django.core.cache import cache +from django.test import override_settings, TestCase + +from extras.models import ConfigRevision +from netbox.config import clear_config, get_config + + +# Prefix cache keys to avoid interfering with the local environment +CACHES = settings.CACHES +CACHES['default'].update({'KEY_PREFIX': 'TEST-'}) + + +class ConfigTestCase(TestCase): + + @override_settings(CACHES=CACHES) + def test_config_init_empty(self): + cache.clear() + + config = get_config() + self.assertEqual(config.config, {}) + self.assertEqual(config.version, None) + + clear_config() + + @override_settings(CACHES=CACHES) + def test_config_init_from_db(self): + CONFIG_DATA = {'BANNER_TOP': 'A'} + cache.clear() + + # Create a config but don't load it into the cache + configrevision = ConfigRevision.objects.create(data=CONFIG_DATA) + + config = get_config() + self.assertEqual(config.config, CONFIG_DATA) + self.assertEqual(config.version, configrevision.pk) + + clear_config() + + @override_settings(CACHES=CACHES) + def test_config_init_from_cache(self): + CONFIG_DATA = {'BANNER_TOP': 'B'} + cache.clear() + + # Create a config and load it into the cache + configrevision = ConfigRevision.objects.create(data=CONFIG_DATA) + configrevision.activate() + + config = get_config() + self.assertEqual(config.config, CONFIG_DATA) + self.assertEqual(config.version, configrevision.pk) + + clear_config() + + @override_settings(CACHES=CACHES, BANNER_TOP='Z') + def test_settings_override(self): + CONFIG_DATA = {'BANNER_TOP': 'A'} + cache.clear() + + # Create a config and load it into the cache + configrevision = ConfigRevision.objects.create(data=CONFIG_DATA) + configrevision.activate() + + config = get_config() + self.assertEqual(config.BANNER_TOP, 'Z') + self.assertEqual(config.version, configrevision.pk) + + clear_config() diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 3d4c60c93..e76efe0fe 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -39,6 +39,7 @@ _patterns = [ # Login/logout path('login/', LoginView.as_view(), name='login'), path('logout/', LogoutView.as_view(), name='logout'), + path('oauth/', include('social_django.urls', namespace='social')), # Apps path('circuits/', include('circuits.urls')), @@ -48,6 +49,7 @@ _patterns = [ path('tenancy/', include('tenancy.urls')), path('user/', include('users.urls')), path('virtualization/', include('virtualization.urls')), + path('wireless/', include('wireless.urls')), # API path('api/', APIRootView.as_view(), name='api-root'), @@ -58,6 +60,7 @@ _patterns = [ path('api/tenancy/', include('tenancy.api.urls')), path('api/users/', include('users.api.urls')), path('api/virtualization/', include('virtualization.api.urls')), + path('api/wireless/', include('wireless.api.urls')), path('api/status/', StatusView.as_view(), name='api-status'), path('api/docs/', schema_view.with_ui('swagger', cache_timeout=86400), name='api_docs'), path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=86400), name='api_redocs'), diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index 2c033e760..b361352d0 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -27,6 +27,7 @@ from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES from netbox.forms import SearchForm from tenancy.models import Tenant from virtualization.models import Cluster, VirtualMachine +from wireless.models import WirelessLAN, WirelessLink class HomeView(View): @@ -92,14 +93,19 @@ class HomeView(View): ("dcim.view_powerpanel", "Power Panels", PowerPanel.objects.restrict(request.user, 'view').count), ("dcim.view_powerfeed", "Power Feeds", PowerFeed.objects.restrict(request.user, 'view').count), ) + wireless = ( + ("wireless.view_wirelesslan", "Wireless LANs", WirelessLAN.objects.restrict(request.user, 'view').count), + ("wireless.view_wirelesslink", "Wireless Links", WirelessLink.objects.restrict(request.user, 'view').count), + ) sections = ( ("Organization", org, "domain"), ("IPAM", ipam, "counter"), ("Virtualization", virtualization, "monitor"), ("Inventory", dcim, "server"), - ("Connections", connections, "cable-data"), ("Circuits", circuits, "transit-connection-variant"), + ("Connections", connections, "cable-data"), ("Power", power, "flash"), + ("Wireless", wireless, "wifi"), ) stats = [] diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 44e83f5ec..1c2ff9917 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -93,6 +93,13 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') + def get_table(self, request, permissions): + table = self.table(self.queryset, user=request.user) + if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): + table.columns.show('pk') + + return table + def export_yaml(self): """ Export the queryset of objects as concatenated YAML documents. @@ -123,8 +130,20 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' ) - def get(self, request): + def export_template(self, template, request): + """ + Render an ExportTemplate using the current queryset. + :param template: ExportTemplate instance + :param request: The current request + """ + try: + return template.render_to_response(self.queryset) + except Exception as e: + messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}") + return redirect(request.path) + + def get(self, request): model = self.queryset.model content_type = ContentType.objects.get_for_model(model) @@ -137,42 +156,33 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): perm_name = get_permission_for_model(model, action) permissions[action] = request.user.has_perm(perm_name) - # Export template/YAML rendering - if 'export' in request.GET and request.GET['export'] != 'table': + if 'export' in request.GET: - # An export template has been specified - if request.GET['export']: - et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) - try: - return et.render_to_response(self.queryset) - except Exception as e: - messages.error( - request, - "There was an error rendering the selected export template ({}): {}".format( - et.name, e - ) - ) + # Export the current table view + if request.GET['export'] == 'table': + table = self.get_table(request, permissions) + columns = [name for name, _ in table.selected_columns] + return self.export_table(table, columns) - # Check for YAML export support + # Render an ExportTemplate + elif request.GET['export']: + template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) + return self.export_template(template, request) + + # Check for YAML export support on the model elif hasattr(model, 'to_yaml'): response = HttpResponse(self.export_yaml(), content_type='text/yaml') filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural) response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) return response - # Construct the objects table - table = self.table(self.queryset, user=request.user) - if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): - table.columns.show('pk') + # Fall back to default table/YAML export + else: + table = self.get_table(request, permissions) + return self.export_table(table) - # Handle table-based exports (current view or static CSV-based) - if request.GET.get('export') == 'table': - columns = [name for name, _ in table.selected_columns] - return self.export_table(table, columns) - elif 'export' in request.GET: - return self.export_table(table) - - # Paginate the objects table + # Render the objects table + table = self.get_table(request, permissions) paginate_table(table, request) context = { diff --git a/netbox/project-static/dist/cable_trace.css b/netbox/project-static/dist/cable_trace.css index 633ccd572..50622f128 100644 Binary files a/netbox/project-static/dist/cable_trace.css and b/netbox/project-static/dist/cable_trace.css differ diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index b06cca0a1..adc964ea1 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index cf06883a9..a072cda9f 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index 7e565c3d5..2093ef4d1 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 6a60ff56d..c4a8e802d 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index ba7d8cd2f..b95e6ba02 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/forms/elements.ts b/netbox/project-static/src/forms/elements.ts index 978c25e10..5cb17f5c7 100644 --- a/netbox/project-static/src/forms/elements.ts +++ b/netbox/project-static/src/forms/elements.ts @@ -1,4 +1,32 @@ -import { getElements, scrollTo } from '../util'; +import { getElements, scrollTo, isTruthy } from '../util'; + +/** + * When editing an object, it is sometimes desirable to customize the form action *without* + * overriding the form's `submit` event. For example, the 'Save & Continue' button. We don't want + * to use the `formaction` attribute on that element because it will be included on the form even + * if the button isn't clicked. + * + * @example + * ```html + * + * ``` + * + * @param event Click event. + */ +function handleSubmitWithReturnUrl(event: MouseEvent): void { + const element = event.target as HTMLElement; + if (element.tagName === 'BUTTON') { + const button = element as HTMLButtonElement; + const action = button.getAttribute('return-url'); + const form = button.form; + if (form !== null && isTruthy(action)) { + form.action = action; + form.submit(); + } + } +} function handleFormSubmit(event: Event, form: HTMLFormElement): void { // Track the names of each invalid field. @@ -38,6 +66,15 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void { } } +/** + * Attach event listeners to form buttons with the `return-url` attribute present. + */ +function initReturnUrlSubmitButtons(): void { + for (const button of getElements('button[return-url]')) { + button.addEventListener('click', handleSubmitWithReturnUrl); + } +} + /** * Attach an event listener to each form's submitter (button[type=submit]). When called, the * callback checks the validity of each form field and adds the appropriate Bootstrap CSS class @@ -54,4 +91,5 @@ export function initFormElements(): void { submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form)); } } + initReturnUrlSubmitButtons(); } diff --git a/netbox/project-static/src/search.ts b/netbox/project-static/src/search.ts index 120d833ea..3140b8d7f 100644 --- a/netbox/project-static/src/search.ts +++ b/netbox/project-static/src/search.ts @@ -107,6 +107,9 @@ function initTableFilter(): void { // Create a regex pattern from the input search text to match against. const filter = new RegExp(target.value.toLowerCase().trim()); + // List of which rows which match the query + const matchedRows: Array = []; + for (const row of rows) { // Find the row's checkbox and deselect it, so that it is not accidentally included in form // submissions. @@ -114,19 +117,26 @@ function initTableFilter(): void { if (checkBox !== null) { checkBox.checked = false; } + // Iterate through each row's cell values for (const value of getRowValues(row)) { if (filter.test(value.toLowerCase())) { - // If this row matches the search pattern, but is already hidden, unhide it and stop - // iterating through the rest of the cells. - row.classList.remove('d-none'); + // If this row matches the search pattern, add it to the list. + matchedRows.push(row); break; - } else { - // If none of the cells in this row match the search pattern, hide the row. - row.classList.add('d-none'); } } } + + // Iterate the rows again to set visibility. + // This results in a single reflow instead of one for each row. + for (const row of rows) { + if (matchedRows.indexOf(row) >= 0) { + row.classList.remove('d-none'); + } else { + row.classList.add('d-none'); + } + } } input.addEventListener('keyup', debounce(handleInput, 300)); } diff --git a/netbox/project-static/styles/cable-trace.scss b/netbox/project-static/styles/cable-trace.scss index 85deafe96..51d94d38a 100644 --- a/netbox/project-static/styles/cable-trace.scss +++ b/netbox/project-static/styles/cable-trace.scss @@ -59,8 +59,13 @@ svg { stroke: var(--nbx-trace-cable-shadow); stroke-width: 7px; } + line.wireless-link { + stroke: var(--nbx-trace-attachment); + stroke-dasharray: 4px 12px; + stroke-linecap: round; + } line.attachment { stroke: var(--nbx-trace-attachment); - stroke-dasharray: 5px, 5px; + stroke-dasharray: 5px; } } diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index 8ce526985..89adfc8bc 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -814,7 +814,7 @@ table .table-badge-group { } &.badge:not(:last-of-type):not(:only-child) { - margin-bottom: map.get($spacers, 2); + margin-bottom: map.get($spacers, 1); } } } @@ -967,6 +967,17 @@ div.card-overlay { max-width: unset; } +// Preformatted text blocks +td pre { + margin-bottom: 0 +} +pre.block { + padding: $spacer; + background-color: var(--nbx-pre-bg); + border: 1px solid var(--nbx-pre-border-color); + border-radius: $border-radius; +} + #django-messages { position: fixed; right: $spacer; diff --git a/netbox/project-static/styles/overrides.scss b/netbox/project-static/styles/overrides.scss index 85e6f0d40..03c72c6e6 100644 --- a/netbox/project-static/styles/overrides.scss +++ b/netbox/project-static/styles/overrides.scss @@ -7,11 +7,7 @@ body { } pre { - padding: $spacer; white-space: pre; - background-color: var(--nbx-pre-bg); - border: 1px solid var(--nbx-pre-border-color); - border-radius: $border-radius; } // Force elements to make text smaller. diff --git a/netbox/templates/500.html b/netbox/templates/500.html index 097699ffc..6cface941 100644 --- a/netbox/templates/500.html +++ b/netbox/templates/500.html @@ -26,7 +26,7 @@

The complete exception is provided below:

-
{{ exception }}
+
{{ exception }}
{{ error }} Python version: {{ python_version }} diff --git a/netbox/templates/admin/extras/configrevision/restore.html b/netbox/templates/admin/extras/configrevision/restore.html new file mode 100644 index 000000000..4a0eb81a6 --- /dev/null +++ b/netbox/templates/admin/extras/configrevision/restore.html @@ -0,0 +1,37 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block content %} +

Restore configuration #{{ object.pk }} from {{ object.created }}?

+ + + + + + + + + + + + {% for param, current, new in params %} + + + + + + + {% endfor %} + +
ParameterCurrent ValueNew Value
{{ param }}{{ current }}{{ new }}{% if current != new %}*{% endif %}
+ +
+ {% csrf_token %} +
+ + Cancel +
+
+{% endblock content %} + + diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 9575d4dcb..d45dc62f6 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -58,13 +58,13 @@ - {% if settings.BANNER_TOP %} + {% if config.BANNER_TOP %} {% endif %} - {% if settings.MAINTENANCE_MODE %} + {% if config.MAINTENANCE_MODE %} - {% if settings.BANNER_BOTTOM %} + {% if config.BANNER_BOTTOM %} {% endif %} @@ -127,7 +127,7 @@ {# GraphQL API #} - {% if settings.GRAPHQL_ENABLED %} + {% if config.GRAPHQL_ENABLED %} diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index b863a8a0e..22713b592 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -64,17 +64,18 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:circuit_list' %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
- {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} - {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} - {% include 'inc/image_attachments_panel.html' %} - {% plugin_right_page object %} -
+ {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} + {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/image_attachments.html' %} + {% plugin_right_page object %} +
diff --git a/netbox/templates/circuits/circuittype.html b/netbox/templates/circuits/circuittype.html index 899ba83c3..57737a6d1 100644 --- a/netbox/templates/circuits/circuittype.html +++ b/netbox/templates/circuits/circuittype.html @@ -28,10 +28,11 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index e2fe6af29..5c224f7c0 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -45,7 +45,7 @@ Marked as connected {% elif termination.cable %} {{ termination.cable }} - {% with peer=termination.get_cable_peer %} + {% with peer=termination.get_link_peer %} to {% if peer.device %} {{ peer.device }}
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 4d35da0e6..c16afa421 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -47,12 +47,13 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html index a5eac1f78..9641c9934 100644 --- a/netbox/templates/circuits/providernetwork.html +++ b/netbox/templates/circuits/providernetwork.html @@ -37,9 +37,9 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:providernetwork_list' %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index c7cd71b65..00704e6ca 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -23,6 +23,19 @@ {{ object.get_status_display }} + + Tenant + + {% if object.tenant %} + {% if object.tenant.group %} + {{ object.tenant.group }} / + {% endif %} + {{ object.tenant }} + {% else %} + None + {% endif %} + + Label {{ object.label|placeholder }} @@ -50,8 +63,8 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:cable_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index ee8b56980..60711eb9d 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -40,8 +40,8 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 8eb84993c..f65af3285 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -40,8 +40,8 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 9d1868e1e..ea0c795c5 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -93,6 +93,12 @@ {{ object.device_type }} ({{ object.device_type.u_height }}U) + + Airflow + + {{ object.get_airflow_display|placeholder }} + + Serial Number {{ object.serial|placeholder }} @@ -214,9 +220,9 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:device_list' %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
@@ -290,7 +296,8 @@
{% endif %} - {% include 'inc/image_attachments_panel.html' %} + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/image_attachments.html' %}
Related Devices diff --git a/netbox/templates/dcim/device/consoleports.html b/netbox/templates/dcim/device/consoleports.html index 4a7bab4d4..6cf736523 100644 --- a/netbox/templates/dcim/device/consoleports.html +++ b/netbox/templates/dcim/device/consoleports.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DeviceConsolePortTable_config" %} - {% render_table consoleport_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_consoleport %} @@ -36,6 +36,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=consoleport_table.paginator page=consoleport_table.page %} - {% table_config_form consoleport_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html index 4e97039f3..ca159029e 100644 --- a/netbox/templates/dcim/device/consoleserverports.html +++ b/netbox/templates/dcim/device/consoleserverports.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DeviceConsoleServerPortTable_config" %} - {% render_table consoleserverport_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_consoleserverport %} @@ -36,6 +36,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=consoleserverport_table.paginator page=consoleserverport_table.page %} - {% table_config_form consoleserverport_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html index 31ea9b249..b72625005 100644 --- a/netbox/templates/dcim/device/devicebays.html +++ b/netbox/templates/dcim/device/devicebays.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DeviceDeviceBayTable_config" %} - {% render_table devicebay_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_devicebay %} @@ -33,6 +33,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=devicebay_table.paginator page=devicebay_table.page %} - {% table_config_form devicebay_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html index 4d15dde1b..5833a1c78 100644 --- a/netbox/templates/dcim/device/frontports.html +++ b/netbox/templates/dcim/device/frontports.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DeviceFrontPortTable_config" %} - {% render_table frontport_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_frontport %} @@ -36,6 +36,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=frontport_table.paginator page=frontport_table.page %} - {% table_config_form frontport_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index 03c8a8913..1d1e7e81b 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -34,7 +34,7 @@
- {% render_table interface_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_interface %} @@ -63,6 +63,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=interface_table.paginator page=interface_table.page %} - {% table_config_form interface_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html index 6c9fdb17b..2aad68984 100644 --- a/netbox/templates/dcim/device/inventory.html +++ b/netbox/templates/dcim/device/inventory.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DeviceInventoryItemTable_config" %} - {% render_table inventoryitem_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_inventoryitem %} @@ -33,6 +33,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=inventoryitem_table.paginator page=inventoryitem_table.page %} - {% table_config_form inventoryitem_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/poweroutlets.html b/netbox/templates/dcim/device/poweroutlets.html index f9937bf27..df936742e 100644 --- a/netbox/templates/dcim/device/poweroutlets.html +++ b/netbox/templates/dcim/device/poweroutlets.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DevicePowerOutletTable_config" %} - {% render_table poweroutlet_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_powerport %} @@ -36,6 +36,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=poweroutlet_table.paginator page=poweroutlet_table.page %} - {% table_config_form poweroutlet_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html index 7d219979c..5a502dc57 100644 --- a/netbox/templates/dcim/device/powerports.html +++ b/netbox/templates/dcim/device/powerports.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DevicePowerPortTable_config" %} - {% render_table powerport_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_powerport %} @@ -36,6 +36,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=powerport_table.paginator page=powerport_table.page %} - {% table_config_form powerport_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html index f0ec37b80..d0ff55ec9 100644 --- a/netbox/templates/dcim/device/rearports.html +++ b/netbox/templates/dcim/device/rearports.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DeviceRearPortTable_config" %} - {% render_table rearport_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_rearport %} @@ -36,6 +36,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=rearport_table.paginator page=rearport_table.page %} - {% table_config_form rearport_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index fbafa197d..1be272d3a 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -19,6 +19,7 @@
{% render_field form.manufacturer %} {% render_field form.device_type %} + {% render_field form.airflow %} {% render_field form.serial %} {% render_field form.asset_tag %}
diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html index cc19413b1..ff8f90db2 100644 --- a/netbox/templates/dcim/devicebay.html +++ b/netbox/templates/dcim/devicebay.html @@ -32,8 +32,8 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index 382cbc4ee..22385ae27 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/netbox/templates/dcim/devicerole.html @@ -58,10 +58,11 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index bb5743ace..21a04e7d0 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -1,51 +1,8 @@ -{% extends 'generic/object.html' %} +{% extends 'dcim/devicetype/base.html' %} {% load buttons %} {% load helpers %} {% load plugins %} -{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} - -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} - -{% block extra_controls %} - {% if perms.dcim.change_devicetype %} - - {% endif %} -{% endblock %} - {% block content %}
@@ -90,6 +47,12 @@ {{ object.get_subdevice_role_display|placeholder }} + + Airflow + + {{ object.get_airflow_display|placeholder }} + + Front Image @@ -124,9 +87,9 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:devicetype_list' %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
@@ -135,76 +98,4 @@ {% plugin_full_width_page object %} -
-
- -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' tab='interfaces' %} -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=front_port_table title='Front Ports' tab='frontports' %} -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=rear_port_table title='Rear Ports' tab='rearports' %} -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' tab='consoleports' %} -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' tab='consoleserverports' %} -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' tab='powerports' %} -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' tab='poweroutlets' %} -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' tab='devicebays' %} -
-
-
-
{% endblock %} diff --git a/netbox/templates/dcim/devicetype/base.html b/netbox/templates/dcim/devicetype/base.html new file mode 100644 index 000000000..a06886de5 --- /dev/null +++ b/netbox/templates/dcim/devicetype/base.html @@ -0,0 +1,119 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} + +{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block extra_controls %} + {% if perms.dcim.change_devicetype %} + + {% endif %} +{% endblock %} + +{% block tab_items %} + + + {% with interface_count=object.interfacetemplates.count %} + {% if interface_count %} + + {% endif %} + {% endwith %} + + {% with frontport_count=object.frontporttemplates.count %} + {% if frontport_count %} + + {% endif %} + {% endwith %} + + {% with rearport_count=object.rearporttemplates.count %} + {% if rearport_count %} + + {% endif %} + {% endwith %} + + {% with consoleport_count=object.consoleporttemplates.count %} + {% if consoleport_count %} + + {% endif %} + {% endwith %} + + {% with consoleserverport_count=object.consoleserverporttemplates.count %} + {% if consoleserverport_count %} + + {% endif %} + {% endwith %} + + {% with powerport_count=object.powerporttemplates.count %} + {% if powerport_count %} + + {% endif %} + {% endwith %} + + {% with poweroutlet_count=object.poweroutlettemplates.count %} + {% if poweroutlet_count %} + + {% endif %} + {% endwith %} + + {% with devicebay_count=object.devicebaytemplates.count %} + {% if devicebay_count %} + + {% endif %} + {% endwith %} +{% endblock %} diff --git a/netbox/templates/dcim/inc/devicetype_component_table.html b/netbox/templates/dcim/devicetype/component_templates.html similarity index 93% rename from netbox/templates/dcim/inc/devicetype_component_table.html rename to netbox/templates/dcim/devicetype/component_templates.html index 900e0f818..d83a232cd 100644 --- a/netbox/templates/dcim/inc/devicetype_component_table.html +++ b/netbox/templates/dcim/devicetype/component_templates.html @@ -1,7 +1,9 @@ -{% load helpers %} +{% extends 'dcim/devicetype/base.html' %} {% load render_table from django_tables2 %} +{% load helpers %} -{% if perms.dcim.change_devicetype %} +{% block content %} + {% if perms.dcim.change_devicetype %}
{% csrf_token %}
@@ -33,7 +35,7 @@
-{% else %} + {% else %}
{{ title }} @@ -42,4 +44,5 @@ {% render_table table 'inc/table.html' %}
-{% endif %} + {% endif %} +{% endblock content %} diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 43ded0c6a..6cc3d482f 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -52,8 +52,8 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/inc/cable_form.html b/netbox/templates/dcim/inc/cable_form.html index 05929821c..0f11ac3cb 100644 --- a/netbox/templates/dcim/inc/cable_form.html +++ b/netbox/templates/dcim/inc/cable_form.html @@ -2,6 +2,8 @@ {% render_field form.status %} {% render_field form.type %} +{% render_field form.tenant_group %} +{% render_field form.tenant %} {% render_field form.label %} {% render_field form.color %}
@@ -17,7 +19,7 @@ {% render_field form.tags %} {% if form.custom_fields %}
-
+
Custom Fields
{% render_custom_fields form %} diff --git a/netbox/templates/dcim/inc/device_component_table.html b/netbox/templates/dcim/inc/device_component_table.html deleted file mode 100644 index b272e2731..000000000 --- a/netbox/templates/dcim/inc/device_component_table.html +++ /dev/null @@ -1,42 +0,0 @@ -{% load helpers %} -{% load perms %} -
- {% csrf_token %} -
-
- {{ title }} -
-
- - {% for obj in components %} - {% include component_template %} - {% endfor %} -
-
- {% if components and perms.dcim.change_consoleport %} - - {% endif %} -
-
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 3a4d16db3..811bf6257 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -69,6 +69,16 @@ {% endif %} + + Bridge + + {% if object.bridge %} + {{ object.bridge }} + {% else %} + None + {% endif %} + + LAG @@ -91,6 +101,14 @@ MAC Address {{ object.mac_address|placeholder }} + + WWN + {{ object.wwn|placeholder }} + + + Transmit power (dBm) + {{ object.tx_power|placeholder }} + 802.1Q Mode {{ object.get_mode_display|placeholder }} @@ -98,12 +116,12 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% if object.is_connectable %} + {% if not object.is_virtual %}
Connection @@ -207,10 +225,40 @@ + {% elif object.wireless_link %} + + + + + + {% with peer_interface=object.connected_endpoint %} + + + + + + + + + + + + + {% endwith %} +
Wireless Link + {{ object.wireless_link }} + + + +
Device + {{ peer_interface.device }} +
Name + {{ peer_interface }} +
Type{{ peer_interface.get_type_display }}
{% else %}
Not Connected - {% if perms.dcim.add_cable %} + {% if object.is_wired and perms.dcim.add_cable %} + {% elif object.is_wireless and perms.wireless.add_wirelesslink %} + {% endif %}
{% endif %}
{% endif %} + {% if object.is_wireless %} +
+
Wireless
+
+ {% with peer=object.connected_endpoint %} + + + + + + {% if peer %} + + {% endif %} + + + + + + {% if peer %} + + {% endif %} + + + + + {% if peer %} + + {{ peer.get_rf_channel_display|placeholder }} + + {% endif %} + + + + + {% if peer %} + + {% if peer.rf_channel_frequency %} + {{ peer.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} + + {% endif %} + + + + + {% if peer %} + + {% if peer.rf_channel_width %} + {{ peer.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} + + {% endif %} + +
LocalPeer
Role{{ object.get_rf_role_display|placeholder }}{{ peer.get_rf_role_display|placeholder }}
Channel{{ object.get_rf_channel_display|placeholder }}
Channel Frequency + {% if object.rf_channel_frequency %} + {{ object.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} +
Channel Width + {% if object.rf_channel_width %} + {{ object.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} +
+ {% endwith %} +
+
+
+
Wireless LANs
+
+ + + + + + + + + {% for wlan in object.wireless_lans.all %} + + + + + {% empty %} + + + + {% endfor %} + +
GroupSSID
+ {% if wlan.group %} + {{ wlan.group }} + {% else %} + — + {% endif %} + + {{ wlan.ssid }} +
None
+
+
+ {% endif %} {% if object.is_lag %}
LAG Members
@@ -279,6 +440,7 @@
{% endif %} + {% include 'ipam/inc/panels/fhrp_groups.html' %} {% plugin_right_page object %} @@ -297,7 +459,7 @@ {% if perms.ipam.add_ipaddress %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 38b22fe5e..a5f686633 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -15,19 +15,54 @@ {% endif %} {% render_field form.name %} - {% render_field form.label %} {% render_field form.type %} - {% render_field form.parent %} - {% render_field form.lag %} - {% render_field form.mac_address %} - {% render_field form.mtu %} + {% render_field form.label %} {% render_field form.description %} {% render_field form.tags %} + + +
+
+
Addressing
+
+ {% render_field form.mac_address %} + {% render_field form.wwn %} +
+ +
+
+
Operation
+
+ {% render_field form.mtu %} + {% render_field form.tx_power %} {% render_field form.enabled %} {% render_field form.mgmt_only %} {% render_field form.mark_connected %}
+
+
+
Related Interfaces
+
+ {% render_field form.parent %} + {% render_field form.bridge %} + {% render_field form.lag %} +
+ + {% if form.instance.is_wireless %} +
+
+
Wireless
+
+ {% render_field form.rf_role %} + {% render_field form.rf_channel %} + {% render_field form.rf_channel_frequency %} + {% render_field form.rf_channel_width %} + {% render_field form.wireless_lan_group %} + {% render_field form.wireless_lans %} +
+ {% endif %} +
802.1Q Switching
@@ -51,7 +86,7 @@ {% block buttons %} Cancel {% if obj.pk %} - + {% else %} diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index 545e8f1e4..163d8edb3 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -64,8 +64,8 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 7d5598bbc..bb0763875 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -40,6 +40,19 @@ {% endif %} + + Tenant + + {% if object.tenant %} + {% if object.tenant.group %} + {{ object.tenant.group }} / + {% endif %} + {{ object.tenant }} + {% else %} + None + {% endif %} + + Racks @@ -62,11 +75,13 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'inc/image_attachments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index 85d76f14f..d43a206c6 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -34,10 +34,12 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index 7229d8078..a926c4ed4 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -55,6 +55,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
@@ -63,10 +64,10 @@ NAPALM Arguments
-
{{ object.napalm_args }}
+
{{ object.napalm_args|render_json }}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index b4fb06081..1824cac19 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -107,8 +107,8 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:powerfeed_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
@@ -182,7 +182,7 @@
{% endif %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index f8973c79b..396ef42a8 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -44,8 +44,8 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index b1367aa1e..021fa1133 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -39,12 +39,13 @@
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:powerpanel_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'inc/image_attachments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index db367df1f..dfe428c50 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -44,8 +44,8 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 5d44e2125..93bd21fd9 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -162,9 +162,9 @@
- {% 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 %}
diff --git a/netbox/templates/dcim/rackrole.html b/netbox/templates/dcim/rackrole.html index 703e7e3d2..2f4661c9f 100644 --- a/netbox/templates/dcim/rackrole.html +++ b/netbox/templates/dcim/rackrole.html @@ -34,10 +34,11 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 1104bd988..b3ecce3ad 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -46,8 +46,8 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index b46c905c3..7452e594e 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -45,7 +45,9 @@
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 1ee8cfce0..2ad970301 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -20,235 +20,286 @@ {% block content %}
-
-
-
- Site -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Region - {% if object.region %} - {% for region in object.region.get_ancestors %} - {{ region }} / - {% endfor %} - {{ object.region }} - {% else %} - None - {% endif %} -
Group - {% if object.group %} - {% for group in object.group.get_ancestors %} - {{ group }} / - {% endfor %} - {{ object.group }} - {% else %} - None - {% endif %} -
Status - {{ object.get_status_display }} -
Tenant - {% if object.tenant %} - {% if object.tenant.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None - {% endif %} -
Facility{{ object.facility|placeholder }}
AS Number{{ object.asn|placeholder }}
Time Zone - {% if object.time_zone %} - {{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})
- Site time: {% timezone object.time_zone %}{% annotated_now %}{% endtimezone %} - {% else %} - - {% endif %} -
Description{{ object.description|placeholder }}
-
-
-
-
- Contact Info -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
Physical Address - {% if object.physical_address %} - - {{ object.physical_address|linebreaksbr }} - {% else %} - - {% endif %} -
Shipping Address{{ object.shipping_address|linebreaksbr|placeholder }}
GPS Coordinates - {% if object.latitude and object.longitude %} - - {{ object.latitude }}, {{ object.longitude }} - {% else %} - - {% endif %} -
Contact Name{{ object.contact_name|placeholder }}
Contact Phone - {% if object.contact_phone %} - {{ object.contact_phone }} - {% else %} - - {% endif %} -
Contact E-Mail - {% if object.contact_email %} - {{ object.contact_email }} - {% else %} - - {% endif %} -
-
-
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %} - {% include 'inc/comments_panel.html' %} - {% plugin_left_page object %} -
-
-
-
- Stats -
-
-
- -
-

{{ stats.device_count }}

-

Devices

-
-
-

{{ stats.prefix_count }}

-

Prefixes

-
- -
-

{{ stats.circuit_count }}

-

Circuits

-
-
-

{{ stats.vm_count }}

-

Virtual Machines

-
-
-
-
-
-
- Locations -
-
- {% if locations %} - - - - - - - - {% for location in locations %} - - - - - - - {% endfor %} -
LocationRacksDevices
- - {{ location }} - - {{ location.rack_count }} - - {{ location.device_count }} - - - - -
+
+
+
Site
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {# 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 %} + + + + + {% endif %} + {% if object.contact_phone %} + + + + + {% endif %} + {% if object.contact_email %} + + + + + {% endif %} + {% endwith %} +
Region + {% if object.region %} + {% for region in object.region.get_ancestors %} + {{ region }} / + {% endfor %} + {{ object.region }} {% else %} None {% endif %} +
Group + {% if object.group %} + {% for group in object.group.get_ancestors %} + {{ group }} / + {% endfor %} + {{ object.group }} + {% else %} + None + {% endif %} +
Status + {{ object.get_status_display }} +
Tenant + {% if object.tenant %} + {% if object.tenant.group %} + {{ object.tenant.group }} / + {% endif %} + {{ object.tenant }} + {% else %} + None + {% endif %} +
Facility{{ object.facility|placeholder }}
Description{{ object.description|placeholder }}
AS Number{{ object.asn|placeholder }}
Time Zone + {% if object.time_zone %} + {{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})
+ Site time: {% timezone object.time_zone %}{% annotated_now %}{% endtimezone %} + {% else %} + + {% endif %} +
Physical Address + {% if object.physical_address %} + + {{ object.physical_address|linebreaksbr }} + {% else %} + + {% endif %} +
Shipping Address{{ object.shipping_address|linebreaksbr|placeholder }}
GPS Coordinates + {% if object.latitude and object.longitude %} + + {{ object.latitude }}, {{ object.longitude }} + {% else %} + + {% endif %} +
Contact Name + {% if object.contact_name %} +
+ +
+ {% endif %} + {{ object.contact_name|placeholder }} +
Contact Phone + {% if object.contact_phone %} +
+ +
+ {{ object.contact_phone }} + {% else %} + + {% endif %} +
Contact E-Mail + {% if object.contact_email %} +
+ +
+ {{ object.contact_email }} + {% else %} + + {% endif %} +
+
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
+
+
+
Stats
+
+
+ + +
+

{{ stats.device_count }}

+

Devices

+
+
+

{{ stats.prefix_count }}

+

Prefixes

+
+ +
+

{{ stats.circuit_count }}

+

Circuits

+
+
+

{{ stats.vm_count }}

+

Virtual Machines

+
+ +
- {% include 'inc/image_attachments_panel.html' %} - {% plugin_right_page object %} +
+ {% include 'inc/panels/contacts.html' %} +
+
Locations
+
+ {% if locations %} + + + + + + + + {% for location in locations %} + + + + + + + {% endfor %} +
LocationRacksDevices
+ {% for i in location.level|as_range %}{% endfor %} + {{ location }} + + {{ location.rack_count }} + + {{ location.device_count }} + + + + +
+ {% else %} + None + {% endif %} +
+ {% if perms.dcim.add_location %} + + {% endif %} +
+
+
ASNs
+
+ {% if asns %} + + + + + + {% for asn in asns %} + + + + + {% endfor %} +
ASNDescription
{{ asn }}{{ asn.description|placeholder }}
+ {% else %} + None + {% endif %} +
+ {% if perms.ipam.add_asn %} + + {% endif %} +
+ {% include 'inc/panels/image_attachments.html' %} + {% plugin_right_page object %}
-
- {% plugin_full_width_page object %} -
+
+ {% plugin_full_width_page object %} +
{% endblock %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 856a86d64..d04330413 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -45,7 +45,9 @@
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 12088e892..eadf1645d 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -38,8 +38,8 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:virtualchassis_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
@@ -61,7 +61,7 @@ {{ vc_member }} - {% badge vc_member.vc_position %} + {% badge vc_member.vc_position show_empty=True %} {% if object.master == vc_member %} diff --git a/netbox/templates/extras/inc/configcontext_data.html b/netbox/templates/extras/inc/configcontext_data.html index 085887748..48ca18543 100644 --- a/netbox/templates/extras/inc/configcontext_data.html +++ b/netbox/templates/extras/inc/configcontext_data.html @@ -1,5 +1,5 @@ {% load helpers %}
-
{% if format == 'json' %}{{ data|render_json }}{% elif format == 'yaml' %}{{ data|render_yaml }}{% else %}{{ data }}{% endif %}
+
{% if format == 'json' %}{{ data|render_json }}{% elif format == 'yaml' %}{{ data|render_yaml }}{% else %}{{ data }}{% endif %}
diff --git a/netbox/templates/extras/inc/tags_panel.html b/netbox/templates/extras/inc/tags_panel.html deleted file mode 100644 index e67098c0f..000000000 --- a/netbox/templates/extras/inc/tags_panel.html +++ /dev/null @@ -1,11 +0,0 @@ -{% load helpers %} -
-
- Tags -
-
- {% for tag in tags.all %} {% tag tag url %} {% empty %} - No tags assigned - {% endfor %} -
-
diff --git a/netbox/templates/extras/journalentry.html b/netbox/templates/extras/journalentry.html index 925d98b41..2e7fcbbf5 100644 --- a/netbox/templates/extras/journalentry.html +++ b/netbox/templates/extras/journalentry.html @@ -45,7 +45,7 @@
- {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/comments.html' %}
{% endblock %} diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 498e56b8d..99d6da730 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -3,108 +3,94 @@ {% block title %}Reports{% endblock %} -{% block content %} -
-
- {% if reports %} - {% for module, module_reports in reports %} -
-
{{ module|bettertitle }}
-
- - - - - - - - - - - - {% for report in module_reports %} - - - - - - - - {% for method, stats in report.result.data.items %} - - - - - {% endfor %} - {% endfor %} - -
NameStatusDescriptionLast Run
- {{ report.name }} - - {% include 'extras/inc/job_label.html' with result=report.result %} - {{ report.description|render_markdown|placeholder }} - {% if report.result %} - {{ report.result.created|annotated_date }} - {% else %} - Never - {% endif %} - - {% if perms.extras.run_report %} -
-
- {% csrf_token %} - -
-
- {% endif %} -
- {{ method }} - - {{ stats.success }} - {{ stats.info }} - {{ stats.warning }} - {{ stats.failure }} -
+{% block tabs %} + +{% endblock tabs %} + +{% block content-wrapper %} +
+ {% if reports %} + {% for module, module_reports in reports %} +
+
+ + {{ module|bettertitle }} +
+
+ + + + + + + + + + + + {% for report in module_reports %} + + + + + + + + {% for method, stats in report.result.data.items %} + + + + + {% endfor %} {% endfor %} - {% else %} - - {% endif %} + +
NameStatusDescriptionLast Run
+ {{ report.name }} + + {% include 'extras/inc/job_label.html' with result=report.result %} + {{ report.description|render_markdown|placeholder }} + {% if report.result %} + {{ report.result.created|annotated_date }} + {% else %} + Never + {% endif %} + + {% if perms.extras.run_report %} +
+
+ {% csrf_token %} + +
- + {% endif %} +
+ {{ method }} + + {{ stats.success }} + {{ stats.info }} + {{ stats.warning }} + {{ stats.failure }} +
+
-
- {% if reports %} -
-
- {% for module, module_reports in reports %} -
{{ module|bettertitle }}
- - {% endfor %} -
-
- {% endif %} -
-
-{% endblock %} + {% endfor %} + {% else %} + + {% endif %} +
+{% endblock content-wrapper %} diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 3a50e09a1..bb2b83360 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -69,7 +69,7 @@
{{ script.filename }} -
{{ script.source }}
+
{{ script.source }}
{% endblock content-wrapper %} diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 1cc35d36c..ccbdca705 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -3,74 +3,66 @@ {% block title %}Scripts{% endblock %} -{% block content %} -
-
- {% if scripts %} - {% for module, module_scripts in scripts.items %} -

{{ module|bettertitle }}

- - - - - - - - - - - {% for class_name, script in module_scripts.items %} - - - - - {% if script.result %} - - {% else %} - - {% endif %} - - {% endfor %} - -
NameStatusDescriptionLast Run
- {{ script }} - - {% include 'extras/inc/job_label.html' with result=script.result %} - {{ script.Meta.description|render_markdown }} - {{ script.result.created|annotated_date }} - Never
+{% block tabs %} + +{% endblock tabs %} + +{% block content-wrapper %} +
+ {% if scripts %} + {% for module, module_scripts in scripts.items %} +
+
+ + {{ module|bettertitle }} +
+
+ + + + + + + + + + + {% for class_name, script in module_scripts.items %} + + + + + {% if script.result %} + + {% else %} + + {% endif %} + {% endfor %} - {% else %} -
-

No Scripts Found

- Scripts should be saved to {{ settings.SCRIPTS_ROOT }}. -
- This path can be changed by setting SCRIPTS_ROOT in NetBox's configuration. -
- {% endif %} + +
NameStatusDescriptionLast Run
+ {{ script }} + + {% include 'extras/inc/job_label.html' with result=script.result %} + + {{ script.Meta.description|render_markdown|placeholder }} + + {{ script.result.created|annotated_date }} + Never
+
-
- {% if scripts %} -
-
- {% for module, module_scripts in scripts.items %} -
{{ module|bettertitle }}
-
- -
- {% endfor %} -
-
- {% endif %} -
-
-{% endblock %} + {% endfor %} + {% else %} +
+

No Scripts Found

+ Scripts should be saved to {{ settings.SCRIPTS_ROOT }}. +
+ This path can be changed by setting SCRIPTS_ROOT in NetBox's configuration. +
+ {% endif %} +
+{% endblock content-wrapper %} diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html index f463b0f2c..3cbd0c611 100644 --- a/netbox/templates/extras/script_result.html +++ b/netbox/templates/extras/script_result.html @@ -102,11 +102,11 @@ {% endif %}
-
{{ result.data.output }}
+
{{ result.data.output }}

{{ script.filename }}

-
{{ script.source }}
+
{{ script.source }}
{% endblock content-wrapper %} diff --git a/netbox/templates/extras/webhook.html b/netbox/templates/extras/webhook.html index c92ec4c99..ab53c56a2 100644 --- a/netbox/templates/extras/webhook.html +++ b/netbox/templates/extras/webhook.html @@ -132,12 +132,28 @@ +
+
+ Conditions +
+
+ {% if object.conditions %} +
{{ object.conditions|render_json }}
+ {% else %} +

None

+ {% endif %} +
+
Additional Headers
-
{{ object.additional_headers }}
+ {% if object.additional_headers %} +
{{ object.additional_headers }}
+ {% else %} + None + {% endif %}
@@ -145,7 +161,11 @@ Body Template
-
{{ object.body_template }}
+ {% if object.body_template %} +
{{ object.body_template }}
+ {% else %} + None + {% endif %}
{% plugin_right_page object %} diff --git a/netbox/templates/inc/paginator.html b/netbox/templates/inc/paginator.html index c55203be3..8242ffcde 100644 --- a/netbox/templates/inc/paginator.html +++ b/netbox/templates/inc/paginator.html @@ -36,7 +36,7 @@ {% endfor %}
diff --git a/netbox/templates/inc/comments_panel.html b/netbox/templates/inc/panels/comments.html similarity index 100% rename from netbox/templates/inc/comments_panel.html rename to netbox/templates/inc/panels/comments.html diff --git a/netbox/templates/inc/panels/contacts.html b/netbox/templates/inc/panels/contacts.html new file mode 100644 index 000000000..e3e5cf483 --- /dev/null +++ b/netbox/templates/inc/panels/contacts.html @@ -0,0 +1,49 @@ +{% load helpers %} + +
+
Contacts
+
+ {% with contacts=object.contacts.all %} + {% if contacts.exists %} + + + + + + + + {% for contact in contacts %} + + + + + + + {% endfor %} +
NameRolePriority
+ {{ contact.contact }} + {{ contact.role|placeholder }}{{ contact.get_priority_display|placeholder }} + {% if perms.tenancy.change_contactassignment %} + + + + {% endif %} + {% if perms.tenancy.delete_contactassignment %} + + + + {% endif %} +
+ {% else %} +
None
+ {% endif %} + {% endwith %} +
+ {% if perms.tenancy.add_contactassignment %} + + {% endif %} +
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 %} +
+
diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index c254d9d63..aca89a526 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -64,8 +64,8 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:aggregate_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html new file mode 100644 index 000000000..ad828c32e --- /dev/null +++ b/netbox/templates/ipam/asn.html @@ -0,0 +1,78 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock breadcrumbs %} + +{% block content %} +
+
+
+
ASN
+
+ + + + + + + + + + + + + + + + + + + + + +
AS Number{{ object.asn }}
RIR + {{ object.rir }} +
Tenant + {% if object.tenant %} + {% if prefix.object.group %} + {{ object.tenant.group }} / + {% endif %} + {{ object.tenant }} + {% else %} + None + {% endif %} +
Description{{ object.description|placeholder }}
Sites + {% if sites_count %} + {{ sites_count }} + {% else %} + {{ sites_count }} + {% endif %} +
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %} + {% plugin_right_page object %} +
+
+
+
+
+
Sites
+
+ {% include 'inc/table.html' with table=sites_table %} +
+
+ {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %} + {% plugin_full_width_page object %} +
+
+{% endblock content %} diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html new file mode 100644 index 000000000..32ed1fc1c --- /dev/null +++ b/netbox/templates/ipam/fhrpgroup.html @@ -0,0 +1,95 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{# Omit assigned IP addresses from object representation #} +{% block title %}{{ object.get_protocol_display }}: {{ object.group_id }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock breadcrumbs %} + +{% block content %} +
+
+
+
FHRP Group
+
+ + + + + + + + + + + + + + + + + +
Protocol{{ object.get_protocol_display }}
Group ID{{ object.group_id }}
Description{{ object.description|placeholder }}
Members{{ member_count }}
+
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+
+
Authentication
+
+ + + + + + + + + +
Authentication Type{{ object.get_auth_type_display|placeholder }}
Authentication Key{{ object.auth_key|placeholder }}
+
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
Virtual IP Addresses
+
+ {% if ipaddress_table.rows %} + {% render_table ipaddress_table 'inc/table.html' %} + {% else %} +
None
+ {% endif %} +
+ {% if perms.ipam.add_ipaddress %} + + {% endif %} +
+
+
Members
+
+ {% if members_table.rows %} + {% render_table members_table 'inc/table.html' %} + {% else %} +
None
+ {% endif %} +
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/fhrpgroup_edit.html b/netbox/templates/ipam/fhrpgroup_edit.html new file mode 100644 index 000000000..858d265ab --- /dev/null +++ b/netbox/templates/ipam/fhrpgroup_edit.html @@ -0,0 +1,40 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
+
FHRP Group
+
+ {% render_field form.protocol %} + {% render_field form.group_id %} + {% render_field form.description %} + {% render_field form.tags %} +
+ +
+
+
Authentication
+
+ {% render_field form.auth_type %} + {% render_field form.auth_key %} +
+ + {% if not form.instance.pk %} +
+
+
Virtual IP Address
+
+ {% render_field form.ip_vrf %} + {% render_field form.ip_address %} + {% render_field form.ip_status %} +
+ {% endif %} + + {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} + {% endif %} +{% endblock %} diff --git a/netbox/templates/ipam/fhrpgroupassignment_edit.html b/netbox/templates/ipam/fhrpgroupassignment_edit.html new file mode 100644 index 000000000..5801febca --- /dev/null +++ b/netbox/templates/ipam/fhrpgroupassignment_edit.html @@ -0,0 +1,18 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
+
FHRP Group Assignment
+
+
+ +
+ +
+
+ {% render_field form.group %} + {% render_field form.priority %} +
+{% endblock %} diff --git a/netbox/templates/ipam/inc/panels/fhrp_groups.html b/netbox/templates/ipam/inc/panels/fhrp_groups.html new file mode 100644 index 000000000..6583694ef --- /dev/null +++ b/netbox/templates/ipam/inc/panels/fhrp_groups.html @@ -0,0 +1,67 @@ +{% load helpers %} + +
+
FHRP Groups
+
+ + + + + + + + + + + + {% for assignment in object.fhrp_group_assignments.all %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
GroupProtocolVirtual IPsPriority
+ {{ assignment.group.group_id }} + + {{ assignment.group.get_protocol_display }} + + {% for ipaddress in assignment.group.ip_addresses.all %} + {{ ipaddress }} + {% if not forloop.last %}
{% endif %} + {% endfor %} +
+ {{ assignment.priority }} + + {% if perms.ipam.change_fhrpgroupassignment %} + + + + {% endif %} + {% if perms.ipam.delete_fhrpgroupassignment %} + + + + {% endif %} +
None
+
+ +
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 668290458..c39f4398a 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -73,12 +73,14 @@ Assignment - {% if object.assigned_object %} - {{ object.assigned_object.parent_object }} / - {{ object.assigned_object }} - {% else %} - + {% if object.assigned_object %} + {% if object.assigned_object.parent_object %} + {{ object.assigned_object.parent_object }} / {% endif %} + {{ object.assigned_object }} + {% else %} + + {% endif %} @@ -107,7 +109,7 @@ - {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_left_page object %} @@ -145,7 +147,7 @@
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:ipaddress_list' %} + {% include 'inc/panels/tags.html' %}
diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index c23fcd63c..e611aefbb 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -33,51 +33,41 @@
Interface Assignment
- {% with vm_tab_active=form.initial.vminterface %} -
-
- -
+
+
+
-
-
- {% render_field form.device %} - {% render_field form.interface %} -
-
- {% render_field form.virtual_machine %} - {% render_field form.vminterface %} -
- {% render_field form.primary_for_parent %} +
+
+
+ {% render_field form.device %} + {% render_field form.interface %}
- {% endwith %} +
+ {% render_field form.virtual_machine %} + {% render_field form.vminterface %} +
+
+ {% render_field form.fhrpgroup %} +
+ {% render_field form.primary_for_parent %} +
diff --git a/netbox/templates/ipam/iprange.html b/netbox/templates/ipam/iprange.html index 729f1ed42..b549ec7c5 100644 --- a/netbox/templates/ipam/iprange.html +++ b/netbox/templates/ipam/iprange.html @@ -82,8 +82,8 @@ {% plugin_left_page object %}
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:prefix_list' %} - {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 4e3fd2edf..eaea4e1ec 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -121,8 +121,8 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:prefix_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/rir.html b/netbox/templates/ipam/rir.html index d9d13e110..c2f88c278 100644 --- a/netbox/templates/ipam/rir.html +++ b/netbox/templates/ipam/rir.html @@ -38,10 +38,11 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/role.html b/netbox/templates/ipam/role.html index 72a4767c9..5579010fa 100644 --- a/netbox/templates/ipam/role.html +++ b/netbox/templates/ipam/role.html @@ -32,10 +32,11 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html index 94eec6a15..71d6f9601 100644 --- a/netbox/templates/ipam/routetarget.html +++ b/netbox/templates/ipam/routetarget.html @@ -3,50 +3,48 @@ {% load plugins %} {% block content %} -
-
-
-
- Route Target -
-
- - - - - - - - - - - - - -
Name{{ object.name }}
Tenant - {% if object.tenant %} - {{ object.tenant }} - {% else %} - None - {% endif %} -
Description{{ object.description|placeholder }}
-
+
+
+
+
Route Target
+
+ + + + + + + + + + + + + +
Name{{ object.name }}
Tenant + {% if object.tenant %} + {{ object.tenant }} + {% else %} + None + {% endif %} +
Description{{ object.description|placeholder }}
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:routetarget_list' %} - {% include 'inc/custom_fields_panel.html' %} - {% plugin_left_page object %} -
-
-
+
+ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_left_page object %} +
+
+
{% include 'inc/panel_table.html' with table=importing_vrfs_table heading="Importing VRFs" %} -
- {% include 'inc/panel_table.html' with table=exporting_vrfs_table heading="Exporting VRFs" %} - {% plugin_right_page object %} +
+ {% include 'inc/panel_table.html' with table=exporting_vrfs_table heading="Exporting VRFs" %} + {% plugin_right_page object %}
-
-
+
+
- {% plugin_full_width_page object %} + {% plugin_full_width_page object %}
-
+
{% endblock %} diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index 6083d1b5e..5a47e44f0 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -60,8 +60,8 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:service_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 5ecd6efed..367ae3641 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -82,8 +82,8 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:vlan_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index a46bef3b0..d51253b62 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -1,6 +1,7 @@ {% extends 'generic/object.html' %} {% load helpers %} {% load plugins %} +{% load render_table from django_tables2 %} {% block breadcrumbs %} {{ block.super }} @@ -54,10 +55,11 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
@@ -68,7 +70,7 @@ VLANs
- {% include 'inc/table.html' with table=vlans_table %} + {% render_table vlans_table 'inc/table.html' %}
{% if perms.ipam.add_vlan %}
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:vrf_list' %} - {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/login.html b/netbox/templates/login.html index 37cdd8e53..b7f466c16 100644 --- a/netbox/templates/login.html +++ b/netbox/templates/login.html @@ -7,9 +7,9 @@
{# Login banner #} - {% if settings.BANNER_LOGIN %} + {% if config.BANNER_LOGIN %} {% endif %} @@ -39,6 +39,14 @@ + {# TODO: Improve the design & layout #} + {% if auth_backends %} +
Or use an SSO provider:
+ {% for name, backend in auth_backends.items %} +

{{ name }}

+ {% endfor %} + {% endif %} + {# Login form errors #} {% if form.non_field_errors %} - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='tenancy:tenant_list' %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/tenancy/tenantgroup.html b/netbox/templates/tenancy/tenantgroup.html index 06fd07522..75d2c5a27 100644 --- a/netbox/templates/tenancy/tenantgroup.html +++ b/netbox/templates/tenancy/tenantgroup.html @@ -45,10 +45,11 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 769ae431f..b7af89bb2 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -56,12 +56,13 @@ - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:cluster_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/clustergroup.html b/netbox/templates/virtualization/clustergroup.html index f7e8cbe5b..3979fa0e6 100644 --- a/netbox/templates/virtualization/clustergroup.html +++ b/netbox/templates/virtualization/clustergroup.html @@ -28,10 +28,12 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/clustertype.html b/netbox/templates/virtualization/clustertype.html index 9ef1abb8e..de5f3c519 100644 --- a/netbox/templates/virtualization/clustertype.html +++ b/netbox/templates/virtualization/clustertype.html @@ -28,10 +28,11 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 249ef91e4..068d7f164 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -89,9 +89,9 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:virtualmachine_list' %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
@@ -173,6 +173,7 @@
{% endif %} + {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index 6a618a1be..105adb6b7 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -47,6 +47,16 @@ {% endif %} + + Bridge + + {% if object.bridge %} + {{ object.bridge }} + {% else %} + None + {% endif %} + + Description {{ object.description|placeholder }} @@ -66,12 +76,13 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} - {% plugin_right_page object %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'ipam/inc/panels/fhrp_groups.html' %} + {% plugin_right_page object %}
@@ -89,7 +100,7 @@
{% if perms.ipam.add_ipaddress %} diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index b4d097513..687c6c090 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -15,12 +15,19 @@ {% endif %} {% render_field form.name %} - {% render_field form.enabled %} - {% render_field form.parent %} + {% render_field form.description %} {% render_field form.mac_address %} {% render_field form.mtu %} - {% render_field form.description %} {% render_field form.tags %} + {% render_field form.enabled %} + + +
+
+
Related Interfaces
+
+ {% render_field form.parent %} + {% render_field form.bridge %}
@@ -46,7 +53,7 @@ {% block buttons %} Cancel {% if obj.pk %} - + {% else %} diff --git a/netbox/templates/wireless/inc/authentication_attrs.html b/netbox/templates/wireless/inc/authentication_attrs.html new file mode 100644 index 000000000..ed4c7546c --- /dev/null +++ b/netbox/templates/wireless/inc/authentication_attrs.html @@ -0,0 +1,21 @@ +{% load helpers %} + +
+
Authentication
+
+ + + + + + + + + + + + + +
Type{{ object.get_auth_type_display|placeholder }}
Cipher{{ object.get_auth_cipher_display|placeholder }}
PSK{{ object.auth_psk|placeholder }}
+
+
diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html new file mode 100644 index 000000000..e33047539 --- /dev/null +++ b/netbox/templates/wireless/inc/wirelesslink_interface.html @@ -0,0 +1,54 @@ +{% load helpers %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Device + {{ interface.device }} +
Interface + {{ interface }} +
Type + {{ interface.get_type_display }} +
Role + {{ interface.get_rf_role_display|placeholder }} +
Channel + {{ interface.get_rf_channel_display|placeholder }} +
Channel Frequency + {% if interface.rf_channel_frequency %} + {{ interface.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} +
Channel Width + {% if interface.rf_channel_width %} + {{ interface.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} +
diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html new file mode 100644 index 000000000..370102ed1 --- /dev/null +++ b/netbox/templates/wireless/wirelesslan.html @@ -0,0 +1,64 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
Wireless LAN
+
+ + + + + + + + + + + + + + + + + +
SSID{{ object.ssid }}
Group + {% if object.group %} + {{ object.group }} + {% else %} + None + {% endif %} +
Description{{ object.description|placeholder }}
VLAN + {% if object.vlan %} + {{ object.vlan }} + {% else %} + None + {% endif %} +
+
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+ {% include 'wireless/inc/authentication_attrs.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
Attached Interfaces
+
+ {% include 'inc/table.html' with table=interfaces_table %} +
+
+ {% include 'inc/paginator.html' with paginator=interfaces_table.paginator page=interfaces_table.page %} + {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/wireless/wirelesslangroup.html b/netbox/templates/wireless/wirelesslangroup.html new file mode 100644 index 000000000..3e6bc382e --- /dev/null +++ b/netbox/templates/wireless/wirelesslangroup.html @@ -0,0 +1,73 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + {{ block.super }} + {% for group in object.get_ancestors %} + + {% endfor %} +{% endblock %} + +{% block content %} +
+
+
+
Wireless LAN Group
+
+ + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
Wireless LANs + {{ wirelesslans_table.rows|length }} +
+
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
Wireless LANs
+
+ {% include 'inc/table.html' with table=wirelesslans_table %} +
+ {% if perms.wireless.add_wirelesslan %} + + {% endif %} +
+ {% include 'inc/paginator.html' with paginator=wirelesslans_table.paginator page=wirelesslans_table.page %} + {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html new file mode 100644 index 000000000..6ad88729d --- /dev/null +++ b/netbox/templates/wireless/wirelesslink.html @@ -0,0 +1,55 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
Interface A
+
+ {% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_a %} +
+
+
+
Link Properties
+
+ + + + + + + + + + + + + +
Status + {{ object.get_status_display }} +
SSID{{ object.ssid|placeholder }}
Description{{ object.description|placeholder }}
+
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+
+
Interface B
+
+ {% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_b %} +
+
+ {% include 'wireless/inc/authentication_attrs.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/wireless/wirelesslink_edit.html b/netbox/templates/wireless/wirelesslink_edit.html new file mode 100644 index 000000000..034d147de --- /dev/null +++ b/netbox/templates/wireless/wirelesslink_edit.html @@ -0,0 +1,33 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
+
+
+
Side A
+
+ {% render_field form.device_a %} + {% render_field form.interface_a %} +
+
+
+
+
+
Side B
+
+ {% render_field form.device_b %} + {% render_field form.interface_b %} +
+
+
+ {% if form.custom_fields %} +
+
+
Custom Fields
+
+ {% render_custom_fields form %} +
+ {% endif %} +{% endblock %} diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index 11225fa7a..00ac6ff84 100644 --- a/netbox/tenancy/api/nested_serializers.py +++ b/netbox/tenancy/api/nested_serializers.py @@ -1,9 +1,13 @@ from rest_framework import serializers from netbox.api import WritableNestedSerializer -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * __all__ = [ + 'NestedContactSerializer', + 'NestedContactAssignmentSerializer', + 'NestedContactGroupSerializer', + 'NestedContactRoleSerializer', 'NestedTenantGroupSerializer', 'NestedTenantSerializer', ] @@ -29,3 +33,43 @@ class NestedTenantSerializer(WritableNestedSerializer): class Meta: model = Tenant fields = ['id', 'url', 'display', 'name', 'slug'] + + +# +# Contacts +# + +class NestedContactGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail') + contact_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = ContactGroup + fields = ['id', 'url', 'display', 'name', 'slug', 'contact_count', '_depth'] + + +class NestedContactRoleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail') + + class Meta: + model = ContactRole + fields = ['id', 'url', 'display', 'name', 'slug'] + + +class NestedContactSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail') + + class Meta: + model = Contact + fields = ['id', 'url', 'display', 'name'] + + +class NestedContactAssignmentSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail') + contact = NestedContactSerializer() + role = NestedContactRoleSerializer + + class Meta: + model = ContactAssignment + fields = ['id', 'url', 'display', 'contact', 'role', 'priority'] diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 3136c811c..f60c8f258 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,7 +1,12 @@ +from django.contrib.auth.models import ContentType +from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers +from netbox.api import ChoiceField, ContentTypeField from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer -from tenancy.models import Tenant, TenantGroup +from tenancy.choices import ContactPriorityChoices +from tenancy.models import * +from utilities.api import get_serializer_for_model from .nested_serializers import * @@ -17,8 +22,8 @@ class TenantGroupSerializer(NestedGroupModelSerializer): class Meta: model = TenantGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', - 'tenant_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'tenant_count', '_depth', ] @@ -43,3 +48,66 @@ class TenantSerializer(PrimaryModelSerializer): 'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count', 'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count', ] + + +# +# Contacts +# + +class ContactGroupSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail') + parent = NestedContactGroupSerializer(required=False, allow_null=True) + contact_count = serializers.IntegerField(read_only=True) + + class Meta: + model = ContactGroup + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'contact_count', '_depth', + ] + + +class ContactRoleSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail') + + class Meta: + model = ContactRole + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + ] + + +class ContactSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail') + group = NestedContactGroupSerializer(required=False, allow_null=True, default=None) + + class Meta: + model = Contact + fields = [ + 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + + +class ContactAssignmentSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail') + content_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + object = serializers.SerializerMethodField(read_only=True) + contact = NestedContactSerializer() + role = NestedContactRoleSerializer(required=False, allow_null=True) + priority = ChoiceField(choices=ContactPriorityChoices, required=False) + + class Meta: + model = ContactAssignment + fields = [ + 'id', 'url', 'display', 'content_type', 'object_id', 'object', 'contact', 'role', 'priority', 'created', + 'last_updated', + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_object(self, instance): + serializer = get_serializer_for_model(instance.content_type.model_class(), prefix='Nested') + context = {'request': self.context['request']} + return serializer(instance.object, context=context).data diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 32540879d..00e1a6469 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -9,5 +9,11 @@ router.APIRootView = views.TenancyRootView router.register('tenant-groups', views.TenantGroupViewSet) router.register('tenants', views.TenantViewSet) +# Contacts +router.register('contact-groups', views.ContactGroupViewSet) +router.register('contact-roles', views.ContactRoleViewSet) +router.register('contacts', views.ContactViewSet) +router.register('contact-assignments', views.ContactAssignmentViewSet) + app_name = 'tenancy-api' urlpatterns = router.urls diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 2e049135d..50b188b5f 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -5,7 +5,7 @@ from dcim.models import Device, Rack, Site from extras.api.views import CustomFieldModelViewSet from ipam.models import IPAddress, Prefix, VLAN, VRF from tenancy import filtersets -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * from utilities.utils import count_related from virtualization.models import VirtualMachine from . import serializers @@ -20,7 +20,7 @@ class TenancyRootView(APIRootView): # -# Tenant Groups +# Tenants # class TenantGroupViewSet(CustomFieldModelViewSet): @@ -30,15 +30,11 @@ class TenantGroupViewSet(CustomFieldModelViewSet): 'group', 'tenant_count', cumulative=True - ) + ).prefetch_related('tags') serializer_class = serializers.TenantGroupSerializer filterset_class = filtersets.TenantGroupFilterSet -# -# Tenants -# - class TenantViewSet(CustomFieldModelViewSet): queryset = Tenant.objects.prefetch_related( 'group', 'tags' @@ -55,3 +51,37 @@ class TenantViewSet(CustomFieldModelViewSet): ) serializer_class = serializers.TenantSerializer filterset_class = filtersets.TenantFilterSet + + +# +# Contacts +# + +class ContactGroupViewSet(CustomFieldModelViewSet): + queryset = ContactGroup.objects.add_related_count( + ContactGroup.objects.all(), + Contact, + 'group', + 'contact_count', + cumulative=True + ).prefetch_related('tags') + serializer_class = serializers.ContactGroupSerializer + filterset_class = filtersets.ContactGroupFilterSet + + +class ContactRoleViewSet(CustomFieldModelViewSet): + queryset = ContactRole.objects.prefetch_related('tags') + serializer_class = serializers.ContactRoleSerializer + filterset_class = filtersets.ContactRoleFilterSet + + +class ContactViewSet(CustomFieldModelViewSet): + queryset = Contact.objects.prefetch_related('group', 'tags') + serializer_class = serializers.ContactSerializer + filterset_class = filtersets.ContactFilterSet + + +class ContactAssignmentViewSet(CustomFieldModelViewSet): + queryset = ContactAssignment.objects.prefetch_related('object', 'contact', 'role') + serializer_class = serializers.ContactAssignmentSerializer + filterset_class = filtersets.ContactAssignmentFilterSet diff --git a/netbox/tenancy/choices.py b/netbox/tenancy/choices.py new file mode 100644 index 000000000..b59d2050d --- /dev/null +++ b/netbox/tenancy/choices.py @@ -0,0 +1,19 @@ +from utilities.choices import ChoiceSet + + +# +# Contacts +# + +class ContactPriorityChoices(ChoiceSet): + PRIORITY_PRIMARY = 'primary' + PRIORITY_SECONDARY = 'secondary' + PRIORITY_TERTIARY = 'tertiary' + PRIORITY_INACTIVE = 'inactive' + + CHOICES = ( + (PRIORITY_PRIMARY, 'Primary'), + (PRIORITY_SECONDARY, 'Secondary'), + (PRIORITY_TERTIARY, 'Tertiary'), + (PRIORITY_INACTIVE, 'Inactive'), + ) diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index d00b78629..c8af89143 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -2,18 +2,26 @@ import django_filters from django.db.models import Q from extras.filters import TagFilter -from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet -from utilities.filters import TreeNodeMultipleChoiceFilter -from .models import Tenant, TenantGroup +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet +from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter +from .models import * __all__ = ( + 'ContactAssignmentFilterSet', + 'ContactFilterSet', + 'ContactGroupFilterSet', + 'ContactRoleFilterSet', 'TenancyFilterSet', 'TenantFilterSet', 'TenantGroupFilterSet', ) +# +# Tenancy +# + class TenantGroupFilterSet(OrganizationalModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=TenantGroup.objects.all(), @@ -23,8 +31,9 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): field_name='parent__slug', queryset=TenantGroup.objects.all(), to_field_name='slug', - label='Tenant group group (slug)', + label='Tenant group (slug)', ) + tag = TagFilter() class Meta: model = TenantGroup @@ -93,3 +102,92 @@ class TenancyFilterSet(django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) + + +# +# Contacts +# + +class ContactGroupFilterSet(OrganizationalModelFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + label='Contact group (ID)', + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=ContactGroup.objects.all(), + to_field_name='slug', + label='Contact group (slug)', + ) + tag = TagFilter() + + class Meta: + model = ContactGroup + fields = ['id', 'name', 'slug', 'description'] + + +class ContactRoleFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() + + class Meta: + model = ContactRole + fields = ['id', 'name', 'slug'] + + +class ContactFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + group_id = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='group', + lookup_expr='in', + label='Contact group (ID)', + ) + group = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='group', + lookup_expr='in', + to_field_name='slug', + label='Contact group (slug)', + ) + tag = TagFilter() + + class Meta: + model = Contact + fields = ['id', 'name', 'title', 'phone', 'email', 'address'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(title__icontains=value) | + Q(phone__icontains=value) | + Q(email__icontains=value) | + Q(address__icontains=value) | + Q(comments__icontains=value) + ) + + +class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet): + content_type = ContentTypeFilter() + contact_id = django_filters.ModelMultipleChoiceFilter( + queryset=Contact.objects.all(), + label='Contact (ID)', + ) + role_id = django_filters.ModelMultipleChoiceFilter( + queryset=ContactRole.objects.all(), + label='Contact role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + field_name='role__slug', + queryset=ContactRole.objects.all(), + to_field_name='slug', + label='Contact role (slug)', + ) + + class Meta: + model = ContactAssignment + fields = ['id', 'content_type_id', 'object_id', 'priority'] diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index b2fc7dafd..9dc1b8ec5 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -1,16 +1,23 @@ from django import forms from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm -from tenancy.models import Tenant, TenantGroup -from utilities.forms import BootstrapMixin, DynamicModelChoiceField +from tenancy.models import * +from utilities.forms import DynamicModelChoiceField __all__ = ( + 'ContactBulkEditForm', + 'ContactGroupBulkEditForm', + 'ContactRoleBulkEditForm', 'TenantBulkEditForm', 'TenantGroupBulkEditForm', ) -class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +# +# Tenants +# + +class TenantGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=TenantGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -28,7 +35,7 @@ class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'description'] -class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class TenantBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput() @@ -42,3 +49,68 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk nullable_fields = [ 'group', ] + + +# +# Contacts +# + +class ContactGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ContactGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + +class ContactRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ContactRole.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + +class ContactBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Contact.objects.all(), + widget=forms.MultipleHiddenInput() + ) + group = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False + ) + title = forms.CharField( + max_length=100, + required=False + ) + phone = forms.CharField( + max_length=50, + required=False + ) + email = forms.EmailField( + required=False + ) + address = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['group', 'title', 'phone', 'email', 'address', 'comments'] diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index 335d71ef6..51b863cac 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -1,13 +1,20 @@ from extras.forms import CustomFieldModelCSVForm -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * from utilities.forms import CSVModelChoiceField, SlugField __all__ = ( + 'ContactCSVForm', + 'ContactGroupCSVForm', + 'ContactRoleCSVForm', 'TenantCSVForm', 'TenantGroupCSVForm', ) +# +# Tenants +# + class TenantGroupCSVForm(CustomFieldModelCSVForm): parent = CSVModelChoiceField( queryset=TenantGroup.objects.all(), @@ -34,3 +41,42 @@ class TenantCSVForm(CustomFieldModelCSVForm): class Meta: model = Tenant fields = ('name', 'slug', 'group', 'description', 'comments') + + +# +# Contacts +# + +class ContactGroupCSVForm(CustomFieldModelCSVForm): + parent = CSVModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Parent group' + ) + slug = SlugField() + + class Meta: + model = ContactGroup + fields = ('name', 'slug', 'parent', 'description') + + +class ContactRoleCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + + class Meta: + model = ContactRole + fields = ('name', 'slug', 'description') + + +class ContactCSVForm(CustomFieldModelCSVForm): + group = CSVModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned group' + ) + + class Meta: + model = Contact + fields = ('name', 'title', 'phone', 'email', 'address', 'group', 'comments') diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 6e2eb7fd1..957f0ab7b 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -2,36 +2,39 @@ from django import forms from django.utils.translation import gettext as _ from extras.forms import CustomFieldModelFilterForm -from tenancy.models import Tenant, TenantGroup -from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, TagFilterField +from tenancy.models import * +from utilities.forms import DynamicModelMultipleChoiceField, TagFilterField + +__all__ = ( + 'ContactFilterForm', + 'ContactGroupFilterForm', + 'ContactRoleFilterForm', + 'TenantFilterForm', + 'TenantGroupFilterForm', +) -class TenantGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): +# +# Tenants +# + +class TenantGroupFilterForm(CustomFieldModelFilterForm): model = TenantGroup - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) parent_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), required=False, label=_('Parent group'), fetch_trigger='open' ) + tag = TagFilterField(model) -class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm): +class TenantFilterForm(CustomFieldModelFilterForm): model = Tenant field_groups = ( ('q', 'tag'), ('group_id',), ) - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) group_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), required=False, @@ -40,3 +43,39 @@ class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm): fetch_trigger='open' ) tag = TagFilterField(model) + + +# +# Contacts +# + +class ContactGroupFilterForm(CustomFieldModelFilterForm): + model = ContactGroup + parent_id = DynamicModelMultipleChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + label=_('Parent group'), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class ContactRoleFilterForm(CustomFieldModelFilterForm): + model = ContactRole + tag = TagFilterField(model) + + +class ContactFilterForm(CustomFieldModelFilterForm): + model = Contact + field_groups = ( + ('q', 'tag'), + ('group_id',), + ) + group_id = DynamicModelMultipleChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + null_option='None', + label=_('Group'), + fetch_trigger='open' + ) + tag = TagFilterField(model) diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index de3a9e515..398a44c9b 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/models.py @@ -1,31 +1,46 @@ +from django import forms + from extras.forms import CustomFieldModelForm from extras.models import Tag -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * from utilities.forms import ( - BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, + BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea, + StaticSelect, ) __all__ = ( + 'ContactAssignmentForm', + 'ContactForm', + 'ContactGroupForm', + 'ContactRoleForm', 'TenantForm', 'TenantGroupForm', ) -class TenantGroupForm(BootstrapMixin, CustomFieldModelForm): +# +# Tenants +# + +class TenantGroupForm(CustomFieldModelForm): parent = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = TenantGroup fields = [ - 'parent', 'name', 'slug', 'description', + 'parent', 'name', 'slug', 'description', 'tags', ] -class TenantForm(BootstrapMixin, CustomFieldModelForm): +class TenantForm(CustomFieldModelForm): slug = SlugField() group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), @@ -45,3 +60,87 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): fieldsets = ( ('Tenant', ('name', 'slug', 'group', 'description', 'tags')), ) + + +# +# Contacts +# + +class ContactGroupForm(CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False + ) + slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = ContactGroup + fields = ('parent', 'name', 'slug', 'description', 'tags') + + +class ContactRoleForm(CustomFieldModelForm): + slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = ContactRole + fields = ('name', 'slug', 'description', 'tags') + + +class ContactForm(CustomFieldModelForm): + group = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Contact + fields = ( + 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags', + ) + fieldsets = ( + ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'tags')), + ) + widgets = { + 'address': SmallTextarea(attrs={'rows': 3}), + } + + +class ContactAssignmentForm(BootstrapMixin, forms.ModelForm): + group = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + initial_params={ + 'contacts': '$contact' + } + ) + contact = DynamicModelChoiceField( + queryset=Contact.objects.all(), + query_params={ + 'group_id': '$group' + } + ) + role = DynamicModelChoiceField( + queryset=ContactRole.objects.all() + ) + + class Meta: + model = ContactAssignment + fields = ( + 'group', 'contact', 'role', 'priority', + ) + widgets = { + 'priority': StaticSelect(), + } diff --git a/netbox/tenancy/graphql/schema.py b/netbox/tenancy/graphql/schema.py index f420eb787..de0a1781a 100644 --- a/netbox/tenancy/graphql/schema.py +++ b/netbox/tenancy/graphql/schema.py @@ -10,3 +10,15 @@ class TenancyQuery(graphene.ObjectType): tenant_group = ObjectField(TenantGroupType) tenant_group_list = ObjectListField(TenantGroupType) + + contact = ObjectField(ContactType) + contact_list = ObjectListField(ContactType) + + contact_role = ObjectField(ContactRoleType) + contact_role_list = ObjectListField(ContactRoleType) + + contact_group = ObjectField(ContactGroupType) + contact_group_list = ObjectListField(ContactGroupType) + + contact_assignment = ObjectField(ContactAssignmentType) + contact_assignment_list = ObjectListField(ContactAssignmentType) diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index 6f1e27274..ce00eafa3 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -1,12 +1,29 @@ +import graphene + from tenancy import filtersets, models -from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType __all__ = ( + 'ContactAssignmentType', + 'ContactGroupType', + 'ContactRoleType', + 'ContactType', 'TenantType', 'TenantGroupType', ) +class ContactAssignmentsMixin: + assignments = graphene.List('tenancy.graphql.types.ContactAssignmentType') + + def resolve_assignments(self, info): + return self.assignments.restrict(info.context.user, 'view') + + +# +# Tenants +# + class TenantType(PrimaryObjectType): class Meta: @@ -21,3 +38,39 @@ class TenantGroupType(OrganizationalObjectType): model = models.TenantGroup fields = '__all__' filterset_class = filtersets.TenantGroupFilterSet + + +# +# Contacts +# + +class ContactType(ContactAssignmentsMixin, PrimaryObjectType): + + class Meta: + model = models.Contact + fields = '__all__' + filterset_class = filtersets.ContactFilterSet + + +class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType): + + class Meta: + model = models.ContactRole + fields = '__all__' + filterset_class = filtersets.ContactRoleFilterSet + + +class ContactGroupType(OrganizationalObjectType): + + class Meta: + model = models.ContactGroup + fields = '__all__' + filterset_class = filtersets.ContactGroupFilterSet + + +class ContactAssignmentType(BaseObjectType): + + class Meta: + model = models.ContactAssignment + fields = '__all__' + filterset_class = filtersets.ContactAssignmentFilterSet diff --git a/netbox/tenancy/migrations/0003_contacts.py b/netbox/tenancy/migrations/0003_contacts.py new file mode 100644 index 000000000..35e568ab1 --- /dev/null +++ b/netbox/tenancy/migrations/0003_contacts.py @@ -0,0 +1,91 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('contenttypes', '0002_remove_content_type_name'), + ('tenancy', '0002_tenant_ordering'), + ] + + operations = [ + migrations.CreateModel( + name='ContactRole', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ContactGroup', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('slug', models.SlugField(max_length=100)), + ('description', models.CharField(blank=True, max_length=200)), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='tenancy.contactgroup')), + ], + options={ + 'ordering': ['name'], + 'unique_together': {('parent', 'name')}, + }, + ), + migrations.CreateModel( + name='Contact', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('title', models.CharField(blank=True, max_length=100)), + ('phone', models.CharField(blank=True, max_length=50)), + ('email', models.EmailField(blank=True, max_length=254)), + ('address', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacts', to='tenancy.contactgroup')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ['name'], + 'unique_together': {('group', 'name')}, + }, + ), + migrations.CreateModel( + name='ContactAssignment', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('object_id', models.PositiveIntegerField()), + ('priority', models.CharField(blank=True, max_length=50)), + ('contact', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='tenancy.contact')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='tenancy.contactrole')), + ], + options={ + 'ordering': ('priority', 'contact'), + 'unique_together': {('content_type', 'object_id', 'contact', 'role', 'priority')}, + }, + ), + ] diff --git a/netbox/tenancy/migrations/0004_extend_tag_support.py b/netbox/tenancy/migrations/0004_extend_tag_support.py new file mode 100644 index 000000000..942be38b5 --- /dev/null +++ b/netbox/tenancy/migrations/0004_extend_tag_support.py @@ -0,0 +1,30 @@ +# 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'), + ('tenancy', '0003_contacts'), + ] + + operations = [ + migrations.AddField( + model_name='contactgroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='contactrole', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='tenantgroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/tenancy/models/__init__.py b/netbox/tenancy/models/__init__.py new file mode 100644 index 000000000..6d62edd20 --- /dev/null +++ b/netbox/tenancy/models/__init__.py @@ -0,0 +1,2 @@ +from .contacts import * +from .tenants import * diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py new file mode 100644 index 000000000..42a7ffe7d --- /dev/null +++ b/netbox/tenancy/models/contacts.py @@ -0,0 +1,168 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.urls import reverse +from mptt.models import TreeForeignKey + +from extras.utils import extras_features +from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel +from tenancy.choices import * + +__all__ = ( + 'ContactAssignment', + 'Contact', + 'ContactGroup', + 'ContactRole', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class ContactGroup(NestedGroupModel): + """ + An arbitrary collection of Contacts. + """ + name = models.CharField( + max_length=100 + ) + slug = models.SlugField( + max_length=100 + ) + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + class Meta: + ordering = ['name'] + unique_together = ( + ('parent', 'name') + ) + + def get_absolute_url(self): + return reverse('tenancy:contactgroup', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class ContactRole(OrganizationalModel): + """ + Functional role for a Contact assigned to an object. + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True, + ) + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('tenancy:contactrole', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Contact(PrimaryModel): + """ + Contact information for a particular object(s) in NetBox. + """ + group = models.ForeignKey( + to='tenancy.ContactGroup', + on_delete=models.SET_NULL, + related_name='contacts', + blank=True, + null=True + ) + name = models.CharField( + max_length=100 + ) + title = models.CharField( + max_length=100, + blank=True + ) + phone = models.CharField( + max_length=50, + blank=True + ) + email = models.EmailField( + blank=True + ) + address = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) + + clone_fields = [ + 'group', + ] + + class Meta: + ordering = ['name'] + unique_together = ( + ('group', 'name') + ) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('tenancy:contact', args=[self.pk]) + + +@extras_features('webhooks') +class ContactAssignment(ChangeLoggedModel): + content_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) + object_id = models.PositiveIntegerField() + object = GenericForeignKey( + ct_field='content_type', + fk_field='object_id' + ) + contact = models.ForeignKey( + to='tenancy.Contact', + on_delete=models.PROTECT, + related_name='assignments' + ) + role = models.ForeignKey( + to='tenancy.ContactRole', + on_delete=models.PROTECT, + related_name='assignments' + ) + priority = models.CharField( + max_length=50, + choices=ContactPriorityChoices, + blank=True + ) + + clone_fields = ('content_type', 'object_id') + + class Meta: + ordering = ('priority', 'contact') + unique_together = ('content_type', 'object_id', 'contact', 'role', 'priority') + + def __str__(self): + if self.priority: + return f"{self.contact} ({self.get_priority_display()})" + return str(self.contact) diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models/tenants.py similarity index 89% rename from netbox/tenancy/models.py rename to netbox/tenancy/models/tenants.py index 4a5b1967e..d480f9112 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models/tenants.py @@ -1,12 +1,10 @@ -from django.core.exceptions import ValidationError +from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse -from mptt.models import MPTTModel, TreeForeignKey +from mptt.models import TreeForeignKey from extras.utils import extras_features from netbox.models import NestedGroupModel, PrimaryModel -from utilities.querysets import RestrictedQuerySet - __all__ = ( 'Tenant', @@ -14,7 +12,7 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class TenantGroup(NestedGroupModel): """ An arbitrary collection of Tenants. @@ -76,7 +74,10 @@ class Tenant(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) clone_fields = [ 'group', 'description', diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index a7bb087d8..0ae1139bf 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,11 +1,16 @@ import django_tables2 as tables from utilities.tables import ( - BaseTable, ButtonsColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, + BaseTable, ButtonsColumn, ContentTypeColumn, LinkedCountColumn, linkify_phone, MarkdownColumn, MPTTColumn, + TagColumn, ToggleColumn, ) -from .models import Tenant, TenantGroup +from .models import * __all__ = ( + 'ContactAssignmentTable', + 'ContactGroupTable', + 'ContactRoleTable', + 'ContactTable', 'TenantColumn', 'TenantGroupTable', 'TenantTable', @@ -38,7 +43,7 @@ class TenantColumn(tables.TemplateColumn): # -# Tenant groups +# Tenants # class TenantGroupTable(BaseTable): @@ -51,18 +56,17 @@ class TenantGroupTable(BaseTable): url_params={'group_id': 'pk'}, verbose_name='Tenants' ) + tags = TagColumn( + url_name='tenancy:tenantgroup_list' + ) actions = ButtonsColumn(TenantGroup) class Meta(BaseTable.Meta): model = TenantGroup - fields = ('pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'actions') + fields = ('pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions') default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions') -# -# Tenants -# - class TenantTable(BaseTable): pk = ToggleColumn() name = tables.Column( @@ -80,3 +84,92 @@ class TenantTable(BaseTable): model = Tenant fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags') default_columns = ('pk', 'name', 'group', 'description') + + +# +# Contacts +# + +class ContactGroupTable(BaseTable): + pk = ToggleColumn() + name = MPTTColumn( + linkify=True + ) + contact_count = LinkedCountColumn( + viewname='tenancy:contact_list', + url_params={'role_id': 'pk'}, + verbose_name='Contacts' + ) + tags = TagColumn( + url_name='tenancy:contactgroup_list' + ) + actions = ButtonsColumn(ContactGroup) + + class Meta(BaseTable.Meta): + model = ContactGroup + fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'actions') + default_columns = ('pk', 'name', 'contact_count', 'description', 'actions') + + +class ContactRoleTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + actions = ButtonsColumn(ContactRole) + + class Meta(BaseTable.Meta): + model = ContactRole + fields = ('pk', 'name', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'description', 'actions') + + +class ContactTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + group = tables.Column( + linkify=True + ) + phone = tables.Column( + linkify=linkify_phone, + ) + comments = MarkdownColumn() + assignment_count = tables.Column( + verbose_name='Assignments' + ) + tags = TagColumn( + url_name='tenancy:tenant_list' + ) + + class Meta(BaseTable.Meta): + model = Contact + fields = ('pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags') + default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') + + +class ContactAssignmentTable(BaseTable): + pk = ToggleColumn() + content_type = ContentTypeColumn( + verbose_name='Object Type' + ) + object = tables.Column( + linkify=True, + orderable=False + ) + contact = tables.Column( + linkify=True + ) + role = tables.Column( + linkify=True + ) + actions = ButtonsColumn( + model=ContactAssignment, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = ContactAssignment + fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') + default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 5a3c2c1b0..a4469e0f2 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -1,6 +1,8 @@ from django.urls import reverse -from tenancy.models import Tenant, TenantGroup +from dcim.models import Site +from tenancy.choices import * +from tenancy.models import * from utilities.testing import APITestCase, APIViewTestCases @@ -92,3 +94,176 @@ class TenantTest(APIViewTestCases.APIViewTestCase): 'group': tenant_groups[1].pk, }, ] + + +class ContactGroupTest(APIViewTestCases.APIViewTestCase): + model = ContactGroup + brief_fields = ['_depth', 'contact_count', 'display', 'id', 'name', 'slug', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + parent_contact_groups = ( + ContactGroup.objects.create(name='Parent Contact Group 1', slug='parent-contact-group-1'), + ContactGroup.objects.create(name='Parent Contact Group 2', slug='parent-contact-group-2'), + ) + + ContactGroup.objects.create(name='Contact Group 1', slug='contact-group-1', parent=parent_contact_groups[0]) + ContactGroup.objects.create(name='Contact Group 2', slug='contact-group-2', parent=parent_contact_groups[0]) + ContactGroup.objects.create(name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[0]) + + cls.create_data = [ + { + 'name': 'Contact Group 4', + 'slug': 'contact-group-4', + 'parent': parent_contact_groups[1].pk, + }, + { + 'name': 'Contact Group 5', + 'slug': 'contact-group-5', + 'parent': parent_contact_groups[1].pk, + }, + { + 'name': 'Contact Group 6', + 'slug': 'contact-group-6', + 'parent': parent_contact_groups[1].pk, + }, + ] + + +class ContactRoleTest(APIViewTestCases.APIViewTestCase): + model = ContactRole + brief_fields = ['display', 'id', 'name', 'slug', 'url'] + create_data = [ + { + 'name': 'Contact Role 4', + 'slug': 'contact-role-4', + }, + { + 'name': 'Contact Role 5', + 'slug': 'contact-role-5', + }, + { + 'name': 'Contact Role 6', + 'slug': 'contact-role-6', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + contact_roles = ( + ContactRole(name='Contact Role 1', slug='contact-role-1'), + ContactRole(name='Contact Role 2', slug='contact-role-2'), + ContactRole(name='Contact Role 3', slug='contact-role-3'), + ) + ContactRole.objects.bulk_create(contact_roles) + + +class ContactTest(APIViewTestCases.APIViewTestCase): + model = Contact + brief_fields = ['display', 'id', 'name', 'url'] + bulk_update_data = { + 'group': None, + 'comments': 'New comments', + } + + @classmethod + def setUpTestData(cls): + + contact_groups = ( + ContactGroup.objects.create(name='Contact Group 1', slug='contact-group-1'), + ContactGroup.objects.create(name='Contact Group 2', slug='contact-group-2'), + ) + + contacts = ( + Contact(name='Contact 1', group=contact_groups[0]), + Contact(name='Contact 2', group=contact_groups[0]), + Contact(name='Contact 3', group=contact_groups[0]), + ) + Contact.objects.bulk_create(contacts) + + cls.create_data = [ + { + 'name': 'Contact 4', + 'group': contact_groups[1].pk, + }, + { + 'name': 'Contact 5', + 'group': contact_groups[1].pk, + }, + { + 'name': 'Contact 6', + }, + ] + + +class ContactAssignmentTest(APIViewTestCases.APIViewTestCase): + model = ContactAssignment + brief_fields = ['contact', 'display', 'id', 'priority', 'role', 'url'] + bulk_update_data = { + 'priority': ContactPriorityChoices.PRIORITY_INACTIVE, + } + + @classmethod + def setUpTestData(cls): + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + contacts = ( + Contact(name='Contact 1'), + Contact(name='Contact 2'), + Contact(name='Contact 3'), + Contact(name='Contact 4'), + Contact(name='Contact 5'), + Contact(name='Contact 6'), + ) + Contact.objects.bulk_create(contacts) + + contact_roles = ( + ContactRole(name='Contact Role 1', slug='contact-role-1'), + ContactRole(name='Contact Role 2', slug='contact-role-2'), + ContactRole(name='Contact Role 3', slug='contact-role-3'), + ) + ContactRole.objects.bulk_create(contact_roles) + + contact_assignments = ( + ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0], priority=ContactPriorityChoices.PRIORITY_PRIMARY), + ContactAssignment(object=sites[0], contact=contacts[1], role=contact_roles[1], priority=ContactPriorityChoices.PRIORITY_SECONDARY), + ContactAssignment(object=sites[0], contact=contacts[2], role=contact_roles[2], priority=ContactPriorityChoices.PRIORITY_TERTIARY), + ) + ContactAssignment.objects.bulk_create(contact_assignments) + + cls.create_data = [ + { + 'content_type': 'dcim.site', + 'object_id': sites[1].pk, + 'contact': contacts[3].pk, + 'role': contact_roles[0].pk, + 'priority': ContactPriorityChoices.PRIORITY_PRIMARY, + }, + { + 'content_type': 'dcim.site', + 'object_id': sites[1].pk, + 'contact': contacts[4].pk, + 'role': contact_roles[1].pk, + 'priority': ContactPriorityChoices.PRIORITY_SECONDARY, + }, + { + 'content_type': 'dcim.site', + 'object_id': sites[1].pk, + 'contact': contacts[5].pk, + 'role': contact_roles[2].pk, + 'priority': ContactPriorityChoices.PRIORITY_TERTIARY, + }, + ] diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py index fd4a0bd76..86170734c 100644 --- a/netbox/tenancy/tests/test_filtersets.py +++ b/netbox/tenancy/tests/test_filtersets.py @@ -1,7 +1,7 @@ from django.test import TestCase from tenancy.filtersets import * -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * from utilities.testing import ChangeLoggedFilterSetTests @@ -84,3 +84,103 @@ class TenantTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'group': [group[0].slug, group[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ContactGroup.objects.all() + filterset = ContactGroupFilterSet + + @classmethod + def setUpTestData(cls): + + parent_contact_groups = ( + ContactGroup(name='Parent Contact Group 1', slug='parent-contact-group-1'), + ContactGroup(name='Parent Contact Group 2', slug='parent-contact-group-2'), + ContactGroup(name='Parent Contact Group 3', slug='parent-contact-group-3'), + ) + for contactgroup in parent_contact_groups: + contactgroup.save() + + contact_groups = ( + ContactGroup(name='Contact Group 1', slug='contact-group-1', parent=parent_contact_groups[0], description='A'), + ContactGroup(name='Contact Group 2', slug='contact-group-2', parent=parent_contact_groups[1], description='B'), + ContactGroup(name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[2], description='C'), + ) + for contactgroup in contact_groups: + contactgroup.save() + + def test_name(self): + params = {'name': ['Contact Group 1', 'Contact Group 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['contact-group-1', 'contact-group-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_parent(self): + parent_groups = ContactGroup.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ContactRole.objects.all() + filterset = ContactRoleFilterSet + + @classmethod + def setUpTestData(cls): + + contact_roles = ( + ContactRole(name='Contact Role 1', slug='contact-role-1'), + ContactRole(name='Contact Role 2', slug='contact-role-2'), + ContactRole(name='Contact Role 3', slug='contact-role-3'), + ) + ContactRole.objects.bulk_create(contact_roles) + + def test_name(self): + params = {'name': ['Contact Role 1', 'Contact Role 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['contact-role-1', 'contact-role-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class ContactTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = Contact.objects.all() + filterset = ContactFilterSet + + @classmethod + def setUpTestData(cls): + + contact_groups = ( + ContactGroup(name='Contact Group 1', slug='contact-group-1'), + ContactGroup(name='Contact Group 2', slug='contact-group-2'), + ContactGroup(name='Contact Group 3', slug='contact-group-3'), + ) + for contactgroup in contact_groups: + contactgroup.save() + + contacts = ( + Contact(name='Contact 1', group=contact_groups[0]), + Contact(name='Contact 2', group=contact_groups[1]), + Contact(name='Contact 3', group=contact_groups[2]), + ) + Contact.objects.bulk_create(contacts) + + def test_name(self): + params = {'name': ['Contact 1', 'Contact 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_group(self): + group = ContactGroup.objects.all()[:2] + params = {'group_id': [group[0].pk, group[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [group[0].slug, group[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index f45afc302..881802a7b 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -1,4 +1,4 @@ -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * from utilities.testing import ViewTestCases, create_tags @@ -16,10 +16,13 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for tenanantgroup in tenant_groups: tenanantgroup.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Tenant Group X', 'slug': 'tenant-group-x', 'description': 'A new tenant group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -74,3 +77,111 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.bulk_edit_data = { 'group': tenant_groups[1].pk, } + + +class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = ContactGroup + + @classmethod + def setUpTestData(cls): + + contact_groups = ( + ContactGroup(name='Contact Group 1', slug='contact-group-1'), + ContactGroup(name='Contact Group 2', slug='contact-group-2'), + ContactGroup(name='Contact Group 3', slug='contact-group-3'), + ) + for tenanantgroup in contact_groups: + tenanantgroup.save() + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Contact Group X', + 'slug': 'contact-group-x', + 'description': 'A new contact group', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,slug,description", + "Contact Group 4,contact-group-4,Fourth contact group", + "Contact Group 5,contact-group-5,Fifth contact group", + "Contact Group 6,contact-group-6,Sixth contact group", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class ContactRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = ContactRole + + @classmethod + def setUpTestData(cls): + + ContactRole.objects.bulk_create([ + ContactRole(name='Contact Role 1', slug='contact-role-1'), + ContactRole(name='Contact Role 2', slug='contact-role-2'), + ContactRole(name='Contact Role 3', slug='contact-role-3'), + ]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Devie Role X', + 'slug': 'contact-role-x', + 'description': 'New contact role', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,slug", + "Contact Role 4,contact-role-4", + "Contact Role 5,contact-role-5", + "Contact Role 6,contact-role-6", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Contact + + @classmethod + def setUpTestData(cls): + + contact_groups = ( + ContactGroup(name='Contact Group 1', slug='contact-group-1'), + ContactGroup(name='Contact Group 2', slug='contact-group-2'), + ) + for contactgroup in contact_groups: + contactgroup.save() + + Contact.objects.bulk_create([ + Contact(name='Contact 1', group=contact_groups[0]), + Contact(name='Contact 2', group=contact_groups[0]), + Contact(name='Contact 3', group=contact_groups[0]), + ]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Contact X', + 'group': contact_groups[1].pk, + 'comments': 'Some comments', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "group,name", + "Contact Group 1,Contact 4", + "Contact Group 1,Contact 5", + "Contact Group 1,Contact 6", + ) + + cls.bulk_edit_data = { + 'group': contact_groups[1].pk, + } diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index a1f46c7ec..b20e1c3d1 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -3,7 +3,7 @@ from django.urls import path from extras.views import ObjectChangeLogView, ObjectJournalView from utilities.views import SlugRedirectView from . import views -from .models import Tenant, TenantGroup +from .models import * app_name = 'tenancy' urlpatterns = [ @@ -32,4 +32,43 @@ urlpatterns = [ path('tenants//changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}), path('tenants//journal/', ObjectJournalView.as_view(), name='tenant_journal', kwargs={'model': Tenant}), + # Contact groups + path('contact-groups/', views.ContactGroupListView.as_view(), name='contactgroup_list'), + path('contact-groups/add/', views.ContactGroupEditView.as_view(), name='contactgroup_add'), + path('contact-groups/import/', views.ContactGroupBulkImportView.as_view(), name='contactgroup_import'), + path('contact-groups/edit/', views.ContactGroupBulkEditView.as_view(), name='contactgroup_bulk_edit'), + path('contact-groups/delete/', views.ContactGroupBulkDeleteView.as_view(), name='contactgroup_bulk_delete'), + path('contact-groups//', views.ContactGroupView.as_view(), name='contactgroup'), + path('contact-groups//edit/', views.ContactGroupEditView.as_view(), name='contactgroup_edit'), + path('contact-groups//delete/', views.ContactGroupDeleteView.as_view(), name='contactgroup_delete'), + path('contact-groups//changelog/', ObjectChangeLogView.as_view(), name='contactgroup_changelog', kwargs={'model': ContactGroup}), + + # Contact roles + path('contact-roles/', views.ContactRoleListView.as_view(), name='contactrole_list'), + path('contact-roles/add/', views.ContactRoleEditView.as_view(), name='contactrole_add'), + path('contact-roles/import/', views.ContactRoleBulkImportView.as_view(), name='contactrole_import'), + path('contact-roles/edit/', views.ContactRoleBulkEditView.as_view(), name='contactrole_bulk_edit'), + path('contact-roles/delete/', views.ContactRoleBulkDeleteView.as_view(), name='contactrole_bulk_delete'), + path('contact-roles//', views.ContactRoleView.as_view(), name='contactrole'), + path('contact-roles//edit/', views.ContactRoleEditView.as_view(), name='contactrole_edit'), + path('contact-roles//delete/', views.ContactRoleDeleteView.as_view(), name='contactrole_delete'), + path('contact-roles//changelog/', ObjectChangeLogView.as_view(), name='contactrole_changelog', kwargs={'model': ContactRole}), + + # Contacts + path('contacts/', views.ContactListView.as_view(), name='contact_list'), + path('contacts/add/', views.ContactEditView.as_view(), name='contact_add'), + path('contacts/import/', views.ContactBulkImportView.as_view(), name='contact_import'), + path('contacts/edit/', views.ContactBulkEditView.as_view(), name='contact_bulk_edit'), + path('contacts/delete/', views.ContactBulkDeleteView.as_view(), name='contact_bulk_delete'), + path('contacts//', views.ContactView.as_view(), name='contact'), + path('contacts//edit/', views.ContactEditView.as_view(), name='contact_edit'), + path('contacts//delete/', views.ContactDeleteView.as_view(), name='contact_delete'), + path('contacts//changelog/', ObjectChangeLogView.as_view(), name='contact_changelog', kwargs={'model': Contact}), + path('contacts//journal/', ObjectJournalView.as_view(), name='contact_journal', kwargs={'model': Contact}), + + # Contact assignments + path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'), + path('contact-assignments//edit/', views.ContactAssignmentEditView.as_view(), name='contactassignment_edit'), + path('contact-assignments//delete/', views.ContactAssignmentDeleteView.as_view(), name='contactassignment_delete'), + ] diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 0b28a62d2..c848de47f 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,11 +1,16 @@ +from django.contrib.contenttypes.models import ContentType +from django.http import Http404 +from django.shortcuts import get_object_or_404 + from circuits.models import Circuit from dcim.models import Site, Rack, Device, RackReservation from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from netbox.views import generic from utilities.tables import paginate_table +from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster from . import filtersets, forms, tables -from .models import Tenant, TenantGroup +from .models import * # @@ -140,3 +145,221 @@ class TenantBulkDeleteView(generic.BulkDeleteView): queryset = Tenant.objects.prefetch_related('group') filterset = filtersets.TenantFilterSet table = tables.TenantTable + + +# +# Contact groups +# + +class ContactGroupListView(generic.ObjectListView): + queryset = ContactGroup.objects.add_related_count( + ContactGroup.objects.all(), + Contact, + 'group', + 'contact_count', + cumulative=True + ) + filterset = filtersets.ContactGroupFilterSet + filterset_form = forms.ContactGroupFilterForm + table = tables.ContactGroupTable + + +class ContactGroupView(generic.ObjectView): + queryset = ContactGroup.objects.all() + + def get_extra_context(self, request, instance): + child_groups = ContactGroup.objects.add_related_count( + ContactGroup.objects.all(), + Contact, + 'group', + 'contact_count', + cumulative=True + ).restrict(request.user, 'view').filter( + parent__in=instance.get_descendants(include_self=True) + ) + child_groups_table = tables.ContactGroupTable(child_groups) + child_groups_table.columns.hide('actions') + + contacts = Contact.objects.restrict(request.user, 'view').filter( + group=instance + ) + contacts_table = tables.ContactTable(contacts, exclude=('group',)) + paginate_table(contacts_table, request) + + return { + 'child_groups_table': child_groups_table, + 'contacts_table': contacts_table, + } + + +class ContactGroupEditView(generic.ObjectEditView): + queryset = ContactGroup.objects.all() + model_form = forms.ContactGroupForm + + +class ContactGroupDeleteView(generic.ObjectDeleteView): + queryset = ContactGroup.objects.all() + + +class ContactGroupBulkImportView(generic.BulkImportView): + queryset = ContactGroup.objects.all() + model_form = forms.ContactGroupCSVForm + table = tables.ContactGroupTable + + +class ContactGroupBulkEditView(generic.BulkEditView): + queryset = ContactGroup.objects.add_related_count( + ContactGroup.objects.all(), + Contact, + 'group', + 'contact_count', + cumulative=True + ) + filterset = filtersets.ContactGroupFilterSet + table = tables.ContactGroupTable + form = forms.ContactGroupBulkEditForm + + +class ContactGroupBulkDeleteView(generic.BulkDeleteView): + queryset = ContactGroup.objects.add_related_count( + ContactGroup.objects.all(), + Contact, + 'group', + 'contact_count', + cumulative=True + ) + table = tables.ContactGroupTable + + +# +# Contact roles +# + +class ContactRoleListView(generic.ObjectListView): + queryset = ContactRole.objects.all() + filterset = filtersets.ContactRoleFilterSet + filterset_form = forms.ContactRoleFilterForm + table = tables.ContactRoleTable + + +class ContactRoleView(generic.ObjectView): + queryset = ContactRole.objects.all() + + def get_extra_context(self, request, instance): + contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( + role=instance + ) + contacts_table = tables.ContactAssignmentTable(contact_assignments) + contacts_table.columns.hide('role') + paginate_table(contacts_table, request) + + return { + 'contacts_table': contacts_table, + 'assignment_count': ContactAssignment.objects.filter(role=instance).count(), + } + + +class ContactRoleEditView(generic.ObjectEditView): + queryset = ContactRole.objects.all() + model_form = forms.ContactRoleForm + + +class ContactRoleDeleteView(generic.ObjectDeleteView): + queryset = ContactRole.objects.all() + + +class ContactRoleBulkImportView(generic.BulkImportView): + queryset = ContactRole.objects.all() + model_form = forms.ContactRoleCSVForm + table = tables.ContactRoleTable + + +class ContactRoleBulkEditView(generic.BulkEditView): + queryset = ContactRole.objects.all() + filterset = filtersets.ContactRoleFilterSet + table = tables.ContactRoleTable + form = forms.ContactRoleBulkEditForm + + +class ContactRoleBulkDeleteView(generic.BulkDeleteView): + queryset = ContactRole.objects.all() + table = tables.ContactRoleTable + + +# +# Contacts +# + +class ContactListView(generic.ObjectListView): + queryset = Contact.objects.annotate( + assignment_count=count_related(ContactAssignment, 'contact') + ) + filterset = filtersets.ContactFilterSet + filterset_form = forms.ContactFilterForm + table = tables.ContactTable + + +class ContactView(generic.ObjectView): + queryset = Contact.objects.all() + + def get_extra_context(self, request, instance): + contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( + contact=instance + ) + assignments_table = tables.ContactAssignmentTable(contact_assignments) + assignments_table.columns.hide('contact') + paginate_table(assignments_table, request) + + return { + 'assignments_table': assignments_table, + 'assignment_count': ContactAssignment.objects.filter(contact=instance).count(), + } + + +class ContactEditView(generic.ObjectEditView): + queryset = Contact.objects.all() + model_form = forms.ContactForm + + +class ContactDeleteView(generic.ObjectDeleteView): + queryset = Contact.objects.all() + + +class ContactBulkImportView(generic.BulkImportView): + queryset = Contact.objects.all() + model_form = forms.ContactCSVForm + table = tables.ContactTable + + +class ContactBulkEditView(generic.BulkEditView): + queryset = Contact.objects.prefetch_related('group') + filterset = filtersets.ContactFilterSet + table = tables.ContactTable + form = forms.ContactBulkEditForm + + +class ContactBulkDeleteView(generic.BulkDeleteView): + queryset = Contact.objects.prefetch_related('group') + filterset = filtersets.ContactFilterSet + table = tables.ContactTable + + +# +# Contact assignments +# + +class ContactAssignmentEditView(generic.ObjectEditView): + queryset = ContactAssignment.objects.all() + model_form = forms.ContactAssignmentForm + template_name = 'tenancy/contactassignment_edit.html' + + def alter_obj(self, instance, request, args, kwargs): + if not instance.pk: + # Assign the object based on URL kwargs + content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type')) + instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id')) + return instance + + +class ContactAssignmentDeleteView(generic.ObjectDeleteView): + queryset = ContactAssignment.objects.all() diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 80643c1e5..ad296ea25 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -99,8 +99,20 @@ class TokenFilterSet(BaseFilterSet): model = Token fields = ['id', 'key', 'write_enabled'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(user__username__icontains=value) | + Q(description__icontains=value) + ) + class ObjectPermissionFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) user_id = django_filters.ModelMultipleChoiceFilter( field_name='users', queryset=User.objects.all(), @@ -127,3 +139,11 @@ class ObjectPermissionFilterSet(BaseFilterSet): class Meta: model = ObjectPermission fields = ['id', 'name', 'enabled', 'object_types'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) diff --git a/netbox/users/graphql/types.py b/netbox/users/graphql/types.py index 3315744b9..d948686c6 100644 --- a/netbox/users/graphql/types.py +++ b/netbox/users/graphql/types.py @@ -19,7 +19,7 @@ class GroupType(DjangoObjectType): @classmethod def get_queryset(cls, queryset, info): - return RestrictedQuerySet(model=Group) + return RestrictedQuerySet(model=Group).restrict(info.context.user, 'view') class UserType(DjangoObjectType): @@ -34,4 +34,4 @@ class UserType(DjangoObjectType): @classmethod def get_queryset(cls, queryset, info): - return RestrictedQuerySet(model=User) + return RestrictedQuerySet(model=User).restrict(info.context.user, 'view') diff --git a/netbox/users/views.py b/netbox/users/views.py index afee10eeb..5acb593b4 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -13,7 +13,9 @@ from django.utils.decorators import method_decorator from django.utils.http import is_safe_url from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import View +from social_core.backends.utils import load_backends +from netbox.config import get_config from utilities.forms import ConfirmationForm from .forms import LoginForm, PasswordChangeForm, TokenForm from .models import Token @@ -42,6 +44,7 @@ class LoginView(View): return render(request, self.template_name, { 'form': form, + 'auth_backends': load_backends(settings.AUTHENTICATION_BACKENDS), }) def post(self, request): @@ -53,7 +56,7 @@ class LoginView(View): # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's # last_login time upon authentication. - if settings.MAINTENANCE_MODE: + if get_config().MAINTENANCE_MODE: logger.warning("Maintenance mode enabled: disabling update of most recent login time") user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login') @@ -69,13 +72,14 @@ class LoginView(View): return render(request, self.template_name, { 'form': form, + 'auth_backends': load_backends(settings.AUTHENTICATION_BACKENDS), }) def redirect_to_next(self, request, logger): if request.method == "POST": - redirect_to = request.POST.get('next', reverse('home')) + redirect_to = request.POST.get('next', settings.LOGIN_REDIRECT_URL) else: - redirect_to = request.GET.get('next', reverse('home')) + redirect_to = request.GET.get('next', settings.LOGIN_REDIRECT_URL) if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}") diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index ed71afc1b..fe4bae3b4 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -3,7 +3,7 @@ from django import forms from django.conf import settings from django_filters.constants import EMPTY_VALUES -from dcim.forms import MACAddressField +from utilities.forms import MACAddressField def multivalue_field_factory(field_class): @@ -57,6 +57,10 @@ class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(MACAddressField) +class MultiValueWWNFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(MACAddressField) + + class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): """ Filters for a set of Models, including all descendant models within a Tree. Example: [,] diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 2561c2e22..007215b6e 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -2,6 +2,7 @@ import csv import json import re from io import StringIO +from netaddr import AddrFormatError, EUI import django_filters from django import forms @@ -14,7 +15,7 @@ from django.forms.fields import JSONField as _JSONField, InvalidJSONInput from django.urls import reverse from utilities.choices import unpack_grouped_choices -from utilities.utils import content_type_name +from utilities.utils import content_type_identifier, content_type_name from utilities.validators import EnhancedURLValidator from . import widgets from .constants import * @@ -38,6 +39,7 @@ __all__ = ( 'ExpandableNameField', 'JSONField', 'LaxURLField', + 'MACAddressField', 'SlugField', 'TagFilterField', ) @@ -129,6 +131,28 @@ class JSONField(_JSONField): return json.dumps(value, sort_keys=True, indent=4) +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 + + +# +# Content type fields +# + class ContentTypeChoiceMixin: def __init__(self, queryset, *args, **kwargs): @@ -224,7 +248,7 @@ class CSVFileField(forms.FileField): return None csv_str = file.read().decode('utf-8').strip() - reader = csv.reader(csv_str.splitlines()) + reader = csv.reader(StringIO(csv_str)) headers, records = parse_csv(reader) return headers, records @@ -278,7 +302,7 @@ class CSVContentTypeField(CSVModelChoiceField): STATIC_CHOICES = True def prepare_value(self, value): - return f'{value.app_label}.{value.model}' + return content_type_identifier(value) def to_python(self, value): if not value: @@ -304,7 +328,7 @@ class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField): app_label, model = name.split('.') ct_filter |= Q(app_label=app_label, model=model) return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True)) - return super().prepare_value(value) + return content_type_identifier(value) # diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 8bc113218..87fa4ae33 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -3,6 +3,7 @@ import re import yaml from django import forms +from django.utils.translation import gettext as _ from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSelect @@ -13,13 +14,14 @@ __all__ = ( 'BulkRenameForm', 'ConfirmationForm', 'CSVModelForm', + 'FilterForm', 'ImportForm', 'ReturnURLForm', 'TableConfigForm', ) -class BootstrapMixin(forms.BaseForm): +class BootstrapMixin: """ Add the base Bootstrap CSS classes to form elements. """ @@ -73,7 +75,7 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm): confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True) -class BulkEditForm(forms.Form): +class BulkEditForm(BootstrapMixin, forms.Form): """ Base form for editing multiple objects in bulk """ @@ -177,6 +179,19 @@ class ImportForm(BootstrapMixin, forms.Form): }) +class FilterForm(BootstrapMixin, forms.Form): + """ + Base Form class for FilterSet forms. + """ + q = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={'placeholder': _('All fields')} + ), + label=_('Search') + ) + + class TableConfigForm(BootstrapMixin, forms.Form): """ Form for configuring user's table preferences. diff --git a/netbox/utilities/markdown.py b/netbox/utilities/markdown.py new file mode 100644 index 000000000..00b6330f1 --- /dev/null +++ b/netbox/utilities/markdown.py @@ -0,0 +1,16 @@ +import markdown +from markdown.inlinepatterns import SimpleTagPattern + +STRIKE_RE = r'(~{2})(.+?)(~{2})' + + +class StrikethroughExtension(markdown.Extension): + """ + A python-markdown extension which support strikethrough formatting (e.g. "~~text~~"). + """ + def extendMarkdown(self, md): + md.inlinePatterns.register( + markdown.inlinepatterns.SimpleTagPattern(STRIKE_RE, 'del'), + 'strikethrough', + 200 + ) diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py index 85ceab73d..1f07aa42a 100644 --- a/netbox/utilities/paginator.py +++ b/netbox/utilities/paginator.py @@ -1,8 +1,12 @@ -from django.conf import settings from django.core.paginator import Paginator, Page +from netbox.config import get_config + class EnhancedPaginator(Paginator): + default_page_lengths = ( + 25, 50, 100, 250, 500, 1000 + ) def __init__(self, object_list, per_page, orphans=None, **kwargs): @@ -10,9 +14,9 @@ class EnhancedPaginator(Paginator): try: per_page = int(per_page) if per_page < 1: - per_page = settings.PAGINATE_COUNT + per_page = get_config().PAGINATE_COUNT except ValueError: - per_page = settings.PAGINATE_COUNT + per_page = get_config().PAGINATE_COUNT # Set orphans count based on page size if orphans is None and per_page <= 50: @@ -25,6 +29,11 @@ class EnhancedPaginator(Paginator): def _get_page(self, *args, **kwargs): return EnhancedPage(*args, **kwargs) + def get_page_lengths(self): + if self.per_page not in self.default_page_lengths: + return sorted([*self.default_page_lengths, self.per_page]) + return self.default_page_lengths + class EnhancedPage(Page): @@ -57,9 +66,11 @@ def get_paginate_count(request): Return the lesser of the calculated value and MAX_PAGE_SIZE. """ + config = get_config() + def _max_allowed(page_size): - if settings.MAX_PAGE_SIZE: - return min(page_size, settings.MAX_PAGE_SIZE) + if config.MAX_PAGE_SIZE: + return min(page_size, config.MAX_PAGE_SIZE) return page_size if 'per_page' in request.GET: @@ -72,7 +83,7 @@ def get_paginate_count(request): pass if request.user.is_authenticated: - per_page = request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT) + per_page = request.user.config.get('pagination.per_page', config.PAGINATE_COUNT) return _max_allowed(per_page) - return _max_allowed(settings.PAGINATE_COUNT) + return _max_allowed(config.PAGINATE_COUNT) diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index b92cde47c..7b348b5ac 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -13,7 +13,7 @@ from django_tables2.utils import Accessor from extras.choices import CustomFieldTypeChoices from extras.models import CustomField -from .utils import content_type_name +from .utils import content_type_identifier, content_type_name from .paginator import EnhancedPaginator, get_paginate_count @@ -289,16 +289,27 @@ class ContentTypeColumn(tables.Column): def value(self, value): if value is None: return None - return f"{value.app_label}.{value.model}" + return content_type_identifier(value) class ContentTypesColumn(tables.ManyToManyColumn): """ Display a list of ContentType instances. """ + def __init__(self, separator=None, *args, **kwargs): + # Use a line break as the default separator + if separator is None: + separator = mark_safe('
') + super().__init__(separator=separator, *args, **kwargs) + def transform(self, obj): return content_type_name(obj) + def value(self, value): + return ','.join([ + content_type_identifier(ct) for ct in self.filter(value) + ]) + class ColorColumn(tables.Column): """ @@ -478,3 +489,19 @@ def paginate_table(table, request): 'per_page': get_paginate_count(request) } RequestConfig(request, paginate).configure(table) + + +# +# Callables +# + +def linkify_email(value): + if value is None: + return None + return f"mailto:{value}" + + +def linkify_phone(value): + if value is None: + return None + return f"tel:{value}" diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 1695c8257..2d0c8edd1 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,4 +1,5 @@ import datetime +import decimal import json import re from typing import Dict, Any @@ -6,6 +7,7 @@ from typing import Dict, Any import yaml from django import template from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.template.defaultfilters import date from django.urls import NoReverseMatch, reverse from django.utils import timezone @@ -13,7 +15,9 @@ from django.utils.html import strip_tags from django.utils.safestring import mark_safe from markdown import markdown +from netbox.config import get_config from utilities.forms import get_selected_values, TableConfigForm +from utilities.markdown import StrikethroughExtension from utilities.utils import foreground_color register = template.Library() @@ -39,16 +43,21 @@ def render_markdown(value): """ Render text as Markdown """ + schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES) + # Strip HTML tags value = strip_tags(value) # Sanitize Markdown links - schemes = '|'.join(settings.ALLOWED_URL_SCHEMES) - pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)' + pattern = fr'\[([^\]]+)\]\((?!({schemes})).*:(.+)\)' value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) + # Sanitize Markdown reference links + pattern = fr'\[(.+)\]:\s*(?!({schemes}))\w*:(.+)' + value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE) + # Render Markdown - html = markdown(value, extensions=['fenced_code', 'tables']) + html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()]) return mark_safe(html) @@ -78,6 +87,25 @@ def meta(obj, attr): return getattr(obj._meta, attr, '') +@register.filter() +def content_type(obj): + """ + Return the ContentType for the given object. + """ + return ContentType.objects.get_for_model(obj) + + +@register.filter() +def content_type_id(obj): + """ + Return the ContentType ID for the given object. + """ + content_type = ContentType.objects.get_for_model(obj) + if content_type: + return content_type.pk + return None + + @register.filter() def viewname(model, action): """ @@ -146,6 +174,19 @@ def humanize_megabytes(mb): return f'{mb} MB' +@register.filter() +def simplify_decimal(value): + """ + Return the simplest expression of a decimal value. Examples: + 1.00 => '1' + 1.20 => '1.2' + 1.23 => '1.23' + """ + if type(value) is not decimal.Decimal: + return value + return str(value).rstrip('0').rstrip('.') + + @register.filter() def tzoffset(value): """ diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py index dd7ca4236..499a5e2e7 100644 --- a/netbox/utilities/testing/base.py +++ b/netbox/utilities/testing/base.py @@ -10,6 +10,7 @@ from taggit.managers import TaggableManager from users.models import ObjectPermission from utilities.permissions import resolve_permission_ct +from utilities.utils import content_type_identifier from .utils import extract_form_failures __all__ = ( @@ -110,7 +111,7 @@ class ModelTestCase(TestCase): if value and type(field) in (ManyToManyField, TaggableManager): if field.related_model is ContentType and api: - model_dict[key] = sorted([f'{ct.app_label}.{ct.model}' for ct in value]) + model_dict[key] = sorted([content_type_identifier(ct) for ct in value]) else: model_dict[key] = sorted([obj.pk for obj in value]) @@ -119,7 +120,7 @@ class ModelTestCase(TestCase): # Replace ContentType numeric IDs with . if type(getattr(instance, key)) is ContentType: ct = ContentType.objects.get(pk=value) - model_dict[key] = f'{ct.app_label}.{ct.model}' + model_dict[key] = content_type_identifier(ct) # Convert IPNetwork instances to strings elif type(value) is IPNetwork: diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index bec3bc762..466b5e22b 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -7,6 +7,7 @@ from django.utils.text import slugify from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from extras.models import Tag +from virtualization.models import Cluster, ClusterType, VirtualMachine def post_data(data): @@ -46,6 +47,17 @@ def create_test_device(name): return device +def create_test_virtualmachine(name): + """ + Convenience method for creating a VirtualMachine. + """ + cluster_type, _ = ClusterType.objects.get_or_create(name='Cluster Type 1', slug='cluster-type-1') + cluster, _ = Cluster.objects.get_or_create(name='Cluster 1', type=cluster_type) + virtual_machine = VirtualMachine.objects.create(name=name, cluster=cluster) + + return virtual_machine + + def create_test_user(username='testuser', permissions=None): """ Create a User with the given permissions. diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py index 5b711056a..1171bd496 100644 --- a/netbox/utilities/tests/test_api.py +++ b/netbox/utilities/tests/test_api.py @@ -1,6 +1,5 @@ import urllib.parse -from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.test import Client, TestCase, override_settings from django.urls import reverse @@ -10,6 +9,7 @@ from dcim.models import Region, Site from extras.choices import CustomFieldTypeChoices from extras.models import CustomField from ipam.models import VLAN +from netbox.config import get_config from utilities.testing import APITestCase, disable_warnings @@ -137,7 +137,7 @@ class APIPaginationTestCase(APITestCase): def test_default_page_size(self): response = self.client.get(self.url, format='json', **self.header) - page_size = settings.PAGINATE_COUNT + page_size = get_config().PAGINATE_COUNT self.assertLess(page_size, 100, "Default page size not sufficient for data set") self.assertHttpStatus(response, status.HTTP_200_OK) diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 374167f1c..2616dbf36 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -5,6 +5,9 @@ from django.test import TestCase from mptt.fields import TreeForeignKey from taggit.managers import TaggableManager +from circuits.choices import CircuitStatusChoices +from circuits.filtersets import CircuitFilterSet +from circuits.models import Circuit, Provider, CircuitType from dcim.choices import * from dcim.fields import MACAddressField from dcim.filtersets import DeviceFilterSet, SiteFilterSet @@ -13,6 +16,7 @@ from dcim.models import ( ) from extras.filters import TagFilter from extras.models import TaggedItem +from ipam.models import RIR, ASN from netbox.filtersets import BaseFilterSet from utilities.filters import ( MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, MultiValueNumberFilter, @@ -337,6 +341,8 @@ class DynamicFilterLookupExpressionTest(TestCase): device_filterset = DeviceFilterSet site_queryset = Site.objects.all() site_filterset = SiteFilterSet + circuit_queryset = Circuit.objects.all() + circuit_filterset = CircuitFilterSet @classmethod def setUpTestData(cls): @@ -384,6 +390,19 @@ class DynamicFilterLookupExpressionTest(TestCase): ) Site.objects.bulk_create(sites) + rir = RIR.objects.create(name='RFC 6996', is_private=True) + + asns = [ + ASN(asn=65001, rir=rir), + ASN(asn=65101, rir=rir), + ASN(asn=65201, rir=rir) + ] + ASN.objects.bulk_create(asns) + + asns[0].sites.add(sites[0]) + asns[1].sites.add(sites[1]) + asns[2].sites.add(sites[2]) + racks = ( Rack(name='Rack 1', site=sites[0]), Rack(name='Rack 2', site=sites[1]), diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 9ddb072f3..203c12b3f 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -327,13 +327,6 @@ def decode_dict(encoded_dict: Dict, *, decode_keys: bool = True) -> Dict: return {urllib.parse.unquote(k): decode_value(v, decode_keys) for k, v in encoded_dict.items()} -# Taken from django.utils.functional (<3.0) -def curry(_curried_func, *args, **kwargs): - def _curried(*moreargs, **morekwargs): - return _curried_func(*args, *moreargs, **{**kwargs, **morekwargs}) - return _curried - - def array_to_string(array): """ Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField. @@ -344,16 +337,23 @@ def array_to_string(array): return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group) -def content_type_name(contenttype): +def content_type_name(ct): """ - Return a proper ContentType name. + Return a human-friendly ContentType name (e.g. "DCIM > Site"). """ try: - meta = contenttype.model_class()._meta + meta = ct.model_class()._meta return f'{meta.app_config.verbose_name} > {meta.verbose_name}' except AttributeError: # Model no longer exists - return f'{contenttype.app_label} > {contenttype.model}' + return f'{ct.app_label} > {ct.model}' + + +def content_type_identifier(ct): + """ + Return a "raw" ContentType identifier string suitable for bulk import/export (e.g. "dcim.site"). + """ + return f'{ct.app_label}.{ct.model}' # diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index b087b0867..5fce17a3a 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -1,9 +1,10 @@ import re -from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator +from netbox.config import get_config + class EnhancedURLValidator(URLValidator): """ @@ -19,7 +20,11 @@ class EnhancedURLValidator(URLValidator): r'(?::\d{2,5})?' # Port number r'(?:[/?#][^\s]*)?' # Path r'\Z', re.IGNORECASE) - schemes = settings.ALLOWED_URL_SCHEMES + + def __init__(self, schemes=None, **kwargs): + super().__init__(**kwargs) + if schemes is not None: + self.schemes = get_config().ALLOWED_URL_SCHEMES class ExclusionValidator(BaseValidator): diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index adad9bf4d..866b8f9bb 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -6,7 +6,7 @@ from dcim.choices import InterfaceModeChoices from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN from netbox.api import ChoiceField, SerializedPKRelatedField -from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer +from netbox.api.serializers import PrimaryModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -17,26 +17,26 @@ from .nested_serializers import * # Clusters # -class ClusterTypeSerializer(OrganizationalModelSerializer): +class ClusterTypeSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterType fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'cluster_count', ] -class ClusterGroupSerializer(OrganizationalModelSerializer): +class ClusterGroupSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'cluster_count', ] @@ -44,9 +44,9 @@ class ClusterGroupSerializer(OrganizationalModelSerializer): class ClusterSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') type = NestedClusterTypeSerializer() - group = NestedClusterGroupSerializer(required=False, allow_null=True) + group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True) - site = NestedSiteSerializer(required=False, allow_null=True) + site = NestedSiteSerializer(required=False, allow_null=True, default=None) device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) @@ -107,6 +107,7 @@ class VMInterfaceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail') virtual_machine = NestedVirtualMachineSerializer() parent = NestedVMInterfaceSerializer(required=False, allow_null=True) + bridge = NestedVMInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( @@ -116,13 +117,14 @@ class VMInterfaceSerializer(PrimaryModelSerializer): many=True ) count_ipaddresses = serializers.IntegerField(read_only=True) + count_fhrp_groups = serializers.IntegerField(read_only=True) class Meta: model = VMInterface fields = [ - 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'mtu', 'mac_address', 'description', - 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', - 'count_ipaddresses', + 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', + 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', + 'count_ipaddresses', 'count_fhrp_groups', ] def validate(self, data): diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 8eebd2120..894045c1a 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -23,7 +23,7 @@ class VirtualizationRootView(APIRootView): class ClusterTypeViewSet(CustomFieldModelViewSet): queryset = ClusterType.objects.annotate( cluster_count=count_related(Cluster, 'type') - ) + ).prefetch_related('tags') serializer_class = serializers.ClusterTypeSerializer filterset_class = filtersets.ClusterTypeFilterSet @@ -31,7 +31,7 @@ class ClusterTypeViewSet(CustomFieldModelViewSet): class ClusterGroupViewSet(CustomFieldModelViewSet): queryset = ClusterGroup.objects.annotate( cluster_count=count_related(Cluster, 'group') - ) + ).prefetch_related('tags') serializer_class = serializers.ClusterGroupSerializer filterset_class = filtersets.ClusterGroupFilterSet @@ -80,7 +80,7 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet) class VMInterfaceViewSet(ModelViewSet): queryset = VMInterface.objects.prefetch_related( - 'virtual_machine', 'parent', 'tags', 'tagged_vlans', 'ip_addresses' + 'virtual_machine', 'parent', 'tags', 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', ) serializer_class = serializers.VMInterfaceSerializer filterset_class = filtersets.VMInterfaceFilterSet diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 3fc1da8ea..ed2775de2 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -20,6 +20,7 @@ __all__ = ( class ClusterTypeFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = ClusterType @@ -27,6 +28,7 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet): class ClusterGroupFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = ClusterGroup @@ -144,6 +146,12 @@ class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConf queryset=Cluster.objects.all(), label='Cluster (ID)', ) + cluster = django_filters.ModelMultipleChoiceFilter( + field_name='cluster__name', + queryset=Cluster.objects.all(), + to_field_name='name', + label='Cluster', + ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='cluster__site__region', @@ -262,6 +270,11 @@ class VMInterfaceFilterSet(PrimaryModelFilterSet): queryset=VMInterface.objects.all(), label='Parent interface (ID)', ) + bridge_id = django_filters.ModelMultipleChoiceFilter( + field_name='bridge', + queryset=VMInterface.objects.all(), + label='Bridged interface (ID)', + ) mac_address = MultiValueMACAddressFilter( label='MAC address', ) diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index c140fbc73..1e80e88e5 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -7,7 +7,7 @@ from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.models import VLAN from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField, + add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, StaticSelect ) from virtualization.choices import * @@ -23,7 +23,7 @@ __all__ = ( ) -class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ClusterTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ClusterType.objects.all(), widget=forms.MultipleHiddenInput @@ -37,7 +37,7 @@ class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['description'] -class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ClusterGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -51,7 +51,7 @@ class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['description'] -class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ClusterBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput() @@ -95,7 +95,7 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBul ] -class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class VirtualMachineBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput() @@ -150,7 +150,7 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldM ] -class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class VMInterfaceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VMInterface.objects.all(), widget=forms.MultipleHiddenInput() @@ -165,6 +165,10 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode queryset=VMInterface.objects.all(), required=False ) + bridge = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False + ) enabled = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect() @@ -195,7 +199,7 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode class Meta: nullable_fields = [ - 'parent', 'mtu', 'description', + 'parent', 'bridge', 'mtu', 'description', ] def __init__(self, *args, **kwargs): @@ -203,8 +207,9 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode if 'virtual_machine' in self.initial: vm_id = self.initial.get('virtual_machine') - # Restrict parent interface assignment by VM + # Restrict parent/bridge interface assignment by VM self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) + self.fields['bridge'].widget.add_query_param('virtual_machine_id', vm_id) # Limit VLAN choices by virtual machine self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) @@ -231,6 +236,11 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + self.fields['parent'].choices = () + self.fields['parent'].widget.attrs['disabled'] = True + self.fields['bridge'].choices = () + self.fields['bridge'].widget.attrs['disabled'] = True + class VMInterfaceBulkRenameForm(BulkRenameForm): pk = forms.ModelMultipleChoiceField( diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index d01418aa0..bd3279959 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -104,6 +104,18 @@ class VMInterfaceCSVForm(CustomFieldModelCSVForm): queryset=VirtualMachine.objects.all(), to_field_name='name' ) + parent = CSVModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + to_field_name='name', + help_text='Parent interface' + ) + bridge = CSVModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + to_field_name='name', + help_text='Bridged interface' + ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, @@ -113,7 +125,7 @@ class VMInterfaceCSVForm(CustomFieldModelCSVForm): class Meta: model = VMInterface fields = ( - 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', ) def clean_enabled(self): diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 0bb5c2bd7..7132ba316 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -5,8 +5,7 @@ from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm from tenancy.forms import TenancyFilterForm from utilities.forms import ( - BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, - BOOLEAN_WITH_BLANK_CHOICES, + DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.choices import * from virtualization.models import * @@ -20,31 +19,17 @@ __all__ = ( ) -class ClusterTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): +class ClusterTypeFilterForm(CustomFieldModelFilterForm): model = ClusterType - field_groups = [ - ['q'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) + tag = TagFilterField(model) -class ClusterGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): +class ClusterGroupFilterForm(CustomFieldModelFilterForm): model = ClusterGroup - field_groups = [ - ['q'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) + tag = TagFilterField(model) -class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): +class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = Cluster field_order = [ 'q', 'type_id', 'region_id', 'site_id', 'group_id', 'tenant_group_id', 'tenant_id', @@ -55,11 +40,6 @@ class ClusterFilterForm(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=ClusterType.objects.all(), required=False, @@ -99,12 +79,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte tag = TagFilterField(model) -class VirtualMachineFilterForm( - BootstrapMixin, - LocalConfigContextFilterForm, - TenancyFilterForm, - CustomFieldModelFilterForm -): +class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): model = VirtualMachine field_groups = [ ['q', 'tag'], @@ -113,11 +88,6 @@ class VirtualMachineFilterForm( ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), required=False, @@ -197,18 +167,13 @@ class VirtualMachineFilterForm( tag = TagFilterField(model) -class VMInterfaceFilterForm(BootstrapMixin, forms.Form): +class VMInterfaceFilterForm(CustomFieldModelFilterForm): model = VMInterface field_groups = [ ['q', 'tag'], ['cluster_id', 'virtual_machine_id'], ['enabled', 'mac_address'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), required=False, diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index d66bc9f1f..6fa90ea65 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -26,27 +26,35 @@ __all__ = ( ) -class ClusterTypeForm(BootstrapMixin, CustomFieldModelForm): +class ClusterTypeForm(CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = ClusterType - fields = [ - 'name', 'slug', 'description', - ] + fields = ( + 'name', 'slug', 'description', 'tags', + ) -class ClusterGroupForm(BootstrapMixin, CustomFieldModelForm): +class ClusterGroupForm(CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = ClusterGroup - fields = [ - 'name', 'slug', 'description', - ] + fields = ( + 'name', 'slug', 'description', 'tags', + ) -class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): +class ClusterForm(TenancyForm, CustomFieldModelForm): type = DynamicModelChoiceField( queryset=ClusterType.objects.all() ) @@ -163,7 +171,7 @@ class ClusterRemoveDevicesForm(ConfirmationForm): ) -class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): +class VirtualMachineForm(TenancyForm, CustomFieldModelForm): cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, @@ -263,12 +271,17 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): self.fields['primary_ip6'].widget.attrs['readonly'] = True -class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): +class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm): parent = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, label='Parent interface' ) + bridge = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + label='Bridged interface' + ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, @@ -298,8 +311,8 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) class Meta: model = VMInterface fields = [ - 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags', - 'untagged_vlan', 'tagged_vlans', + 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + 'tags', 'untagged_vlan', 'tagged_vlans', ] widgets = { 'virtual_machine': forms.HiddenInput(), @@ -318,6 +331,7 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) # Restrict parent interface assignment by VM self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) + self.fields['bridge'].widget.add_query_param('virtual_machine_id', vm_id) # Limit VLAN choices by virtual machine self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py index b58fb51f8..332334594 100644 --- a/netbox/virtualization/forms/object_create.py +++ b/netbox/virtualization/forms/object_create.py @@ -35,6 +35,13 @@ class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonFo 'virtual_machine_id': '$virtual_machine', } ) + bridge = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + query_params={ + 'virtual_machine_id': '$virtual_machine', + } + ) mac_address = forms.CharField( required=False, label='MAC Address' @@ -61,7 +68,7 @@ class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonFo required=False ) field_order = ( - 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'mtu', 'mac_address', 'description', 'mode', + 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' ) diff --git a/netbox/virtualization/migrations/0024_cluster_relax_uniqueness.py b/netbox/virtualization/migrations/0024_cluster_relax_uniqueness.py new file mode 100644 index 000000000..5ff214d29 --- /dev/null +++ b/netbox/virtualization/migrations/0024_cluster_relax_uniqueness.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0136_device_airflow'), + ('virtualization', '0023_virtualmachine_natural_ordering'), + ] + + operations = [ + migrations.AlterField( + model_name='cluster', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterUniqueTogether( + name='cluster', + unique_together={('site', 'name'), ('group', 'name')}, + ), + ] diff --git a/netbox/virtualization/migrations/0025_extend_tag_support.py b/netbox/virtualization/migrations/0025_extend_tag_support.py new file mode 100644 index 000000000..c77aee194 --- /dev/null +++ b/netbox/virtualization/migrations/0025_extend_tag_support.py @@ -0,0 +1,25 @@ +# 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'), + ('virtualization', '0024_cluster_relax_uniqueness'), + ] + + operations = [ + migrations.AddField( + model_name='clustergroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='clustertype', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/virtualization/migrations/0026_vminterface_bridge.py b/netbox/virtualization/migrations/0026_vminterface_bridge.py new file mode 100644 index 000000000..04909c72c --- /dev/null +++ b/netbox/virtualization/migrations/0026_vminterface_bridge.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-10-21 20:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0025_extend_tag_support'), + ] + + operations = [ + migrations.AddField( + model_name='vminterface', + name='bridge', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='virtualization.vminterface'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 3408cedbc..5a1bcd42f 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator @@ -9,11 +8,11 @@ from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features +from netbox.config import get_config from netbox.models import OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar -from utilities.querysets import RestrictedQuerySet from .choices import * @@ -30,7 +29,7 @@ __all__ = ( # Cluster types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterType(OrganizationalModel): """ A type of Cluster. @@ -48,8 +47,6 @@ class ClusterType(OrganizationalModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] @@ -64,7 +61,7 @@ class ClusterType(OrganizationalModel): # Cluster groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterGroup(OrganizationalModel): """ An organizational group of Clusters. @@ -81,14 +78,17 @@ class ClusterGroup(OrganizationalModel): 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='cluster_group' ) - - objects = RestrictedQuerySet.as_manager() + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) class Meta: ordering = ['name'] @@ -110,8 +110,7 @@ class Cluster(PrimaryModel): A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ name = models.CharField( - max_length=100, - unique=True + max_length=100 ) type = models.ForeignKey( to=ClusterType, @@ -142,14 +141,17 @@ class Cluster(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='cluster' ) - - objects = RestrictedQuerySet.as_manager() + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) clone_fields = [ 'type', 'group', 'tenant', 'site', @@ -157,6 +159,10 @@ class Cluster(PrimaryModel): class Meta: ordering = ['name'] + unique_together = ( + ('group', 'name'), + ('site', 'name'), + ) def __str__(self): return self.name @@ -268,6 +274,11 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): blank=True ) + # Generic relation + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + objects = ConfigContextModelQuerySet.as_manager() clone_fields = [ @@ -322,7 +333,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): @property def primary_ip(self): - if settings.PREFER_IPV4 and self.primary_ip4: + if get_config().PREFER_IPV4 and self.primary_ip4: return self.primary_ip4 elif self.primary_ip6: return self.primary_ip6 @@ -360,14 +371,6 @@ class VMInterface(PrimaryModel, BaseInterface): max_length=200, blank=True ) - parent = models.ForeignKey( - to='self', - on_delete=models.SET_NULL, - related_name='child_interfaces', - null=True, - blank=True, - verbose_name='Parent interface' - ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, @@ -388,8 +391,12 @@ class VMInterface(PrimaryModel, BaseInterface): object_id_field='assigned_object_id', related_query_name='vminterface' ) - - objects = RestrictedQuerySet.as_manager() + fhrp_group_assignments = GenericRelation( + to='ipam.FHRPGroupAssignment', + content_type_field='interface_type', + object_id_field='interface_id', + related_query_name='+' + ) class Meta: verbose_name = 'interface' @@ -405,6 +412,12 @@ class VMInterface(PrimaryModel, BaseInterface): def clean(self): super().clean() + # 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."}) + # An interface's parent must belong to the same virtual machine if self.parent and self.parent.virtual_machine != self.virtual_machine: raise ValidationError({ @@ -412,15 +425,26 @@ class VMInterface(PrimaryModel, BaseInterface): f"({self.parent.virtual_machine})." }) - # 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 + + # 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 virtual machine + if self.bridge and self.bridge.virtual_machine != self.virtual_machine: + raise ValidationError({ + 'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different virtual machine " + f"({self.bridge.virtual_machine})." + }) + + # VLAN validation # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: raise ValidationError({ 'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the " - f"interface's parent virtual machine, or it must be global" + f"interface's parent virtual machine, or it must be global." }) def to_objectchange(self, action): diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index cb6e64043..501b5c0c7 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,5 +1,4 @@ import django_tables2 as tables -from django.conf import settings from dcim.tables.devices import BaseInterfaceTable from tenancy.tables import TenantColumn from utilities.tables import ( @@ -17,8 +16,6 @@ __all__ = ( 'VMInterfaceTable', ) -PRIMARY_IP_ORDERING = ('primary_ip4', 'primary_ip6') if settings.PREFER_IPV4 else ('primary_ip6', 'primary_ip4') - VMINTERFACE_BUTTONS = """ {% if perms.ipam.add_ipaddress %} @@ -40,11 +37,14 @@ class ClusterTypeTable(BaseTable): cluster_count = tables.Column( verbose_name='Clusters' ) + tags = TagColumn( + url_name='virtualization:clustertype_list' + ) actions = ButtonsColumn(ClusterType) class Meta(BaseTable.Meta): model = ClusterType - fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'actions') + fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') @@ -60,11 +60,14 @@ class ClusterGroupTable(BaseTable): cluster_count = tables.Column( verbose_name='Clusters' ) + tags = TagColumn( + url_name='virtualization:clustergroup_list' + ) actions = ButtonsColumn(ClusterGroup) class Meta(BaseTable.Meta): model = ClusterGroup - fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'actions') + fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') @@ -130,7 +133,7 @@ class VirtualMachineTable(BaseTable): ) primary_ip = tables.Column( linkify=True, - order_by=PRIMARY_IP_ORDERING, + order_by=('primary_ip4', 'primary_ip6'), verbose_name='IP Address' ) tags = TagColumn( @@ -160,9 +163,6 @@ class VMInterfaceTable(BaseInterfaceTable): name = tables.Column( linkify=True ) - parent = tables.Column( - linkify=True - ) tags = TagColumn( url_name='virtualization:vminterface_list' ) @@ -170,13 +170,19 @@ class VMInterfaceTable(BaseInterfaceTable): class Meta(BaseTable.Meta): model = VMInterface fields = ( - 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'parent', 'mac_address', 'mtu', 'mode', 'description', 'tags', - 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', + 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', ) - default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'parent', 'description') + default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') class VirtualMachineVMInterfaceTable(VMInterfaceTable): + parent = tables.Column( + linkify=True + ) + bridge = tables.Column( + linkify=True + ) actions = ButtonsColumn( model=VMInterface, buttons=('edit', 'delete'), @@ -186,8 +192,8 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): class Meta(BaseTable.Meta): model = VMInterface fields = ( - 'pk', 'id', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses', - 'untagged_vlan', 'tagged_vlans', 'actions', + 'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', + 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', ) default_columns = ( 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions', diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 3245fb9bf..4a9b67bf0 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -246,14 +246,15 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): 'virtual_machine': virtualmachine.pk, 'name': 'Interface 5', 'mode': InterfaceModeChoices.MODE_TAGGED, + 'bridge': interfaces[0].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, { 'virtual_machine': virtualmachine.pk, 'name': 'Interface 6', - 'parent': interfaces[0].pk, 'mode': InterfaceModeChoices.MODE_TAGGED, + 'parent': interfaces[1].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 0ca6364a5..8c8f6671f 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -324,9 +324,8 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): clusters = Cluster.objects.all()[:2] params = {'cluster_id': [clusters[0].pk, clusters[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - # TODO: 'cluster' should match on name - # params = {'cluster': [clusters[0].name, clusters[1].name]} - # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'cluster': [clusters[0].name, clusters[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_region(self): regions = Region.objects.all()[:2] @@ -452,6 +451,19 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'parent_id': [parent_interface.pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_bridge(self): + # Create bridged interfaces + bridge_interface = VMInterface.objects.first() + bridged_interfaces = ( + VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 1', bridge=bridge_interface), + VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 2', bridge=bridge_interface), + VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 3', bridge=bridge_interface), + ) + VMInterface.objects.bulk_create(bridged_interfaces) + + params = {'bridge_id': [bridge_interface.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_mtu(self): params = {'mtu': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 020c9ebc5..7dc5660fd 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -22,10 +22,13 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Cluster Group X', 'slug': 'cluster-group-x', 'description': 'A new cluster group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -52,10 +55,13 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ClusterType(name='Cluster Type 3', slug='cluster-type-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Cluster Type X', 'slug': 'cluster-type-x', 'description': 'A new cluster type', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -242,10 +248,11 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): ) VirtualMachine.objects.bulk_create(virtualmachines) - VMInterface.objects.bulk_create([ + interfaces = VMInterface.objects.bulk_create([ VMInterface(virtual_machine=virtualmachines[0], name='Interface 1'), VMInterface(virtual_machine=virtualmachines[0], name='Interface 2'), VMInterface(virtual_machine=virtualmachines[0], name='Interface 3'), + VMInterface(virtual_machine=virtualmachines[1], name='BRIDGE'), ]) vlans = ( @@ -262,6 +269,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'virtual_machine': virtualmachines[1].pk, 'name': 'Interface X', 'enabled': False, + 'bridge': interfaces[3].pk, 'mac_address': EUI('01-02-03-04-05-06'), 'mtu': 65000, 'description': 'New description', @@ -275,6 +283,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'virtual_machine': virtualmachines[1].pk, 'name_pattern': 'Interface [4-6]', 'enabled': False, + 'bridge': interfaces[3].pk, 'mac_address': EUI('01-02-03-04-05-06'), 'mtu': 2000, 'description': 'New description', diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 2294d2c38..5cb4f133a 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -8,7 +8,7 @@ from dcim.models import Device from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView from ipam.models import IPAddress, Service -from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable +from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic from utilities.tables import paginate_table from utilities.utils import count_related @@ -421,7 +421,7 @@ class VMInterfaceView(generic.ObjectView): def get_extra_context(self, request, instance): # Get assigned IP addresses - ipaddress_table = InterfaceIPAddressTable( + ipaddress_table = AssignedIPAddressesTable( data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), orderable=False ) diff --git a/netbox/wireless/__init__.py b/netbox/wireless/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/api/__init__.py b/netbox/wireless/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py new file mode 100644 index 000000000..e9a840bfc --- /dev/null +++ b/netbox/wireless/api/nested_serializers.py @@ -0,0 +1,36 @@ +from rest_framework import serializers + +from netbox.api import WritableNestedSerializer +from wireless.models import * + +__all__ = ( + 'NestedWirelessLANSerializer', + 'NestedWirelessLANGroupSerializer', + 'NestedWirelessLinkSerializer', +) + + +class NestedWirelessLANGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail') + wirelesslan_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = WirelessLANGroup + fields = ['id', 'url', 'display', 'name', 'slug', 'wirelesslan_count', '_depth'] + + +class NestedWirelessLANSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') + + class Meta: + model = WirelessLAN + fields = ['id', 'url', 'display', 'ssid'] + + +class NestedWirelessLinkSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslink-detail') + + class Meta: + model = WirelessLink + fields = ['id', 'url', 'display', 'ssid'] diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py new file mode 100644 index 000000000..68e8181f1 --- /dev/null +++ b/netbox/wireless/api/serializers.py @@ -0,0 +1,59 @@ +from rest_framework import serializers + +from dcim.choices import LinkStatusChoices +from dcim.api.serializers import NestedInterfaceSerializer +from ipam.api.serializers import NestedVLANSerializer +from netbox.api import ChoiceField +from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer +from wireless.choices import * +from wireless.models import * +from .nested_serializers import * + +__all__ = ( + 'WirelessLANGroupSerializer', + 'WirelessLANSerializer', + 'WirelessLinkSerializer', +) + + +class WirelessLANGroupSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail') + parent = NestedWirelessLANGroupSerializer(required=False, allow_null=True, default=None) + wirelesslan_count = serializers.IntegerField(read_only=True) + + class Meta: + model = WirelessLANGroup + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'wirelesslan_count', '_depth', + ] + + +class WirelessLANSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') + group = NestedWirelessLANGroupSerializer(required=False, allow_null=True) + vlan = NestedVLANSerializer(required=False, allow_null=True) + auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) + auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) + + class Meta: + model = WirelessLAN + fields = [ + 'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', + ] + + +class WirelessLinkSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslink-detail') + status = ChoiceField(choices=LinkStatusChoices, required=False) + interface_a = NestedInterfaceSerializer() + interface_b = NestedInterfaceSerializer() + auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) + auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) + + class Meta: + model = WirelessLink + fields = [ + 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'description', 'auth_type', + 'auth_cipher', 'auth_psk', + ] diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py new file mode 100644 index 000000000..b02aa67c0 --- /dev/null +++ b/netbox/wireless/api/urls.py @@ -0,0 +1,13 @@ +from netbox.api import OrderedDefaultRouter +from . import views + + +router = OrderedDefaultRouter() +router.APIRootView = views.WirelessRootView + +router.register('wireless-lan-groups', views.WirelessLANGroupViewSet) +router.register('wireless-lans', views.WirelessLANViewSet) +router.register('wireless-links', views.WirelessLinkViewSet) + +app_name = 'wireless-api' +urlpatterns = router.urls diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py new file mode 100644 index 000000000..734f6940f --- /dev/null +++ b/netbox/wireless/api/views.py @@ -0,0 +1,38 @@ +from rest_framework.routers import APIRootView + +from extras.api.views import CustomFieldModelViewSet +from wireless import filtersets +from wireless.models import * +from . import serializers + + +class WirelessRootView(APIRootView): + """ + Wireless API root view + """ + def get_view_name(self): + return 'Wireless' + + +class WirelessLANGroupViewSet(CustomFieldModelViewSet): + queryset = WirelessLANGroup.objects.add_related_count( + WirelessLANGroup.objects.all(), + WirelessLAN, + 'group', + 'wirelesslan_count', + cumulative=True + ) + serializer_class = serializers.WirelessLANGroupSerializer + filterset_class = filtersets.WirelessLANGroupFilterSet + + +class WirelessLANViewSet(CustomFieldModelViewSet): + queryset = WirelessLAN.objects.prefetch_related('vlan', 'tags') + serializer_class = serializers.WirelessLANSerializer + filterset_class = filtersets.WirelessLANFilterSet + + +class WirelessLinkViewSet(CustomFieldModelViewSet): + queryset = WirelessLink.objects.prefetch_related('interface_a', 'interface_b', 'tags') + serializer_class = serializers.WirelessLinkSerializer + filterset_class = filtersets.WirelessLinkFilterSet diff --git a/netbox/wireless/apps.py b/netbox/wireless/apps.py new file mode 100644 index 000000000..59e47aba5 --- /dev/null +++ b/netbox/wireless/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class WirelessConfig(AppConfig): + name = 'wireless' + + def ready(self): + import wireless.signals diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py new file mode 100644 index 000000000..c8e7fd09f --- /dev/null +++ b/netbox/wireless/choices.py @@ -0,0 +1,191 @@ +from utilities.choices import ChoiceSet + + +class WirelessRoleChoices(ChoiceSet): + ROLE_AP = 'ap' + ROLE_STATION = 'station' + + CHOICES = ( + (ROLE_AP, 'Access point'), + (ROLE_STATION, 'Station'), + ) + + +class WirelessChannelChoices(ChoiceSet): + + # 2.4 GHz + CHANNEL_24G_1 = '2.4g-1-2412-22' + CHANNEL_24G_2 = '2.4g-2-2417-22' + CHANNEL_24G_3 = '2.4g-3-2422-22' + CHANNEL_24G_4 = '2.4g-4-2427-22' + CHANNEL_24G_5 = '2.4g-5-2432-22' + CHANNEL_24G_6 = '2.4g-6-2437-22' + CHANNEL_24G_7 = '2.4g-7-2442-22' + CHANNEL_24G_8 = '2.4g-8-2447-22' + CHANNEL_24G_9 = '2.4g-9-2452-22' + CHANNEL_24G_10 = '2.4g-10-2457-22' + CHANNEL_24G_11 = '2.4g-11-2462-22' + CHANNEL_24G_12 = '2.4g-12-2467-22' + CHANNEL_24G_13 = '2.4g-13-2472-22' + + # 5 GHz + CHANNEL_5G_32 = '5g-32-5160-20' + CHANNEL_5G_34 = '5g-34-5170-40' + CHANNEL_5G_36 = '5g-36-5180-20' + CHANNEL_5G_38 = '5g-38-5190-40' + CHANNEL_5G_40 = '5g-40-5200-20' + CHANNEL_5G_42 = '5g-42-5210-80' + CHANNEL_5G_44 = '5g-44-5220-20' + CHANNEL_5G_46 = '5g-46-5230-40' + CHANNEL_5G_48 = '5g-48-5240-20' + CHANNEL_5G_50 = '5g-50-5250-160' + CHANNEL_5G_52 = '5g-52-5260-20' + CHANNEL_5G_54 = '5g-54-5270-40' + CHANNEL_5G_56 = '5g-56-5280-20' + CHANNEL_5G_58 = '5g-58-5290-80' + CHANNEL_5G_60 = '5g-60-5300-20' + CHANNEL_5G_62 = '5g-62-5310-40' + CHANNEL_5G_64 = '5g-64-5320-20' + CHANNEL_5G_100 = '5g-100-5500-20' + CHANNEL_5G_102 = '5g-102-5510-40' + CHANNEL_5G_104 = '5g-104-5520-20' + CHANNEL_5G_106 = '5g-106-5530-80' + CHANNEL_5G_108 = '5g-108-5540-20' + CHANNEL_5G_110 = '5g-110-5550-40' + CHANNEL_5G_112 = '5g-112-5560-20' + CHANNEL_5G_114 = '5g-114-5570-160' + CHANNEL_5G_116 = '5g-116-5580-20' + CHANNEL_5G_118 = '5g-118-5590-40' + CHANNEL_5G_120 = '5g-120-5600-20' + CHANNEL_5G_122 = '5g-122-5610-80' + CHANNEL_5G_124 = '5g-124-5620-20' + CHANNEL_5G_126 = '5g-126-5630-40' + CHANNEL_5G_128 = '5g-128-5640-20' + CHANNEL_5G_132 = '5g-132-5660-20' + CHANNEL_5G_134 = '5g-134-5670-40' + CHANNEL_5G_136 = '5g-136-5680-20' + CHANNEL_5G_138 = '5g-138-5690-80' + CHANNEL_5G_140 = '5g-140-5700-20' + CHANNEL_5G_142 = '5g-142-5710-40' + CHANNEL_5G_144 = '5g-144-5720-20' + CHANNEL_5G_149 = '5g-149-5745-20' + CHANNEL_5G_151 = '5g-151-5755-40' + CHANNEL_5G_153 = '5g-153-5765-20' + CHANNEL_5G_155 = '5g-155-5775-80' + CHANNEL_5G_157 = '5g-157-5785-20' + CHANNEL_5G_159 = '5g-159-5795-40' + CHANNEL_5G_161 = '5g-161-5805-20' + CHANNEL_5G_163 = '5g-163-5815-160' + CHANNEL_5G_165 = '5g-165-5825-20' + CHANNEL_5G_167 = '5g-167-5835-40' + CHANNEL_5G_169 = '5g-169-5845-20' + CHANNEL_5G_171 = '5g-171-5855-80' + CHANNEL_5G_173 = '5g-173-5865-20' + CHANNEL_5G_175 = '5g-175-5875-40' + CHANNEL_5G_177 = '5g-177-5885-20' + + CHOICES = ( + ( + '2.4 GHz (802.11b/g/n/ax)', + ( + (CHANNEL_24G_1, '1 (2412 MHz)'), + (CHANNEL_24G_2, '2 (2417 MHz)'), + (CHANNEL_24G_3, '3 (2422 MHz)'), + (CHANNEL_24G_4, '4 (2427 MHz)'), + (CHANNEL_24G_5, '5 (2432 MHz)'), + (CHANNEL_24G_6, '6 (2437 MHz)'), + (CHANNEL_24G_7, '7 (2442 MHz)'), + (CHANNEL_24G_8, '8 (2447 MHz)'), + (CHANNEL_24G_9, '9 (2452 MHz)'), + (CHANNEL_24G_10, '10 (2457 MHz)'), + (CHANNEL_24G_11, '11 (2462 MHz)'), + (CHANNEL_24G_12, '12 (2467 MHz)'), + (CHANNEL_24G_13, '13 (2472 MHz)'), + ) + ), + ( + '5 GHz (802.11a/n/ac/ax)', + ( + (CHANNEL_5G_32, '32 (5160/20 MHz)'), + (CHANNEL_5G_34, '34 (5170/40 MHz)'), + (CHANNEL_5G_36, '36 (5180/20 MHz)'), + (CHANNEL_5G_38, '38 (5190/40 MHz)'), + (CHANNEL_5G_40, '40 (5200/20 MHz)'), + (CHANNEL_5G_42, '42 (5210/80 MHz)'), + (CHANNEL_5G_44, '44 (5220/20 MHz)'), + (CHANNEL_5G_46, '46 (5230/40 MHz)'), + (CHANNEL_5G_48, '48 (5240/20 MHz)'), + (CHANNEL_5G_50, '50 (5250/160 MHz)'), + (CHANNEL_5G_52, '52 (5260/20 MHz)'), + (CHANNEL_5G_54, '54 (5270/40 MHz)'), + (CHANNEL_5G_56, '56 (5280/20 MHz)'), + (CHANNEL_5G_58, '58 (5290/80 MHz)'), + (CHANNEL_5G_60, '60 (5300/20 MHz)'), + (CHANNEL_5G_62, '62 (5310/40 MHz)'), + (CHANNEL_5G_64, '64 (5320/20 MHz)'), + (CHANNEL_5G_100, '100 (5500/20 MHz)'), + (CHANNEL_5G_102, '102 (5510/40 MHz)'), + (CHANNEL_5G_104, '104 (5520/20 MHz)'), + (CHANNEL_5G_106, '106 (5530/80 MHz)'), + (CHANNEL_5G_108, '108 (5540/20 MHz)'), + (CHANNEL_5G_110, '110 (5550/40 MHz)'), + (CHANNEL_5G_112, '112 (5560/20 MHz)'), + (CHANNEL_5G_114, '114 (5570/160 MHz)'), + (CHANNEL_5G_116, '116 (5580/20 MHz)'), + (CHANNEL_5G_118, '118 (5590/40 MHz)'), + (CHANNEL_5G_120, '120 (5600/20 MHz)'), + (CHANNEL_5G_122, '122 (5610/80 MHz)'), + (CHANNEL_5G_124, '124 (5620/20 MHz)'), + (CHANNEL_5G_126, '126 (5630/40 MHz)'), + (CHANNEL_5G_128, '128 (5640/20 MHz)'), + (CHANNEL_5G_132, '132 (5660/20 MHz)'), + (CHANNEL_5G_134, '134 (5670/40 MHz)'), + (CHANNEL_5G_136, '136 (5680/20 MHz)'), + (CHANNEL_5G_138, '138 (5690/80 MHz)'), + (CHANNEL_5G_140, '140 (5700/20 MHz)'), + (CHANNEL_5G_142, '142 (5710/40 MHz)'), + (CHANNEL_5G_144, '144 (5720/20 MHz)'), + (CHANNEL_5G_149, '149 (5745/20 MHz)'), + (CHANNEL_5G_151, '151 (5755/40 MHz)'), + (CHANNEL_5G_153, '153 (5765/20 MHz)'), + (CHANNEL_5G_155, '155 (5775/80 MHz)'), + (CHANNEL_5G_157, '157 (5785/20 MHz)'), + (CHANNEL_5G_159, '159 (5795/40 MHz)'), + (CHANNEL_5G_161, '161 (5805/20 MHz)'), + (CHANNEL_5G_163, '163 (5815/160 MHz)'), + (CHANNEL_5G_165, '165 (5825/20 MHz)'), + (CHANNEL_5G_167, '167 (5835/40 MHz)'), + (CHANNEL_5G_169, '169 (5845/20 MHz)'), + (CHANNEL_5G_171, '171 (5855/80 MHz)'), + (CHANNEL_5G_173, '173 (5865/20 MHz)'), + (CHANNEL_5G_175, '175 (5875/40 MHz)'), + (CHANNEL_5G_177, '177 (5885/20 MHz)'), + ) + ), + ) + + +class WirelessAuthTypeChoices(ChoiceSet): + TYPE_OPEN = 'open' + TYPE_WEP = 'wep' + TYPE_WPA_PERSONAL = 'wpa-personal' + TYPE_WPA_ENTERPRISE = 'wpa-enterprise' + + CHOICES = ( + (TYPE_OPEN, 'Open'), + (TYPE_WEP, 'WEP'), + (TYPE_WPA_PERSONAL, 'WPA Personal (PSK)'), + (TYPE_WPA_ENTERPRISE, 'WPA Enterprise'), + ) + + +class WirelessAuthCipherChoices(ChoiceSet): + CIPHER_AUTO = 'auto' + CIPHER_TKIP = 'tkip' + CIPHER_AES = 'aes' + + CHOICES = ( + (CIPHER_AUTO, 'Auto'), + (CIPHER_TKIP, 'TKIP'), + (CIPHER_AES, 'AES'), + ) diff --git a/netbox/wireless/constants.py b/netbox/wireless/constants.py new file mode 100644 index 000000000..63de2b136 --- /dev/null +++ b/netbox/wireless/constants.py @@ -0,0 +1,2 @@ +SSID_MAX_LENGTH = 32 # Per IEEE 802.11-2007 +PSK_MAX_LENGTH = 64 diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py new file mode 100644 index 000000000..3fb173b1b --- /dev/null +++ b/netbox/wireless/filtersets.py @@ -0,0 +1,105 @@ +import django_filters +from django.db.models import Q + +from dcim.choices import LinkStatusChoices +from extras.filters import TagFilter +from ipam.models import VLAN +from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from utilities.filters import MultiValueNumberFilter, TreeNodeMultipleChoiceFilter +from .choices import * +from .models import * + +__all__ = ( + 'WirelessLANFilterSet', + 'WirelessLANGroupFilterSet', + 'WirelessLinkFilterSet', +) + + +class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=WirelessLANGroup.objects.all() + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=WirelessLANGroup.objects.all(), + to_field_name='slug' + ) + tag = TagFilter() + + class Meta: + model = WirelessLANGroup + fields = ['id', 'name', 'slug', 'description'] + + +class WirelessLANFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + group_id = TreeNodeMultipleChoiceFilter( + queryset=WirelessLANGroup.objects.all(), + field_name='group', + lookup_expr='in' + ) + group = TreeNodeMultipleChoiceFilter( + queryset=WirelessLANGroup.objects.all(), + field_name='group', + lookup_expr='in', + to_field_name='slug' + ) + vlan_id = django_filters.ModelMultipleChoiceFilter( + queryset=VLAN.objects.all() + ) + auth_type = django_filters.MultipleChoiceFilter( + choices=WirelessAuthTypeChoices + ) + auth_cipher = django_filters.MultipleChoiceFilter( + choices=WirelessAuthCipherChoices + ) + tag = TagFilter() + + class Meta: + model = WirelessLAN + fields = ['id', 'ssid', 'auth_psk'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(ssid__icontains=value) | + Q(description__icontains=value) + ) + return queryset.filter(qs_filter) + + +class WirelessLinkFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + interface_a_id = MultiValueNumberFilter() + interface_b_id = MultiValueNumberFilter() + status = django_filters.MultipleChoiceFilter( + choices=LinkStatusChoices + ) + auth_type = django_filters.MultipleChoiceFilter( + choices=WirelessAuthTypeChoices + ) + auth_cipher = django_filters.MultipleChoiceFilter( + choices=WirelessAuthCipherChoices + ) + tag = TagFilter() + + class Meta: + model = WirelessLink + fields = ['id', 'ssid', 'auth_psk'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(ssid__icontains=value) | + Q(description__icontains=value) + ) + return queryset.filter(qs_filter) diff --git a/netbox/wireless/forms/__init__.py b/netbox/wireless/forms/__init__.py new file mode 100644 index 000000000..62c2ec2d9 --- /dev/null +++ b/netbox/wireless/forms/__init__.py @@ -0,0 +1,4 @@ +from .models import * +from .filtersets import * +from .bulk_edit import * +from .bulk_import import * diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py new file mode 100644 index 000000000..314c42653 --- /dev/null +++ b/netbox/wireless/forms/bulk_edit.py @@ -0,0 +1,101 @@ +from django import forms + +from dcim.choices import LinkStatusChoices +from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm +from ipam.models import VLAN +from utilities.forms import DynamicModelChoiceField +from wireless.choices import * +from wireless.constants import SSID_MAX_LENGTH +from wireless.models import * + +__all__ = ( + 'WirelessLANBulkEditForm', + 'WirelessLANGroupBulkEditForm', + 'WirelessLinkBulkEditForm', +) + + +class WirelessLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=WirelessLANGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + +class WirelessLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=WirelessLAN.objects.all(), + widget=forms.MultipleHiddenInput + ) + group = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False + ) + vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + ) + ssid = forms.CharField( + max_length=SSID_MAX_LENGTH, + required=False + ) + description = forms.CharField( + required=False + ) + auth_type = forms.ChoiceField( + choices=WirelessAuthTypeChoices, + required=False + ) + auth_cipher = forms.ChoiceField( + choices=WirelessAuthCipherChoices, + required=False + ) + auth_psk = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ['ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk'] + + +class WirelessLinkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=WirelessLink.objects.all(), + widget=forms.MultipleHiddenInput + ) + ssid = forms.CharField( + max_length=SSID_MAX_LENGTH, + required=False + ) + status = forms.ChoiceField( + choices=LinkStatusChoices, + required=False + ) + description = forms.CharField( + required=False + ) + auth_type = forms.ChoiceField( + choices=WirelessAuthTypeChoices, + required=False + ) + auth_cipher = forms.ChoiceField( + choices=WirelessAuthCipherChoices, + required=False + ) + auth_psk = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ['ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk'] diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py new file mode 100644 index 000000000..aa79e1fc7 --- /dev/null +++ b/netbox/wireless/forms/bulk_import.py @@ -0,0 +1,83 @@ +from dcim.choices import LinkStatusChoices +from dcim.models import Interface +from extras.forms import CustomFieldModelCSVForm +from ipam.models import VLAN +from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField +from wireless.choices import * +from wireless.models import * + +__all__ = ( + 'WirelessLANCSVForm', + 'WirelessLANGroupCSVForm', + 'WirelessLinkCSVForm', +) + + +class WirelessLANGroupCSVForm(CustomFieldModelCSVForm): + parent = CSVModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Parent group' + ) + slug = SlugField() + + class Meta: + model = WirelessLANGroup + fields = ('name', 'slug', 'parent', 'description') + + +class WirelessLANCSVForm(CustomFieldModelCSVForm): + group = CSVModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned group' + ) + vlan = CSVModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + to_field_name='name', + help_text='Bridged VLAN' + ) + auth_type = CSVChoiceField( + choices=WirelessAuthTypeChoices, + required=False, + help_text='Authentication type' + ) + auth_cipher = CSVChoiceField( + choices=WirelessAuthCipherChoices, + required=False, + help_text='Authentication cipher' + ) + + class Meta: + model = WirelessLAN + fields = ('ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk') + + +class WirelessLinkCSVForm(CustomFieldModelCSVForm): + status = CSVChoiceField( + choices=LinkStatusChoices, + help_text='Connection status' + ) + interface_a = CSVModelChoiceField( + queryset=Interface.objects.all() + ) + interface_b = CSVModelChoiceField( + queryset=Interface.objects.all() + ) + auth_type = CSVChoiceField( + choices=WirelessAuthTypeChoices, + required=False, + help_text='Authentication type' + ) + auth_cipher = CSVChoiceField( + choices=WirelessAuthCipherChoices, + required=False, + help_text='Authentication cipher' + ) + + class Meta: + model = WirelessLink + fields = ('interface_a', 'interface_b', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk') diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py new file mode 100644 index 000000000..b94332bd7 --- /dev/null +++ b/netbox/wireless/forms/filtersets.py @@ -0,0 +1,85 @@ +from django import forms +from django.utils.translation import gettext as _ + +from dcim.choices import LinkStatusChoices +from extras.forms import CustomFieldModelFilterForm +from utilities.forms import add_blank_choice, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField +from wireless.choices import * +from wireless.models import * + +__all__ = ( + 'WirelessLANFilterForm', + 'WirelessLANGroupFilterForm', + 'WirelessLinkFilterForm', +) + + +class WirelessLANGroupFilterForm(CustomFieldModelFilterForm): + model = WirelessLANGroup + parent_id = DynamicModelMultipleChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + label=_('Parent group'), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class WirelessLANFilterForm(CustomFieldModelFilterForm): + model = WirelessLAN + field_groups = [ + ('q', 'tag'), + ('group_id',), + ] + ssid = forms.CharField( + required=False, + label='SSID' + ) + group_id = DynamicModelMultipleChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + null_option='None', + label=_('Group'), + fetch_trigger='open' + ) + auth_type = forms.ChoiceField( + required=False, + choices=add_blank_choice(WirelessAuthTypeChoices), + widget=StaticSelect() + ) + auth_cipher = forms.ChoiceField( + required=False, + choices=add_blank_choice(WirelessAuthCipherChoices), + widget=StaticSelect() + ) + auth_psk = forms.CharField( + required=False + ) + tag = TagFilterField(model) + + +class WirelessLinkFilterForm(CustomFieldModelFilterForm): + model = WirelessLink + ssid = forms.CharField( + required=False, + label='SSID' + ) + status = forms.ChoiceField( + required=False, + choices=add_blank_choice(LinkStatusChoices), + widget=StaticSelect() + ) + auth_type = forms.ChoiceField( + required=False, + choices=add_blank_choice(WirelessAuthTypeChoices), + widget=StaticSelect() + ) + auth_cipher = forms.ChoiceField( + required=False, + choices=add_blank_choice(WirelessAuthCipherChoices), + widget=StaticSelect() + ) + auth_psk = forms.CharField( + required=False + ) + tag = TagFilterField(model) diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py new file mode 100644 index 000000000..7687cb372 --- /dev/null +++ b/netbox/wireless/forms/models.py @@ -0,0 +1,164 @@ +from dcim.models import Device, Interface, Location, Site +from extras.forms import CustomFieldModelForm +from extras.models import Tag +from ipam.models import VLAN +from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect +from wireless.models import * + +__all__ = ( + 'WirelessLANForm', + 'WirelessLANGroupForm', + 'WirelessLinkForm', +) + + +class WirelessLANGroupForm(CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False + ) + slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = WirelessLANGroup + fields = [ + 'parent', 'name', 'slug', 'description', 'tags', + ] + + +class WirelessLANForm(CustomFieldModelForm): + group = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False + ) + vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + label='VLAN' + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = WirelessLAN + fields = [ + 'ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + ] + fieldsets = ( + ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), + ('VLAN', ('vlan',)), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), + ) + widgets = { + 'auth_type': StaticSelect, + 'auth_cipher': StaticSelect, + } + + +class WirelessLinkForm(CustomFieldModelForm): + site_a = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + label='Site', + initial_params={ + 'devices': '$device_a', + } + ) + location_a = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + label='Location', + initial_params={ + 'devices': '$device_a', + } + ) + device_a = DynamicModelChoiceField( + queryset=Device.objects.all(), + query_params={ + 'site_id': '$site_a', + 'location_id': '$location_a', + }, + required=False, + label='Device', + initial_params={ + 'interfaces': '$interface_a' + } + ) + interface_a = DynamicModelChoiceField( + queryset=Interface.objects.all(), + query_params={ + 'kind': 'wireless', + 'device_id': '$device_a', + }, + disabled_indicator='_occupied', + label='Interface' + ) + site_b = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + label='Site', + initial_params={ + 'devices': '$device_b', + } + ) + location_b = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + label='Location', + initial_params={ + 'devices': '$device_b', + } + ) + device_b = DynamicModelChoiceField( + queryset=Device.objects.all(), + query_params={ + 'site_id': '$site_b', + 'location_id': '$location_b', + }, + required=False, + label='Device', + initial_params={ + 'interfaces': '$interface_b' + } + ) + interface_b = DynamicModelChoiceField( + queryset=Interface.objects.all(), + query_params={ + 'kind': 'wireless', + 'device_id': '$device_b', + }, + disabled_indicator='_occupied', + label='Interface' + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = WirelessLink + fields = [ + 'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b', + 'status', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + ] + fieldsets = ( + ('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')), + ('Side B', ('site_b', 'location_b', 'device_b', 'interface_b')), + ('Link', ('status', 'ssid', 'description', 'tags')), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), + ) + widgets = { + 'status': StaticSelect, + 'auth_type': StaticSelect, + 'auth_cipher': StaticSelect, + } + labels = { + 'auth_type': 'Type', + 'auth_cipher': 'Cipher', + } diff --git a/netbox/wireless/graphql/__init__.py b/netbox/wireless/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/graphql/schema.py b/netbox/wireless/graphql/schema.py new file mode 100644 index 000000000..cd8fd9f52 --- /dev/null +++ b/netbox/wireless/graphql/schema.py @@ -0,0 +1,15 @@ +import graphene + +from netbox.graphql.fields import ObjectField, ObjectListField +from .types import * + + +class WirelessQuery(graphene.ObjectType): + wireless_lan = ObjectField(WirelessLANType) + wireless_lan_list = ObjectListField(WirelessLANType) + + wireless_lan_group = ObjectField(WirelessLANGroupType) + wireless_lan_group_list = ObjectListField(WirelessLANGroupType) + + wireless_link = ObjectField(WirelessLinkType) + wireless_link_list = ObjectListField(WirelessLinkType) diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py new file mode 100644 index 000000000..c3235e72e --- /dev/null +++ b/netbox/wireless/graphql/types.py @@ -0,0 +1,44 @@ +from wireless import filtersets, models +from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType + +__all__ = ( + 'WirelessLANType', + 'WirelessLANGroupType', + 'WirelessLinkType', +) + + +class WirelessLANGroupType(OrganizationalObjectType): + + class Meta: + model = models.WirelessLANGroup + fields = '__all__' + filterset_class = filtersets.WirelessLANGroupFilterSet + + +class WirelessLANType(PrimaryObjectType): + + class Meta: + model = models.WirelessLAN + fields = '__all__' + filterset_class = filtersets.WirelessLANFilterSet + + def resolve_auth_type(self, info): + return self.auth_type or None + + def resolve_auth_cipher(self, info): + return self.auth_cipher or None + + +class WirelessLinkType(PrimaryObjectType): + + class Meta: + model = models.WirelessLink + fields = '__all__' + filterset_class = filtersets.WirelessLinkFilterSet + + def resolve_auth_type(self, info): + return self.auth_type or None + + def resolve_auth_cipher(self, info): + return self.auth_cipher or None diff --git a/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py new file mode 100644 index 000000000..64a0f7732 --- /dev/null +++ b/netbox/wireless/migrations/0001_wireless.py @@ -0,0 +1,86 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields +import taggit.managers + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dcim', '0139_rename_cable_peer'), + ('extras', '0062_clear_secrets_changelog'), + ('ipam', '0050_iprange'), + ] + + operations = [ + migrations.CreateModel( + name='WirelessLANGroup', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='wireless.wirelesslangroup')), + ], + options={ + 'ordering': ('name', 'pk'), + 'unique_together': {('parent', 'name')}, + }, + ), + migrations.CreateModel( + name='WirelessLAN', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('ssid', models.CharField(max_length=32)), + ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wireless_lans', to='wireless.wirelesslangroup')), + ('description', models.CharField(blank=True, max_length=200)), + ('auth_cipher', models.CharField(blank=True, max_length=50)), + ('auth_psk', models.CharField(blank=True, max_length=64)), + ('auth_type', models.CharField(blank=True, max_length=50)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan')), + ], + options={ + 'verbose_name': 'Wireless LAN', + 'ordering': ('ssid', 'pk'), + }, + ), + migrations.CreateModel( + name='WirelessLink', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('ssid', models.CharField(blank=True, max_length=32)), + ('status', models.CharField(default='connected', max_length=50)), + ('description', models.CharField(blank=True, max_length=200)), + ('auth_cipher', models.CharField(blank=True, max_length=50)), + ('auth_psk', models.CharField(blank=True, max_length=64)), + ('auth_type', models.CharField(blank=True, max_length=50)), + ('_interface_a_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')), + ('_interface_b_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')), + ('interface_a', models.ForeignKey(limit_choices_to={'type__in': ['ieee802.11a', 'ieee802.11g', 'ieee802.11n', 'ieee802.11ac', 'ieee802.11ad', 'ieee802.11ax']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface')), + ('interface_b', models.ForeignKey(limit_choices_to={'type__in': ['ieee802.11a', 'ieee802.11g', 'ieee802.11n', 'ieee802.11ac', 'ieee802.11ad', 'ieee802.11ax']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ['pk'], + 'unique_together': {('interface_a', 'interface_b')}, + }, + ), + ] diff --git a/netbox/wireless/migrations/__init__.py b/netbox/wireless/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py new file mode 100644 index 000000000..4d6d26a92 --- /dev/null +++ b/netbox/wireless/models.py @@ -0,0 +1,204 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse +from mptt.models import MPTTModel, TreeForeignKey + +from dcim.choices import LinkStatusChoices +from dcim.constants import WIRELESS_IFACE_TYPES +from extras.utils import extras_features +from netbox.models import BigIDModel, NestedGroupModel, PrimaryModel +from .choices import * +from .constants import * + +__all__ = ( + 'WirelessLAN', + 'WirelessLANGroup', + 'WirelessLink', +) + + +class WirelessAuthenticationBase(models.Model): + """ + Abstract model for attaching attributes related to wireless authentication. + """ + auth_type = models.CharField( + max_length=50, + choices=WirelessAuthTypeChoices, + blank=True + ) + auth_cipher = models.CharField( + max_length=50, + choices=WirelessAuthCipherChoices, + blank=True + ) + auth_psk = models.CharField( + max_length=PSK_MAX_LENGTH, + blank=True, + verbose_name='Pre-shared key' + ) + + class Meta: + abstract = True + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class WirelessLANGroup(NestedGroupModel): + """ + A nested grouping of WirelessLANs + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + class Meta: + ordering = ('name', 'pk') + unique_together = ( + ('parent', 'name') + ) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('wireless:wirelesslangroup', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): + """ + A wireless network formed among an arbitrary number of access point and clients. + """ + ssid = models.CharField( + max_length=SSID_MAX_LENGTH, + verbose_name='SSID' + ) + group = models.ForeignKey( + to='wireless.WirelessLANGroup', + on_delete=models.SET_NULL, + related_name='wireless_lans', + blank=True, + null=True + ) + vlan = models.ForeignKey( + to='ipam.VLAN', + on_delete=models.PROTECT, + blank=True, + null=True, + verbose_name='VLAN' + ) + description = models.CharField( + max_length=200, + blank=True + ) + + class Meta: + ordering = ('ssid', 'pk') + verbose_name = 'Wireless LAN' + + def __str__(self): + return self.ssid + + def get_absolute_url(self): + return reverse('wireless:wirelesslan', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class WirelessLink(WirelessAuthenticationBase, PrimaryModel): + """ + A point-to-point connection between two wireless Interfaces. + """ + interface_a = models.ForeignKey( + to='dcim.Interface', + limit_choices_to={'type__in': WIRELESS_IFACE_TYPES}, + on_delete=models.PROTECT, + related_name='+' + ) + interface_b = models.ForeignKey( + to='dcim.Interface', + limit_choices_to={'type__in': WIRELESS_IFACE_TYPES}, + on_delete=models.PROTECT, + related_name='+' + ) + ssid = models.CharField( + max_length=SSID_MAX_LENGTH, + blank=True, + verbose_name='SSID' + ) + status = models.CharField( + max_length=50, + choices=LinkStatusChoices, + default=LinkStatusChoices.STATUS_CONNECTED + ) + description = models.CharField( + max_length=200, + blank=True + ) + + # Cache the associated device for the A and B interfaces. This enables filtering of WirelessLinks by their + # associated Devices. + _interface_a_device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + _interface_b_device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + + clone_fields = ('ssid', 'status') + + class Meta: + ordering = ['pk'] + unique_together = ('interface_a', 'interface_b') + + def __str__(self): + return f'#{self.pk}' + + def get_absolute_url(self): + return reverse('wireless:wirelesslink', args=[self.pk]) + + def get_status_class(self): + return LinkStatusChoices.CSS_CLASSES.get(self.status) + + def clean(self): + + # Validate interface types + if self.interface_a.type not in WIRELESS_IFACE_TYPES: + raise ValidationError({ + 'interface_a': f"{self.interface_a.get_type_display()} is not a wireless interface." + }) + if self.interface_b.type not in WIRELESS_IFACE_TYPES: + raise ValidationError({ + 'interface_a': f"{self.interface_b.get_type_display()} is not a wireless interface." + }) + + def save(self, *args, **kwargs): + + # Store the parent Device for the A and B interfaces + self._interface_a_device = self.interface_a.device + self._interface_b_device = self.interface_b.device + + super().save(*args, **kwargs) diff --git a/netbox/wireless/signals.py b/netbox/wireless/signals.py new file mode 100644 index 000000000..3b4831a8d --- /dev/null +++ b/netbox/wireless/signals.py @@ -0,0 +1,66 @@ +import logging + +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver + +from dcim.models import CablePath, Interface +from dcim.utils import create_cablepath +from .models import WirelessLink + + +# +# Wireless links +# + +@receiver(post_save, sender=WirelessLink) +def update_connected_interfaces(instance, created, raw=False, **kwargs): + """ + When a WirelessLink is saved, save a reference to it on each connected interface. + """ + logger = logging.getLogger('netbox.wireless.wirelesslink') + if raw: + logger.debug(f"Skipping endpoint updates for imported wireless link {instance}") + return + + if instance.interface_a.wireless_link != instance: + logger.debug(f"Updating interface A for wireless link {instance}") + instance.interface_a.wireless_link = instance + instance.interface_a._link_peer = instance.interface_b + instance.interface_a.save() + if instance.interface_b.cable != instance: + logger.debug(f"Updating interface B for wireless link {instance}") + instance.interface_b.wireless_link = instance + instance.interface_b._link_peer = instance.interface_a + instance.interface_b.save() + + # Create/update cable paths + if created: + for interface in (instance.interface_a, instance.interface_b): + create_cablepath(interface) + + +@receiver(post_delete, sender=WirelessLink) +def nullify_connected_interfaces(instance, **kwargs): + """ + When a WirelessLink is deleted, update its two connected Interfaces + """ + logger = logging.getLogger('netbox.wireless.wirelesslink') + + if instance.interface_a is not None: + logger.debug(f"Nullifying interface A for wireless link {instance}") + Interface.objects.filter(pk=instance.interface_a.pk).update( + wireless_link=None, + _link_peer_type=None, + _link_peer_id=None + ) + if instance.interface_b is not None: + logger.debug(f"Nullifying interface B for wireless link {instance}") + Interface.objects.filter(pk=instance.interface_b.pk).update( + wireless_link=None, + _link_peer_type=None, + _link_peer_id=None + ) + + # Delete and retrace any dependent cable paths + for cablepath in CablePath.objects.filter(path__contains=instance): + cablepath.delete() diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py new file mode 100644 index 000000000..4f47ee7f9 --- /dev/null +++ b/netbox/wireless/tables.py @@ -0,0 +1,110 @@ +import django_tables2 as tables + +from dcim.models import Interface +from utilities.tables import ( + BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, +) +from .models import * + +__all__ = ( + 'WirelessLANTable', + 'WirelessLANGroupTable', + 'WirelessLinkTable', +) + + +class WirelessLANGroupTable(BaseTable): + pk = ToggleColumn() + name = MPTTColumn( + linkify=True + ) + wirelesslan_count = LinkedCountColumn( + viewname='wireless:wirelesslan_list', + url_params={'group_id': 'pk'}, + verbose_name='Wireless LANs' + ) + tags = TagColumn( + url_name='wireless:wirelesslangroup_list' + ) + actions = ButtonsColumn(WirelessLANGroup) + + class Meta(BaseTable.Meta): + model = WirelessLANGroup + fields = ('pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'actions') + default_columns = ('pk', 'name', 'wirelesslan_count', 'description', 'actions') + + +class WirelessLANTable(BaseTable): + pk = ToggleColumn() + ssid = tables.Column( + linkify=True + ) + group = tables.Column( + linkify=True + ) + interface_count = tables.Column( + verbose_name='Interfaces' + ) + tags = TagColumn( + url_name='wireless:wirelesslan_list' + ) + + class Meta(BaseTable.Meta): + model = WirelessLAN + fields = ( + 'pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', 'auth_psk', + 'tags', + ) + default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') + + +class WirelessLANInterfacesTable(BaseTable): + pk = ToggleColumn() + device = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True + ) + + class Meta(BaseTable.Meta): + model = Interface + fields = ('pk', 'device', 'name', 'rf_role', 'rf_channel') + default_columns = ('pk', 'device', 'name', 'rf_role', 'rf_channel') + + +class WirelessLinkTable(BaseTable): + pk = ToggleColumn() + id = tables.Column( + linkify=True, + verbose_name='ID' + ) + status = ChoiceFieldColumn() + device_a = tables.Column( + accessor=tables.A('interface_a__device'), + linkify=True + ) + interface_a = tables.Column( + linkify=True + ) + device_b = tables.Column( + accessor=tables.A('interface_b__device'), + linkify=True + ) + interface_b = tables.Column( + linkify=True + ) + tags = TagColumn( + url_name='wireless:wirelesslink_list' + ) + + class Meta(BaseTable.Meta): + model = WirelessLink + fields = ( + 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description', + 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + ) + default_columns = ( + 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'auth_type', + 'description', + ) diff --git a/netbox/wireless/tests/__init__.py b/netbox/wireless/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py new file mode 100644 index 000000000..917b7b320 --- /dev/null +++ b/netbox/wireless/tests/test_api.py @@ -0,0 +1,141 @@ +from django.urls import reverse + +from wireless.choices import * +from wireless.models import * +from dcim.choices import InterfaceTypeChoices +from dcim.models import Interface +from utilities.testing import APITestCase, APIViewTestCases, create_test_device + + +class AppTest(APITestCase): + + def test_root(self): + url = reverse('wireless-api:api-root') + response = self.client.get('{}?format=api'.format(url), **self.header) + + self.assertEqual(response.status_code, 200) + + +class WirelessLANGroupTest(APIViewTestCases.APIViewTestCase): + model = WirelessLANGroup + brief_fields = ['_depth', 'display', 'id', 'name', 'slug', 'url', 'wirelesslan_count'] + create_data = [ + { + 'name': 'Wireless LAN Group 4', + 'slug': 'wireless-lan-group-4', + }, + { + 'name': 'Wireless LAN Group 5', + 'slug': 'wireless-lan-group-5', + }, + { + 'name': 'Wireless LAN Group 6', + 'slug': 'wireless-lan-group-6', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + WirelessLANGroup.objects.create(name='Wireless LAN Group 1', slug='wireless-lan-group-1') + WirelessLANGroup.objects.create(name='Wireless LAN Group 2', slug='wireless-lan-group-2') + WirelessLANGroup.objects.create(name='Wireless LAN Group 3', slug='wireless-lan-group-3') + + +class WirelessLANTest(APIViewTestCases.APIViewTestCase): + model = WirelessLAN + brief_fields = ['display', 'id', 'ssid', 'url'] + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Group 1', slug='group-1'), + WirelessLANGroup(name='Group 2', slug='group-2'), + WirelessLANGroup(name='Group 3', slug='group-3'), + ) + for group in groups: + group.save() + + wireless_lans = ( + WirelessLAN(ssid='WLAN1'), + WirelessLAN(ssid='WLAN2'), + WirelessLAN(ssid='WLAN3'), + ) + WirelessLAN.objects.bulk_create(wireless_lans) + + cls.create_data = [ + { + 'ssid': 'WLAN4', + 'group': groups[0].pk, + 'auth_type': WirelessAuthTypeChoices.TYPE_OPEN, + }, + { + 'ssid': 'WLAN5', + 'group': groups[1].pk, + 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, + }, + { + 'ssid': 'WLAN6', + 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_ENTERPRISE, + }, + ] + + cls.bulk_update_data = { + 'group': groups[2].pk, + 'description': 'New description', + 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, + 'auth_cipher': WirelessAuthCipherChoices.CIPHER_AES, + 'auth_psk': 'abc123def456', + } + + +class WirelessLinkTest(APIViewTestCases.APIViewTestCase): + model = WirelessLink + brief_fields = ['display', 'id', 'ssid', 'url'] + bulk_update_data = { + 'status': 'planned', + } + + @classmethod + def setUpTestData(cls): + device = create_test_device('test-device') + interfaces = [ + Interface( + device=device, + name=f'radio{i}', + type=InterfaceTypeChoices.TYPE_80211AC, + rf_channel=WirelessChannelChoices.CHANNEL_5G_32, + rf_channel_frequency=5160, + rf_channel_width=20 + ) for i in range(12) + ] + Interface.objects.bulk_create(interfaces) + + wireless_links = ( + WirelessLink(ssid='LINK1', interface_a=interfaces[0], interface_b=interfaces[1]), + WirelessLink(ssid='LINK2', interface_a=interfaces[2], interface_b=interfaces[3]), + WirelessLink(ssid='LINK3', interface_a=interfaces[4], interface_b=interfaces[5]), + ) + WirelessLink.objects.bulk_create(wireless_links) + + cls.create_data = [ + { + 'interface_a': interfaces[6].pk, + 'interface_b': interfaces[7].pk, + 'ssid': 'LINK4', + }, + { + 'interface_a': interfaces[8].pk, + 'interface_b': interfaces[9].pk, + 'ssid': 'LINK5', + }, + { + 'interface_a': interfaces[10].pk, + 'interface_b': interfaces[11].pk, + 'ssid': 'LINK6', + }, + ] diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py new file mode 100644 index 000000000..50f89c4d6 --- /dev/null +++ b/netbox/wireless/tests/test_filtersets.py @@ -0,0 +1,194 @@ +from django.test import TestCase + +from dcim.choices import InterfaceTypeChoices, LinkStatusChoices +from dcim.models import Interface +from ipam.models import VLAN +from wireless.choices import * +from wireless.filtersets import * +from wireless.models import * +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device + + +class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = WirelessLANGroup.objects.all() + filterset = WirelessLANGroupFilterSet + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1', description='A'), + WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2', description='B'), + WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C'), + ) + for group in groups: + group.save() + + child_groups = ( + WirelessLANGroup(name='Wireless LAN Group 1A', slug='wireless-lan-group-1a', parent=groups[0]), + WirelessLANGroup(name='Wireless LAN Group 1B', slug='wireless-lan-group-1b', parent=groups[0]), + WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=groups[1]), + WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=groups[1]), + WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=groups[2]), + WirelessLANGroup(name='Wireless LAN Group 3B', slug='wireless-lan-group-3b', parent=groups[2]), + ) + for group in child_groups: + group.save() + + def test_name(self): + params = {'name': ['Wireless LAN Group 1', 'Wireless LAN Group 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['wireless-lan-group-1', 'wireless-lan-group-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_parent(self): + parent_groups = WirelessLANGroup.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + +class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = WirelessLAN.objects.all() + filterset = WirelessLANFilterSet + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), + WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), + WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'), + ) + for group in groups: + group.save() + + vlans = ( + VLAN(name='VLAN1', vid=1), + VLAN(name='VLAN2', vid=2), + VLAN(name='VLAN3', vid=3), + ) + VLAN.objects.bulk_create(vlans) + + wireless_lans = ( + WirelessLAN(ssid='WLAN1', group=groups[0], vlan=vlans[0], auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1'), + WirelessLAN(ssid='WLAN2', group=groups[1], vlan=vlans[1], auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2'), + WirelessLAN(ssid='WLAN3', group=groups[2], vlan=vlans[2], auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, auth_psk='PSK3'), + ) + WirelessLAN.objects.bulk_create(wireless_lans) + + def test_ssid(self): + params = {'ssid': ['WLAN1', 'WLAN2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_group(self): + groups = WirelessLANGroup.objects.all()[:2] + params = {'group_id': [groups[0].pk, groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [groups[0].slug, groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_vlan(self): + vlans = VLAN.objects.all()[:2] + params = {'vlan_id': [vlans[0].pk, vlans[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_type(self): + params = {'auth_type': [WirelessAuthTypeChoices.TYPE_OPEN, WirelessAuthTypeChoices.TYPE_WEP]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_cipher(self): + params = {'auth_cipher': [WirelessAuthCipherChoices.CIPHER_AUTO, WirelessAuthCipherChoices.CIPHER_TKIP]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_psk(self): + params = {'auth_psk': ['PSK1', 'PSK2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = WirelessLink.objects.all() + filterset = WirelessLinkFilterSet + + @classmethod + def setUpTestData(cls): + + devices = ( + create_test_device('device1'), + create_test_device('device2'), + create_test_device('device3'), + create_test_device('device4'), + ) + + interfaces = ( + Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[1], name='Interface 3', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[1], name='Interface 4', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[2], name='Interface 5', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[2], name='Interface 6', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC), + ) + Interface.objects.bulk_create(interfaces) + + # Wireless links + WirelessLink( + interface_a=interfaces[0], + interface_b=interfaces[2], + ssid='LINK1', + status=LinkStatusChoices.STATUS_CONNECTED, + auth_type=WirelessAuthTypeChoices.TYPE_OPEN, + auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, + auth_psk='PSK1' + ).save() + WirelessLink( + interface_a=interfaces[1], + interface_b=interfaces[3], + ssid='LINK2', + status=LinkStatusChoices.STATUS_PLANNED, + auth_type=WirelessAuthTypeChoices.TYPE_WEP, + auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, + auth_psk='PSK2' + ).save() + WirelessLink( + interface_a=interfaces[4], + interface_b=interfaces[6], + ssid='LINK3', + status=LinkStatusChoices.STATUS_DECOMMISSIONING, + auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, + auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, + auth_psk='PSK3' + ).save() + WirelessLink( + interface_a=interfaces[5], + interface_b=interfaces[7], + ssid='LINK4' + ).save() + + def test_ssid(self): + params = {'ssid': ['LINK1', 'LINK2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_status(self): + params = {'status': [LinkStatusChoices.STATUS_PLANNED, LinkStatusChoices.STATUS_DECOMMISSIONING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_type(self): + params = {'auth_type': [WirelessAuthTypeChoices.TYPE_OPEN, WirelessAuthTypeChoices.TYPE_WEP]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_cipher(self): + params = {'auth_cipher': [WirelessAuthCipherChoices.CIPHER_AUTO, WirelessAuthCipherChoices.CIPHER_TKIP]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_psk(self): + params = {'auth_psk': ['PSK1', 'PSK2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py new file mode 100644 index 000000000..4141af6d6 --- /dev/null +++ b/netbox/wireless/tests/test_views.py @@ -0,0 +1,123 @@ +from wireless.choices import * +from wireless.models import * +from dcim.choices import InterfaceTypeChoices, LinkStatusChoices +from dcim.models import Interface +from utilities.testing import ViewTestCases, create_tags, create_test_device + + +class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = WirelessLANGroup + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), + WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), + WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'), + ) + for group in groups: + group.save() + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Wireless LAN Group X', + 'slug': 'wireless-lan-group-x', + 'parent': groups[2].pk, + 'description': 'A new wireless LAN group', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,slug,description", + "Wireles sLAN Group 4,wireless-lan-group-4,Fourth wireless LAN group", + "Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group", + "Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = WirelessLAN + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), + WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), + ) + for group in groups: + group.save() + + WirelessLAN.objects.bulk_create([ + WirelessLAN(group=groups[0], ssid='WLAN1'), + WirelessLAN(group=groups[0], ssid='WLAN2'), + WirelessLAN(group=groups[0], ssid='WLAN3'), + ]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'ssid': 'WLAN2', + 'group': groups[1].pk, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "group,ssid", + "Wireless LAN Group 2,WLAN4", + "Wireless LAN Group 2,WLAN5", + "Wireless LAN Group 2,WLAN6", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = WirelessLink + + @classmethod + def setUpTestData(cls): + device = create_test_device('test-device') + interfaces = [ + Interface( + device=device, + name=f'radio{i}', + type=InterfaceTypeChoices.TYPE_80211AC, + rf_channel=WirelessChannelChoices.CHANNEL_5G_32, + rf_channel_frequency=5160, + rf_channel_width=20 + ) for i in range(12) + ] + Interface.objects.bulk_create(interfaces) + + WirelessLink(interface_a=interfaces[0], interface_b=interfaces[1], ssid='LINK1').save() + WirelessLink(interface_a=interfaces[2], interface_b=interfaces[3], ssid='LINK2').save() + WirelessLink(interface_a=interfaces[4], interface_b=interfaces[5], ssid='LINK3').save() + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'interface_a': interfaces[6].pk, + 'interface_b': interfaces[7].pk, + 'status': LinkStatusChoices.STATUS_PLANNED, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "interface_a,interface_b,status", + f"{interfaces[6].pk},{interfaces[7].pk},connected", + f"{interfaces[8].pk},{interfaces[9].pk},connected", + f"{interfaces[10].pk},{interfaces[11].pk},connected", + ) + + cls.bulk_edit_data = { + 'status': LinkStatusChoices.STATUS_PLANNED, + } diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py new file mode 100644 index 000000000..684f55ad5 --- /dev/null +++ b/netbox/wireless/urls.py @@ -0,0 +1,45 @@ +from django.urls import path + +from extras.views import ObjectChangeLogView, ObjectJournalView +from . import views +from .models import * + +app_name = 'wireless' +urlpatterns = ( + + # Wireless LAN groups + path('wireless-lan-groups/', views.WirelessLANGroupListView.as_view(), name='wirelesslangroup_list'), + path('wireless-lan-groups/add/', views.WirelessLANGroupEditView.as_view(), name='wirelesslangroup_add'), + path('wireless-lan-groups/import/', views.WirelessLANGroupBulkImportView.as_view(), name='wirelesslangroup_import'), + path('wireless-lan-groups/edit/', views.WirelessLANGroupBulkEditView.as_view(), name='wirelesslangroup_bulk_edit'), + path('wireless-lan-groups/delete/', views.WirelessLANGroupBulkDeleteView.as_view(), name='wirelesslangroup_bulk_delete'), + path('wireless-lan-groups//', views.WirelessLANGroupView.as_view(), name='wirelesslangroup'), + path('wireless-lan-groups//edit/', views.WirelessLANGroupEditView.as_view(), name='wirelesslangroup_edit'), + path('wireless-lan-groups//delete/', views.WirelessLANGroupDeleteView.as_view(), name='wirelesslangroup_delete'), + path('wireless-lan-groups//changelog/', ObjectChangeLogView.as_view(), name='wirelesslangroup_changelog', kwargs={'model': WirelessLANGroup}), + + # Wireless LANs + path('wireless-lans/', views.WirelessLANListView.as_view(), name='wirelesslan_list'), + path('wireless-lans/add/', views.WirelessLANEditView.as_view(), name='wirelesslan_add'), + path('wireless-lans/import/', views.WirelessLANBulkImportView.as_view(), name='wirelesslan_import'), + path('wireless-lans/edit/', views.WirelessLANBulkEditView.as_view(), name='wirelesslan_bulk_edit'), + path('wireless-lans/delete/', views.WirelessLANBulkDeleteView.as_view(), name='wirelesslan_bulk_delete'), + path('wireless-lans//', views.WirelessLANView.as_view(), name='wirelesslan'), + path('wireless-lans//edit/', views.WirelessLANEditView.as_view(), name='wirelesslan_edit'), + path('wireless-lans//delete/', views.WirelessLANDeleteView.as_view(), name='wirelesslan_delete'), + path('wireless-lans//changelog/', ObjectChangeLogView.as_view(), name='wirelesslan_changelog', kwargs={'model': WirelessLAN}), + path('wireless-lans//journal/', ObjectJournalView.as_view(), name='wirelesslan_journal', kwargs={'model': WirelessLAN}), + + # Wireless links + path('wireless-links/', views.WirelessLinkListView.as_view(), name='wirelesslink_list'), + path('wireless-links/add/', views.WirelessLinkEditView.as_view(), name='wirelesslink_add'), + path('wireless-links/import/', views.WirelessLinkBulkImportView.as_view(), name='wirelesslink_import'), + path('wireless-links/edit/', views.WirelessLinkBulkEditView.as_view(), name='wirelesslink_bulk_edit'), + path('wireless-links/delete/', views.WirelessLinkBulkDeleteView.as_view(), name='wirelesslink_bulk_delete'), + path('wireless-links//', views.WirelessLinkView.as_view(), name='wirelesslink'), + path('wireless-links//edit/', views.WirelessLinkEditView.as_view(), name='wirelesslink_edit'), + path('wireless-links//delete/', views.WirelessLinkDeleteView.as_view(), name='wirelesslink_delete'), + path('wireless-links//changelog/', ObjectChangeLogView.as_view(), name='wirelesslink_changelog', kwargs={'model': WirelessLink}), + path('wireless-links//journal/', ObjectJournalView.as_view(), name='wirelesslink_journal', kwargs={'model': WirelessLink}), + +) diff --git a/netbox/wireless/utils.py b/netbox/wireless/utils.py new file mode 100644 index 000000000..d98d6a853 --- /dev/null +++ b/netbox/wireless/utils.py @@ -0,0 +1,27 @@ +from decimal import Decimal + +from .choices import WirelessChannelChoices + +__all__ = ( + 'get_channel_attr', +) + + +def get_channel_attr(channel, attr): + """ + Return the specified attribute of a given WirelessChannelChoices value. + """ + if channel not in WirelessChannelChoices.values(): + raise ValueError(f"Invalid channel value: {channel}") + + channel_values = channel.split('-') + attrs = { + 'band': channel_values[0], + 'id': int(channel_values[1]), + 'frequency': Decimal(channel_values[2]), + 'width': Decimal(channel_values[3]), + } + if attr not in attrs: + raise ValueError(f"Invalid channel attribute: {attr}") + + return attrs[attr] diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py new file mode 100644 index 000000000..dd1e760bb --- /dev/null +++ b/netbox/wireless/views.py @@ -0,0 +1,177 @@ +from dcim.models import Interface +from netbox.views import generic +from utilities.tables import paginate_table +from utilities.utils import count_related +from . import filtersets, forms, tables +from .models import * + + +# +# Wireless LAN groups +# + +class WirelessLANGroupListView(generic.ObjectListView): + queryset = WirelessLANGroup.objects.add_related_count( + WirelessLANGroup.objects.all(), + WirelessLAN, + 'group', + 'wirelesslan_count', + cumulative=True + ).prefetch_related('tags') + filterset = filtersets.WirelessLANGroupFilterSet + filterset_form = forms.WirelessLANGroupFilterForm + table = tables.WirelessLANGroupTable + + +class WirelessLANGroupView(generic.ObjectView): + queryset = WirelessLANGroup.objects.all() + + def get_extra_context(self, request, instance): + wirelesslans = WirelessLAN.objects.restrict(request.user, 'view').filter( + group=instance + ) + wirelesslans_table = tables.WirelessLANTable(wirelesslans, exclude=('group',)) + paginate_table(wirelesslans_table, request) + + return { + 'wirelesslans_table': wirelesslans_table, + } + + +class WirelessLANGroupEditView(generic.ObjectEditView): + queryset = WirelessLANGroup.objects.all() + model_form = forms.WirelessLANGroupForm + + +class WirelessLANGroupDeleteView(generic.ObjectDeleteView): + queryset = WirelessLANGroup.objects.all() + + +class WirelessLANGroupBulkImportView(generic.BulkImportView): + queryset = WirelessLANGroup.objects.all() + model_form = forms.WirelessLANGroupCSVForm + table = tables.WirelessLANGroupTable + + +class WirelessLANGroupBulkEditView(generic.BulkEditView): + queryset = WirelessLANGroup.objects.add_related_count( + WirelessLANGroup.objects.all(), + WirelessLAN, + 'group', + 'wirelesslan_count', + cumulative=True + ) + filterset = filtersets.WirelessLANGroupFilterSet + table = tables.WirelessLANGroupTable + form = forms.WirelessLANGroupBulkEditForm + + +class WirelessLANGroupBulkDeleteView(generic.BulkDeleteView): + queryset = WirelessLANGroup.objects.add_related_count( + WirelessLANGroup.objects.all(), + WirelessLAN, + 'group', + 'wirelesslan_count', + cumulative=True + ) + filterset = filtersets.WirelessLANGroupFilterSet + table = tables.WirelessLANGroupTable + + +# +# Wireless LANs +# + +class WirelessLANListView(generic.ObjectListView): + queryset = WirelessLAN.objects.annotate( + interface_count=count_related(Interface, 'wireless_lans') + ) + filterset = filtersets.WirelessLANFilterSet + filterset_form = forms.WirelessLANFilterForm + table = tables.WirelessLANTable + + +class WirelessLANView(generic.ObjectView): + queryset = WirelessLAN.objects.all() + + def get_extra_context(self, request, instance): + attached_interfaces = Interface.objects.restrict(request.user, 'view').filter( + wireless_lans=instance + ) + interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces) + paginate_table(interfaces_table, request) + + return { + 'interfaces_table': interfaces_table, + } + + +class WirelessLANEditView(generic.ObjectEditView): + queryset = WirelessLAN.objects.all() + model_form = forms.WirelessLANForm + + +class WirelessLANDeleteView(generic.ObjectDeleteView): + queryset = WirelessLAN.objects.all() + + +class WirelessLANBulkImportView(generic.BulkImportView): + queryset = WirelessLAN.objects.all() + model_form = forms.WirelessLANCSVForm + table = tables.WirelessLANTable + + +class WirelessLANBulkEditView(generic.BulkEditView): + queryset = WirelessLAN.objects.all() + filterset = filtersets.WirelessLANFilterSet + table = tables.WirelessLANTable + form = forms.WirelessLANBulkEditForm + + +class WirelessLANBulkDeleteView(generic.BulkDeleteView): + queryset = WirelessLAN.objects.all() + filterset = filtersets.WirelessLANFilterSet + table = tables.WirelessLANTable + + +# +# Wireless Links +# + +class WirelessLinkListView(generic.ObjectListView): + queryset = WirelessLink.objects.all() + filterset = filtersets.WirelessLinkFilterSet + filterset_form = forms.WirelessLinkFilterForm + table = tables.WirelessLinkTable + + +class WirelessLinkView(generic.ObjectView): + queryset = WirelessLink.objects.all() + + +class WirelessLinkEditView(generic.ObjectEditView): + queryset = WirelessLink.objects.all() + model_form = forms.WirelessLinkForm + + +class WirelessLinkDeleteView(generic.ObjectDeleteView): + queryset = WirelessLink.objects.all() + + +class WirelessLinkBulkImportView(generic.BulkImportView): + queryset = WirelessLink.objects.all() + model_form = forms.WirelessLinkCSVForm + table = tables.WirelessLinkTable + + +class WirelessLinkBulkEditView(generic.BulkEditView): + queryset = WirelessLink.objects.all() + filterset = filtersets.WirelessLinkFilterSet + table = tables.WirelessLinkTable + form = forms.WirelessLinkBulkEditForm + + +class WirelessLinkBulkDeleteView(generic.BulkDeleteView): + queryset = WirelessLink.objects.all() + filterset = filtersets.WirelessLinkFilterSet + table = tables.WirelessLinkTable diff --git a/requirements.txt b/requirements.txt index c537a39c3..d728493dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,28 +1,30 @@ Django==3.2.9 -django-cors-headers==3.10.0 +django-cors-headers==3.10.1 django-debug-toolbar==3.2.2 django-filter==21.1 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.13.4 django-pglocks==1.0.4 django-prometheus==2.1.0 -django-redis==5.0.0 -django-rq==2.4.1 +django-redis==5.1.0 +django-rq==2.5.1 django-tables2==2.4.1 -django-taggit==1.5.1 +django-taggit==2.0.0 django-timezone-field==4.2.1 djangorestframework==3.12.4 drf-yasg[validation]==1.20.0 graphene_django==2.15.0 gunicorn==20.1.0 -Jinja2==3.0.2 -Markdown==3.3.4 +Jinja2==3.0.3 +Markdown==3.3.6 markdown-include==0.6.0 -mkdocs-material==7.3.6 +mkdocs-material==8.0.4 netaddr==0.8.0 Pillow==8.4.0 -psycopg2-binary==2.9.1 +psycopg2-binary==2.9.2 PyYAML==6.0 +social-auth-app-django==5.0.0 +social-auth-core==4.1.0 svgwrite==1.4.1 tablib==3.1.0 diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit index 71b8cca6e..7a3d680a4 100755 --- a/scripts/git-hooks/pre-commit +++ b/scripts/git-hooks/pre-commit @@ -11,6 +11,7 @@ exec 1>&2 EXIT=0 RED='\033[0;31m' +YELLOW='\033[0;33m' NOCOLOR='\033[0m' if [ -d ./venv/ ]; then @@ -22,6 +23,11 @@ if [ -d ./venv/ ]; then fi fi +if [ ${NOVALIDATE} ]; then + echo "${YELLOW}Skipping validation checks${NOCOLOR}" + exit $EXIT +fi + echo "Validating PEP8 compliance..." pycodestyle --ignore=W504,E501 --exclude=node_modules netbox/ if [ $? != 0 ]; then