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/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md new file mode 100644 index 000000000..765e1d0f1 --- /dev/null +++ b/docs/configuration/dynamic-settings.md @@ -0,0 +1,137 @@ +# 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. + +--- + +## 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. + +--- + +## 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..c568fc7f0 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -1,18 +1,21 @@ # 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) ## 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..3c1e24e9b 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 @@ -168,14 +141,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 @@ -299,30 +264,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 +280,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,30 +313,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` 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/development/models.md b/docs/development/models.md index 93a10fff6..59e795cf7 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: | | | | | 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/extras/customfield.md b/docs/models/extras/customfield.md index 7294fbd34..0932791e7 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -16,6 +16,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net * 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/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..c335bf9a8 --- /dev/null +++ b/docs/reference/conditions.md @@ -0,0 +1,89 @@ +# 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 + +### Examples + +`name` equals "foobar": + +```json +{ + "attr": "name", + "value": "foobar" +} +``` + +`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/version-3.1.md b/docs/release-notes/version-3.1.md index 291831500..f489f0966 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -6,6 +6,9 @@ ### 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. + +### New Features #### Contacts ([#1344](https://github.com/netbox-community/netbox/issues/1344)) @@ -13,18 +16,60 @@ A set of new models for tracking contact information has been introduced within 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). + +#### 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", + "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. ### 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 +* [#6497](https://github.com/netbox-community/netbox/issues/6497) - Extend tag support to organizational models * [#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 ### Other Changes @@ -37,6 +82,27 @@ When assigning a contact to an object, the user must select a predefined role (e * `/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 * dcim.Cable * Added `tenant` field * dcim.Device @@ -44,6 +110,18 @@ When assigning a contact to an object, the user must select a predefined role (e * dcim.DeviceType * Added `airflow` field * dcim.Interface + * Added `bridge` field + * Added `rf_role` field + * Added `rf_channel` field + * Added `rf_channel_frequency` field + * Added `rf_chanel_width` field + * Added `tx_power` field * Added `wwn` field + * `cable_peer` has been renamed to `link_peer` + * `cable_peer_type` has been renamed to `link_peer_type` * dcim.Location * Added `tenant` field +* extras.Webhook + * Added the `conditions` field +* virtualization.VMInterface + * Added `bridge` 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 ce660285f..52efdd656 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,6 +51,7 @@ nav: - Configuring NetBox: 'configuration/index.md' - Required Settings: 'configuration/required-settings.md' - Optional Settings: 'configuration/optional-settings.md' + - Dynamic Settings: 'configuration/dynamic-settings.md' - Core Functionality: - IP Address Management: 'core-functionality/ipam.md' - VLAN Management: 'core-functionality/vlans.md' @@ -60,6 +61,7 @@ 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' @@ -92,6 +94,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' 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..7bf5644b9 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -79,7 +79,7 @@ class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField ] -class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class CircuitTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=CircuitType.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 63b654148..b29f8f772 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -79,14 +79,12 @@ class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class CircuitTypeFilterForm(BootstrapMixin, 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): diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 659939293..5679dbc94 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -75,11 +75,15 @@ class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm): class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = CircuitType fields = [ - 'name', 'slug', 'description', + 'name', 'slug', 'description', 'tags', ] 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.py b/netbox/circuits/models.py index 3d213b48d..089d6cb2d 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -4,7 +4,7 @@ from django.db import models from django.urls import reverse from dcim.fields import ASNField -from dcim.models import CableTermination, PathEndpoint +from dcim.models import LinkTermination, PathEndpoint from extras.models import ObjectChange from extras.utils import extras_features from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel @@ -128,7 +128,7 @@ class ProviderNetwork(PrimaryModel): return reverse('circuits:providernetwork', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class CircuitType(OrganizationalModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named @@ -256,7 +256,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, diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 2e31237b6..d0b0797e2 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', 'name', 'circuit_count', 'description', 'slug', 'actions') + fields = ('pk', '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/serializers.py b/netbox/dcim/api/serializers.py index d5d7bd52c..6fd67bf69 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -11,35 +11,37 @@ from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSer from ipam.models import 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 NestedWirelessLinkSerializer +from wireless.choices import * 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) @@ -87,8 +89,8 @@ class RegionSerializer(NestedGroupModelSerializer): 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', ] @@ -100,8 +102,8 @@ class SiteGroupSerializer(NestedGroupModelSerializer): 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', ] @@ -147,20 +149,20 @@ class LocationSerializer(NestedGroupModelSerializer): class Meta: model = Location fields = [ - 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'custom_fields', + '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', ] @@ -231,10 +233,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 @@ -257,7 +259,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) @@ -266,7 +268,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', ] @@ -414,7 +416,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) @@ -422,12 +424,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) @@ -437,7 +439,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', ] @@ -507,7 +509,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( @@ -526,12 +528,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( @@ -550,12 +552,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( @@ -579,12 +581,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( @@ -598,18 +600,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(), @@ -618,14 +623,16 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co many=True ) cable = NestedCableSerializer(read_only=True) + wireless_link = NestedWirelessLinkSerializer(read_only=True) count_ipaddresses = serializers.IntegerField(read_only=True) class Meta: model = Interface fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'wwn', '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', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', ] @@ -644,7 +651,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) @@ -654,7 +661,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', ] @@ -670,7 +677,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) @@ -681,7 +688,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', ] @@ -732,7 +739,7 @@ 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) @@ -857,7 +864,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( @@ -887,7 +894,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 0f39c5434..e05ccaed2 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 @@ -21,6 +20,7 @@ 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 @@ -168,7 +168,7 @@ class LocationViewSet(CustomFieldModelViewSet): 'location', 'rack_count', cumulative=True - ).prefetch_related('site') + ).prefetch_related('site', 'tags') serializer_class = serializers.LocationSerializer filterset_class = filtersets.LocationFilterSet @@ -178,7 +178,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 @@ -262,7 +262,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') @@ -341,7 +341,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') ) @@ -354,7 +354,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') ) @@ -458,9 +458,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) @@ -482,7 +485,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): hostname=host, username=username, password=password, - timeout=settings.NAPALM_TIMEOUT, + timeout=timeout, optional_args=optional_args ) try: @@ -514,7 +517,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'] @@ -522,7 +525,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 @@ -530,14 +533,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'] @@ -545,7 +548,7 @@ 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', 'ip_addresses', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet @@ -626,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 2a7ed8b89..de46aec8a 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -720,6 +720,7 @@ class InterfaceTypeChoices(ChoiceSet): # Virtual TYPE_VIRTUAL = 'virtual' + TYPE_BRIDGE = 'bridge' TYPE_LAG = 'lag' # Ethernet @@ -820,6 +821,7 @@ class InterfaceTypeChoices(ChoiceSet): 'Virtual interfaces', ( (TYPE_VIRTUAL, 'Virtual'), + (TYPE_BRIDGE, 'Bridge'), (TYPE_LAG, 'Link Aggregation Group (LAG)'), ), ), @@ -1061,7 +1063,7 @@ class PortTypeChoices(ChoiceSet): # -# Cables +# Cables/links # class CableTypeChoices(ChoiceSet): @@ -1125,7 +1127,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/filtersets.py b/netbox/dcim/filtersets.py index 0142b36c6..c58e9d17e 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -15,6 +15,7 @@ from utilities.filters import ( TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster +from wireless.choices import WirelessRoleChoices, WirelessChannelChoices from .choices import * from .constants import * from .models import * @@ -72,6 +73,7 @@ class RegionFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Parent region (slug)', ) + tag = TagFilter() class Meta: model = Region @@ -89,6 +91,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Parent site group (slug)', ) + tag = TagFilter() class Meta: model = SiteGroup @@ -220,6 +223,7 @@ class LocationFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Location (slug)', ) + tag = TagFilter() class Meta: model = Location @@ -235,6 +239,7 @@ class LocationFilterSet(OrganizationalModelFilterSet): class RackRoleFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = RackRole @@ -400,6 +405,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class ManufacturerFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = Manufacturer @@ -582,6 +588,7 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent class DeviceRoleFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = DeviceRole @@ -600,6 +607,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + tag = TagFilter() class Meta: model = Platform @@ -980,6 +988,11 @@ 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(), @@ -1000,10 +1013,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: @@ -1215,7 +1237,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): choices=CableTypeChoices ) status = django_filters.MultipleChoiceFilter( - choices=CableStatusChoices + choices=LinkStatusChoices ) color = django_filters.MultipleChoiceFilter( choices=ColorChoices diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 9728f231f..045dbf737 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -51,7 +51,7 @@ __all__ = ( ) -class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RegionBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Region.objects.all(), widget=forms.MultipleHiddenInput @@ -69,7 +69,7 @@ class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'description'] -class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class SiteGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=SiteGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -131,7 +131,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd ] -class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class LocationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Location.objects.all(), widget=forms.MultipleHiddenInput @@ -160,7 +160,7 @@ class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'tenant', 'description'] -class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RackRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RackRole.objects.all(), widget=forms.MultipleHiddenInput @@ -302,7 +302,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField nullable_fields = [] -class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ManufacturerBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Manufacturer.objects.all(), widget=forms.MultipleHiddenInput @@ -344,7 +344,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel nullable_fields = ['airflow'] -class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class DeviceRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=DeviceRole.objects.all(), widget=forms.MultipleHiddenInput @@ -366,7 +366,7 @@ class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['color', 'description'] -class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class PlatformBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Platform.objects.all(), widget=forms.MultipleHiddenInput @@ -462,7 +462,7 @@ 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='' @@ -938,8 +938,8 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', '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, @@ -963,6 +963,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, @@ -990,8 +994,8 @@ class InterfaceBulkEditForm( class Meta: nullable_fields = [ - 'label', 'parent', 'lag', 'mac_address', 'wwn', '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): @@ -999,8 +1003,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 @@ -1028,6 +1033,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 diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 10898fb81..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', @@ -569,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, @@ -584,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', 'wwn', - '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: @@ -812,7 +797,7 @@ class CableCSVForm(CustomFieldModelCSVForm): # Cable attributes status = CSVChoiceField( - choices=CableStatusChoices, + choices=LinkStatusChoices, required=False, help_text='Connection status' ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 328ed37c2..11fc69745 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -12,6 +12,7 @@ from utilities.forms import ( APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) +from wireless.choices import * __all__ = ( 'CableFilterForm', @@ -106,10 +107,6 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = Region - field_groups = [ - ['q'], - ['parent_id'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), @@ -121,14 +118,11 @@ class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Parent region'), fetch_trigger='open' ) + tag = TagFilterField(model) class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = SiteGroup - field_groups = [ - ['q'], - ['parent_id'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), @@ -140,6 +134,7 @@ class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Parent group'), fetch_trigger='open' ) + tag = TagFilterField(model) class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): @@ -226,18 +221,17 @@ class LocationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilt label=_('Parent'), fetch_trigger='open' ) + tag = TagFilterField(model) class RackRoleFilterForm(BootstrapMixin, 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): @@ -378,14 +372,12 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMo class ManufacturerFilterForm(BootstrapMixin, 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): @@ -463,14 +455,12 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class DeviceRoleFilterForm(BootstrapMixin, 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): @@ -486,6 +476,7 @@ class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Manufacturer'), fetch_trigger='open' ) + tag = TagFilterField(model) class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): @@ -743,7 +734,7 @@ class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterF ) status = forms.ChoiceField( required=False, - choices=add_blank_choice(CableStatusChoices), + choices=add_blank_choice(LinkStatusChoices), widget=StaticSelect() ) color = ColorField( @@ -974,6 +965,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['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', 'device_id'], ] kind = forms.MultipleChoiceField( @@ -1006,6 +998,32 @@ class InterfaceFilterForm(DeviceComponentFilterForm): 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) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index ca7074e05..24a1e8140 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -17,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__ = ( @@ -71,11 +72,15 @@ class RegionForm(BootstrapMixin, CustomFieldModelForm): 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', ) @@ -85,11 +90,15 @@ class SiteGroupForm(BootstrapMixin, CustomFieldModelForm): 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', ) @@ -208,15 +217,19 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Location fields = ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags', ) fieldsets = ( ('Location', ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', )), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -224,11 +237,15 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class RackRoleForm(BootstrapMixin, 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', ] @@ -364,11 +381,15 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class ManufacturerForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Manufacturer fields = [ - 'name', 'slug', 'description', + 'name', 'slug', 'description', 'tags', ] @@ -413,11 +434,15 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): class DeviceRoleForm(BootstrapMixin, 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', ] @@ -429,11 +454,15 @@ 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(), @@ -1085,6 +1114,11 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): required=False, label='Parent interface' ) + bridge = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Bridged interface' + ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -1093,6 +1127,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, @@ -1122,19 +1169,24 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', '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): @@ -1142,13 +1194,14 @@ 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) diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 7577ad355..3beb42c8d 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__ = ( @@ -445,6 +446,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 +473,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 +504,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/graphql/types.py b/netbox/dcim/graphql/types.py index 6d93452cd..8b9bd76ef 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -215,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): 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.py b/netbox/dcim/migrations/0134_interface_wwn.py deleted file mode 100644 index 0739edbbb..000000000 --- a/netbox/dcim/migrations/0134_interface_wwn.py +++ /dev/null @@ -1,17 +0,0 @@ -import dcim.fields -from django.db import migrations - - -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), - ), - ] 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 index 673b5027f..96d765eea 100644 --- a/netbox/dcim/migrations/0135_tenancy_extensions.py +++ b/netbox/dcim/migrations/0135_tenancy_extensions.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ ('tenancy', '0002_tenant_ordering'), - ('dcim', '0134_interface_wwn'), + ('dcim', '0134_interface_wwn_bridge'), ] operations = [ 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/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 bddce93b9..54012f0e9 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -64,8 +64,8 @@ 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', @@ -292,7 +292,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): """ @@ -386,7 +386,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 @@ -396,13 +396,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_components.py b/netbox/dcim/models/device_components.py index 386776b41..e166c44ab 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -18,11 +18,13 @@ 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', @@ -87,14 +89,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 +105,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 +148,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 +159,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): """ @@ -219,7 +228,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 +260,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 +292,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 +342,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 +356,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 +389,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 +462,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 @@ -475,7 +500,7 @@ class BaseInterface(models.Model): @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 +511,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, @@ -517,6 +534,51 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): 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, @@ -538,7 +600,7 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): related_query_name='interface' ) - clone_fields = ['device', 'parent', 'lag', 'type', 'mgmt_only'] + clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only'] class Meta: ordering = ('device', CollateAsChar('_name')) @@ -550,18 +612,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: @@ -575,13 +647,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: @@ -595,24 +688,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): @@ -626,13 +747,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. """ @@ -686,7 +811,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 308a094c3..418944a4a 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,6 +14,7 @@ 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 @@ -36,7 +36,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. @@ -351,7 +351,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 @@ -391,7 +391,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". @@ -815,7 +815,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 diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index f4d0ce8df..30e11b342 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -10,7 +10,7 @@ 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', @@ -72,7 +72,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. """ diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 47fcd42e4..0bc28acaa 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,6 +14,7 @@ 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 @@ -35,7 +35,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. @@ -373,8 +373,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 @@ -393,6 +393,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) @@ -427,13 +431,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) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index ab9d8e82d..a978e69e6 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -25,7 +25,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, @@ -82,7 +82,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 @@ -278,7 +278,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 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..b7f1576ee 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 @@ -452,24 +488,39 @@ class CableTraceSVG: ) 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/devices.py b/netbox/dcim/tables/devices.py index a2d3f3da2..e2e11dc6e 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -11,11 +11,7 @@ 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', @@ -84,11 +80,16 @@ 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', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions') + fields = ( + 'pk', '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 +112,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', '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 +160,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' @@ -258,11 +255,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 +267,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 +288,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable): model = ConsolePort fields = ( 'pk', '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 +309,7 @@ class DeviceConsolePortTable(ConsolePortTable): model = ConsolePort fields = ( 'pk', '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 = { @@ -335,7 +332,7 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): model = ConsoleServerPort fields = ( 'pk', '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') @@ -357,7 +354,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): model = ConsoleServerPort fields = ( 'pk', '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 = { @@ -380,7 +377,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable): model = PowerPort fields = ( 'pk', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw', - 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', + 'cable', 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -402,7 +399,7 @@ class DevicePowerPortTable(PowerPortTable): model = PowerPort fields = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'connection', 'tags', 'actions', + 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', @@ -431,7 +428,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable): model = PowerOutlet fields = ( 'pk', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'connection', 'tags', + 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -452,7 +449,7 @@ class DevicePowerOutletTable(PowerOutletTable): model = PowerOutlet fields = ( 'pk', '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', @@ -485,6 +482,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' ) @@ -493,23 +498,26 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable model = Interface fields = ( 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', - 'untagged_vlan', 'tagged_vlans', + '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', '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 +532,10 @@ class DeviceInterfaceTable(InterfaceTable): class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', - 'untagged_vlan', 'tagged_vlans', 'actions', + 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', + 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', 'description', + 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', + 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', ) order_by = ('name',) default_columns = ( @@ -562,7 +571,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable): model = FrontPort fields = ( 'pk', '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 +595,10 @@ class DeviceFrontPortTable(FrontPortTable): model = FrontPort fields = ( 'pk', '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 +622,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable): model = RearPort fields = ( 'pk', '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 +644,10 @@ class DeviceRearPortTable(RearPortTable): model = RearPort fields = ( 'pk', '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 diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index b3310d5d2..9631b5709 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', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', + 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'tags', + 'actions', ) diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index b8e032e7f..956282911 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -71,10 +71,10 @@ class PowerFeedTable(CableTerminationTable): model = PowerFeed fields = ( 'pk', '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 fcc3ed4d2..bdc5ae713 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', 'name', 'rack_count', 'color', 'description', 'slug', 'actions') + fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions') default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index ab9399978..43bceff56 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', 'name', 'slug', 'site_count', 'description', 'actions') + fields = ('pk', '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', 'name', 'slug', 'site_count', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'site_count', 'description', 'actions') @@ -119,6 +125,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 @@ -126,5 +135,7 @@ class LocationTable(BaseTable): class Meta(BaseTable.Meta): model = Location - fields = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'actions') + fields = ( + 'pk', '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..f6938807a 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 }} @@ -64,6 +64,12 @@ INTERFACE_TAGGED_VLANS = """ {% endif %} """ +INTERFACE_WIRELESS_LANS = """ +{% for wlan in record.wireless_lans.all %} + {{ wlan }}
+{% endfor %} +""" + POWERFEED_CABLE = """ {{ value }} @@ -195,15 +201,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 +235,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..042b4ce28 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1198,6 +1198,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase '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, }, @@ -1206,6 +1207,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase '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, }, @@ -1214,7 +1217,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase '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, }, 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 c4558b882..eb37f061a 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -9,6 +9,7 @@ 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): @@ -2077,9 +2078,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): 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) @@ -2100,11 +2103,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) @@ -2116,7 +2119,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} @@ -2139,6 +2142,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() @@ -2193,7 +2209,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'} @@ -2209,6 +2225,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() @@ -2881,12 +2917,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, tenant=tenants[0], 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, tenant=tenants[0], 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, tenant=tenants[1], 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, tenant=tenants[1], 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, tenant=tenants[2], 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, tenant=tenants[2], 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): @@ -2906,9 +2942,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): 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 df0cfcf5d..9c446fc8b 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -31,11 +31,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 = ( @@ -65,11 +68,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 = ( @@ -194,12 +200,15 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 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 = ( @@ -226,11 +235,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 = ( @@ -393,10 +405,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 = ( @@ -1059,12 +1074,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 = ( @@ -1094,6 +1112,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', @@ -1101,6 +1121,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 = ( @@ -1585,6 +1606,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) @@ -1600,10 +1622,10 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 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), @@ -1611,6 +1633,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): '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]], 'tags': [t.pk for t in tags], @@ -1621,6 +1644,7 @@ 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), @@ -1643,6 +1667,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): '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]], } @@ -1948,7 +1973,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, @@ -1965,7 +1990,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/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/extras/admin.py b/netbox/extras/admin.py index dae21c2c9..752c8c83d 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,10 +1,128 @@ 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'), + }), + ('NAPALM', { + 'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'), + }), + ('Miscellaneous', { + 'fields': ('MAINTENANCE_MODE', '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..46d295195 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', ] diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 4f350fc9b..7503b4110 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -13,6 +13,7 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_BOOLEAN = 'boolean' TYPE_DATE = 'date' TYPE_URL = 'url' + TYPE_JSON = 'json' TYPE_SELECT = 'select' TYPE_MULTISELECT = 'multiselect' @@ -23,6 +24,7 @@ class CustomFieldTypeChoices(ChoiceSet): (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..6f1b012eb --- /dev/null +++ b/netbox/extras/conditions.py @@ -0,0 +1,144 @@ +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. + """ + value = functools.reduce(dict.get, self.attr.split('.'), data) + 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/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..937814c5a 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -137,7 +137,7 @@ class WebhookBulkEditForm(BootstrapMixin, BulkEditForm): ) class Meta: - nullable_fields = ['secret', 'ca_file_path'] + nullable_fields = ['secret', 'conditions', 'ca_file_path'] class TagBulkEditForm(BootstrapMixin, BulkEditForm): diff --git a/netbox/extras/forms/config.py b/netbox/extras/forms/config.py new file mode 100644 index 000000000..fab6fdbd1 --- /dev/null +++ b/netbox/extras/forms/config.py @@ -0,0 +1,79 @@ +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 = 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..4e01b7bef 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -1,5 +1,6 @@ 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 * @@ -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/models.py b/netbox/extras/forms/models.py index 7e462e62b..23f4872c2 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -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/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/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 8c0193eaa..bc6458039 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -280,6 +280,10 @@ 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: if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT: diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 75f5242d3..57615c0c5 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,10 +35,6 @@ __all__ = ( ) -# -# Webhooks -# - @extras_features('webhooks') class Webhook(ChangeLoggedModel): """ @@ -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', @@ -138,9 +142,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,10 +179,6 @@ class Webhook(ChangeLoggedModel): return json.dumps(context, cls=JSONEncoder) -# -# Custom links -# - @extras_features('webhooks') class CustomLink(ChangeLoggedModel): """ @@ -230,10 +234,6 @@ class CustomLink(ChangeLoggedModel): return reverse('extras:customlink', args=[self.pk]) -# -# Export templates -# - @extras_features('webhooks') class ExportTemplate(ChangeLoggedModel): content_type = models.ForeignKey( @@ -323,10 +323,6 @@ class ExportTemplate(ChangeLoggedModel): return response -# -# Image attachments -# - class ImageAttachment(BigIDModel): """ An uploaded image which is associated with an object. @@ -399,11 +395,6 @@ class ImageAttachment(BigIDModel): return None -# -# Journal entries -# - - @extras_features('webhooks') class JournalEntry(ChangeLoggedModel): """ @@ -453,36 +444,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 +533,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/signals.py b/netbox/extras/signals.py index 4f09706be..9b37dd763 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -8,7 +8,7 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates 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 @@ -161,3 +161,15 @@ def run_custom_validators(sender, instance, **kwargs): validators = settings.CUSTOM_VALIDATORS.get(model_name, []) for validator in validators: 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_conditions.py b/netbox/extras/tests/test_conditions.py new file mode 100644 index 000000000..ee6afeaf6 --- /dev/null +++ b/netbox/extras/tests/test_conditions.py @@ -0,0 +1,199 @@ +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') + + # + # 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 0a40aeba9..0b51a4de3 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -64,6 +64,11 @@ class CustomFieldTest(TestCase): '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) @@ -207,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' @@ -228,6 +238,7 @@ class CustomFieldAPITest(APITestCase): 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() @@ -248,6 +259,7 @@ class CustomFieldAPITest(APITestCase): 'boolean_field': None, 'date_field': None, 'url_field': None, + 'json_field': None, 'choice_field': None, }) @@ -267,6 +279,7 @@ class CustomFieldAPITest(APITestCase): 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): @@ -291,6 +304,7 @@ class CustomFieldAPITest(APITestCase): 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 @@ -301,6 +315,7 @@ class CustomFieldAPITest(APITestCase): 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): @@ -317,6 +332,7 @@ class CustomFieldAPITest(APITestCase): 'boolean_field': True, 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', + 'json_field': '{"foo": 1, "bar": 2}', 'choice_field': 'Bar', }, } @@ -335,6 +351,7 @@ class CustomFieldAPITest(APITestCase): 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 @@ -345,6 +362,7 @@ class CustomFieldAPITest(APITestCase): 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): @@ -383,6 +401,7 @@ class CustomFieldAPITest(APITestCase): 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 @@ -393,6 +412,7 @@ class CustomFieldAPITest(APITestCase): 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): @@ -406,6 +426,7 @@ class CustomFieldAPITest(APITestCase): 'boolean_field': True, 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', + 'json_field': '{"foo": 1, "bar": 2}', 'choice_field': 'Bar', } data = ( @@ -442,6 +463,7 @@ class CustomFieldAPITest(APITestCase): 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 @@ -452,6 +474,7 @@ class CustomFieldAPITest(APITestCase): 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): @@ -481,6 +504,7 @@ class CustomFieldAPITest(APITestCase): 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 @@ -491,6 +515,7 @@ class CustomFieldAPITest(APITestCase): 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): @@ -549,6 +574,7 @@ class CustomFieldImportTest(TestCase): CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN), CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE), CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), + CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON), CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[ 'Choice A', 'Choice B', 'Choice C', ]), @@ -562,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_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_select'), - ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', 'Choice A'), - ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '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) @@ -574,24 +600,26 @@ class CustomFieldImportTest(TestCase): # Validate data for site 1 site1 = Site.objects.get(name='Site 1') - self.assertEqual(len(site1.custom_field_data), 7) + 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), 7) + 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 diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index 1ccc2332b..cf28a46e7 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -32,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/webhooks_worker.py b/netbox/extras/webhooks_worker.py index ce63e14ce..6bbfba907 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -6,6 +6,7 @@ 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') @@ -16,6 +17,12 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user """ Make a POST request to the defined Webhook """ + # Evaluate webhook conditions (if any) + if webhook.conditions: + if not ConditionSet(webhook.conditions).eval(data): + return + + # Prepare context data for headers & body templates context = { 'event': dict(ObjectChangeActionChoices)[event].lower(), 'timestamp': timestamp, @@ -33,14 +40,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 +58,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 +79,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/serializers.py b/netbox/ipam/api/serializers.py index 84502ca51..28ce1575e 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -9,7 +9,6 @@ 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 @@ -86,14 +85,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', ] @@ -117,7 +116,7 @@ class AggregateSerializer(PrimaryModelSerializer): # 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) @@ -125,12 +124,12 @@ 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( @@ -146,8 +145,8 @@ class VLANGroupSerializer(OrganizationalModelSerializer): 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 = [] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 18f2e13ce..e066e0f57 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -60,7 +60,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 @@ -83,7 +83,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 @@ -138,7 +138,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/filtersets.py b/netbox/ipam/filtersets.py index 025f4a9cb..81727edd1 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -122,6 +122,7 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class RIRFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = RIR @@ -218,6 +219,7 @@ class RoleFilterSet(OrganizationalModelFilterSet): method='search', label='Search', ) + tag = TagFilter() class Meta: model = Role @@ -675,6 +677,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 7b7a0fb0d..507d180ca 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -73,7 +73,7 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode ] -class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RIRBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RIR.objects.all(), widget=forms.MultipleHiddenInput @@ -154,7 +154,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB } -class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Role.objects.all(), widget=forms.MultipleHiddenInput @@ -314,7 +314,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB ] -class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VLANGroup.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index ab084311c..5eeba47c1 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -94,10 +94,6 @@ class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelF class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = RIR - field_groups = [ - ['q'], - ['is_private'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), @@ -110,6 +106,7 @@ class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + tag = TagFilterField(model) class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): @@ -168,14 +165,12 @@ class ASNFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFor class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = Role - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): @@ -393,7 +388,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): field_groups = [ - ['q'], + ['q', 'tag'], ['region', 'sitegroup', 'site', 'location', 'rack'] ] model = VLANGroup @@ -432,6 +427,7 @@ class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Rack'), fetch_trigger='open' ) + tag = TagFilterField(model) class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index a0163a13f..aff071e5d 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -84,11 +84,15 @@ class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class RIRForm(BootstrapMixin, 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', ] @@ -146,11 +150,15 @@ class ASNForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class RoleForm(BootstrapMixin, 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', ] @@ -556,15 +564,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 = { 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/models/ip.py b/netbox/ipam/models/ip.py index 110d93cfe..03fdbeae5 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,10 +1,9 @@ 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 @@ -18,6 +17,7 @@ from ipam.fields import IPNetworkField, IPAddressField from ipam.managers import IPAddressManager from ipam.querysets import PrefixQuerySet from ipam.validators import DNSValidator +from netbox.config import get_config from utilities.querysets import RestrictedQuerySet from virtualization.models import VirtualMachine @@ -33,7 +33,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 @@ -217,7 +217,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 @@ -365,7 +365,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({ @@ -860,7 +860,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/vlans.py b/netbox/ipam/models/vlans.py index 4ba8d7041..14eaa7ccc 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -21,7 +21,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. diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index e624f6f13..95376aad6 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -87,11 +87,14 @@ 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', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions') @@ -169,11 +172,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', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'actions') + fields = ('pk', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions') default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions') diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index fd1e92be8..4c0d5d729 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', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions') + fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions') diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 86f11bf3d..23a03604b 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -159,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 = ( @@ -232,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 = ( @@ -439,10 +445,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/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..9f51d475d 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. @@ -189,9 +182,9 @@ class PrimaryModelSerializer(CustomFieldModelSerializer): 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/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..8bf1d6dc5 --- /dev/null +++ b/netbox/netbox/config/parameters.py @@ -0,0 +1,140 @@ +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 + ), + + # 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='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..189e98d11 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -72,19 +72,6 @@ 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 = '' @@ -134,10 +121,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 = [ @@ -175,17 +158,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 +175,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 +187,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/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/middleware.py b/netbox/netbox/middleware.py index a8f989a2a..8d03c6aee 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -11,11 +11,12 @@ 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. """ @@ -114,7 +115,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 +145,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 +160,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 317548921..95cea6a93 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -143,6 +143,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 @@ -166,7 +178,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): 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. """ @@ -175,15 +187,12 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, object_id_field='assigned_object_id', content_type_field='assigned_object_type' ) - tags = TaggableManager( - through='extras.TaggedItem' - ) 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. @@ -225,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 diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 4b1e2a5b5..35cd8ece8 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -176,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', @@ -196,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', @@ -357,6 +372,7 @@ MENUS = [ ORGANIZATION_MENU, DEVICES_MENU, CONNECTIONS_MENU, + WIRELESS_MENU, IPAM_MENU, VIRTUALIZATION_MENU, CIRCUITS_MENU, diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 35e0c6714..45475ef9a 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -11,12 +11,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.9-dev' +VERSION = '3.1-beta1' # Hostname HOSTNAME = platform.node() @@ -68,14 +70,8 @@ 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 @@ -90,30 +86,19 @@ 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 +112,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 +125,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( @@ -326,6 +315,7 @@ INSTALLED_APPS = [ 'users', 'utilities', 'virtualization', + 'wireless', 'django_rq', # Must come after extras to allow overriding management commands 'drf_yasg', ] @@ -345,6 +335,7 @@ MIDDLEWARE = [ 'netbox.middleware.ExceptionHandlingMiddleware', 'netbox.middleware.RemoteUserMiddleware', 'netbox.middleware.LoginRequiredMiddleware', + 'netbox.middleware.DynamicConfigMiddleware', 'netbox.middleware.APIVersionMiddleware', 'netbox.middleware.ObjectChangeMiddleware', 'django_prometheus.middleware.PrometheusAfterMiddleware', @@ -465,7 +456,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 +555,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..4e0a2e2c6 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -48,6 +48,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 +59,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/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..d98f6539c 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..7eefc861f 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..4a4222488 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/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..e962bedfb 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -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..2770a6dc6 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 %} diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index b61dac6fc..22713b592 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -65,7 +65,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:circuit_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/circuits/circuittype.html b/netbox/templates/circuits/circuittype.html index ad81de7e1..57737a6d1 100644 --- a/netbox/templates/circuits/circuittype.html +++ b/netbox/templates/circuits/circuittype.html @@ -28,6 +28,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_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 d353e4f37..c16afa421 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -47,7 +47,7 @@
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:provider_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html index 18a11e115..9641c9934 100644 --- a/netbox/templates/circuits/providernetwork.html +++ b/netbox/templates/circuits/providernetwork.html @@ -38,7 +38,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:providernetwork_list' %} + {% 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 c5d1f7906..00704e6ca 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -64,7 +64,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:cable_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index c340cbc5c..60711eb9d 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -41,7 +41,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 91de60252..f65af3285 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -41,7 +41,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 869ab1ec7..ea0c795c5 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -221,7 +221,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:device_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html index 918b6b022..ff8f90db2 100644 --- a/netbox/templates/dcim/devicebay.html +++ b/netbox/templates/dcim/devicebay.html @@ -33,7 +33,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index 2c2d7fe6f..22385ae27 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/netbox/templates/dcim/devicerole.html @@ -58,6 +58,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 74a3e73d7..21a04e7d0 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -88,7 +88,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:devicetype_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index c6b6cea48..6cc3d482f 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -53,7 +53,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 0715bec58..5851b3aeb 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 @@ -95,6 +105,10 @@ WWN {{ object.wwn|placeholder }} + + Transmit power (dBm) + {{ object.tx_power|placeholder }} + 802.1Q Mode {{ object.get_mode_display|placeholder }} @@ -103,11 +117,11 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% if object.is_connectable %} + {% if not object.is_virtual %}
Connection @@ -211,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
diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 041eab73a..2abe723f6 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -18,10 +18,12 @@ {% render_field form.label %} {% render_field form.type %} {% render_field form.parent %} + {% render_field form.bridge %} {% render_field form.lag %} {% render_field form.mac_address %} {% render_field form.wwn %} {% render_field form.mtu %} + {% render_field form.tx_power %} {% render_field form.description %} {% render_field form.tags %} {% render_field form.enabled %} @@ -29,6 +31,20 @@ {% render_field form.mark_connected %}
+ {% 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
diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index e55d441d4..163d8edb3 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -65,7 +65,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index eeb891daf..434253d43 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -68,6 +68,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index 792a3e127..d43a206c6 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -34,6 +34,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index bbdf809dd..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,7 +64,7 @@ NAPALM Arguments
-
{{ object.napalm_args }}
+
{{ object.napalm_args|render_json }}
{% include 'inc/panels/custom_fields.html' %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index f29a127e3..1824cac19 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -108,7 +108,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:powerfeed_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 1f960e0d5..396ef42a8 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -45,7 +45,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index a99aabf32..021fa1133 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -39,7 +39,7 @@
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:powerpanel_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index 74ad9603b..dfe428c50 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -45,7 +45,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 586d31771..93bd21fd9 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -163,7 +163,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:rack_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% if power_feeds %}
diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 07ca55f7c..1e16af675 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -84,7 +84,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:rackreservation_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/rackrole.html b/netbox/templates/dcim/rackrole.html index 2668905f4..2f4661c9f 100644 --- a/netbox/templates/dcim/rackrole.html +++ b/netbox/templates/dcim/rackrole.html @@ -34,6 +34,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index b60e04882..b3ecce3ad 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -47,7 +47,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index c03b11e7d..7452e594e 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -45,6 +45,7 @@
+ {% 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 260412815..0364dee64 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -100,7 +100,7 @@ {% if object.physical_address %} @@ -119,7 +119,7 @@ {% if object.latitude and object.longitude %} @@ -169,7 +169,6 @@
- {{ object.contact_email }} {% else %} @@ -181,7 +180,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:site_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index dbee2c835..d04330413 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -45,6 +45,7 @@ + {% 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 fd31be60d..8399576f5 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -39,7 +39,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:virtualchassis_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
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/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_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/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/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index eb6e490e7..68665b8d5 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -20,6 +20,8 @@ {% 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/panels/tags.html b/netbox/templates/inc/panels/tags.html index e67098c0f..c309afdf0 100644 --- a/netbox/templates/inc/panels/tags.html +++ b/netbox/templates/inc/panels/tags.html @@ -1,11 +1,14 @@ {% load helpers %} +
-
- Tags -
+
Tags
- {% for tag in tags.all %} {% tag tag url %} {% empty %} - No tags assigned - {% endfor %} + {% 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 202b6e41c..aca89a526 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -65,7 +65,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:aggregate_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index d98544de4..31782bdd7 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -145,7 +145,7 @@
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:ipaddress_list' %} + {% include 'inc/panels/tags.html' %}
diff --git a/netbox/templates/ipam/iprange.html b/netbox/templates/ipam/iprange.html index e3d37a87a..b549ec7c5 100644 --- a/netbox/templates/ipam/iprange.html +++ b/netbox/templates/ipam/iprange.html @@ -82,7 +82,7 @@ {% plugin_left_page object %}
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:prefix_list' %} + {% 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 877ed49e0..eaea4e1ec 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -122,7 +122,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:prefix_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/rir.html b/netbox/templates/ipam/rir.html index 26d5e71da..c2f88c278 100644 --- a/netbox/templates/ipam/rir.html +++ b/netbox/templates/ipam/rir.html @@ -38,6 +38,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/ipam/role.html b/netbox/templates/ipam/role.html index 7fc967047..5579010fa 100644 --- a/netbox/templates/ipam/role.html +++ b/netbox/templates/ipam/role.html @@ -32,6 +32,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html index f615d2d50..71d6f9601 100644 --- a/netbox/templates/ipam/routetarget.html +++ b/netbox/templates/ipam/routetarget.html @@ -30,7 +30,7 @@
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:routetarget_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index 7609a280b..5a47e44f0 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -61,7 +61,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:service_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index e8c514cca..367ae3641 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -83,7 +83,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:vlan_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index 2d31feb22..1c36e92f6 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -54,6 +54,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index b320fe6b8..349fe20d3 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -60,7 +60,7 @@ {% plugin_left_page object %}
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:vrf_list' %} + {% 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..a01d75422 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 %} diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index 8bdf6c030..3c6ada5a0 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -60,7 +60,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='tenancy:tenant_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/tenancy/contactgroup.html b/netbox/templates/tenancy/contactgroup.html index 0eef750eb..efb86af91 100644 --- a/netbox/templates/tenancy/contactgroup.html +++ b/netbox/templates/tenancy/contactgroup.html @@ -45,6 +45,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/tenancy/contactrole.html b/netbox/templates/tenancy/contactrole.html index 4ddde3624..3272728f2 100644 --- a/netbox/templates/tenancy/contactrole.html +++ b/netbox/templates/tenancy/contactrole.html @@ -30,6 +30,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index dc51b48c5..f54fd1425 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -36,7 +36,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='tenancy:tenant_list' %} + {% 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 31a756d9e..75d2c5a27 100644 --- a/netbox/templates/tenancy/tenantgroup.html +++ b/netbox/templates/tenancy/tenantgroup.html @@ -45,6 +45,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 84b8235ad..b7af89bb2 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -61,7 +61,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='virtualization:cluster_list' %} + {% 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 b367d97f7..3979fa0e6 100644 --- a/netbox/templates/virtualization/clustergroup.html +++ b/netbox/templates/virtualization/clustergroup.html @@ -28,6 +28,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/virtualization/clustertype.html b/netbox/templates/virtualization/clustertype.html index e3c050a1b..de5f3c519 100644 --- a/netbox/templates/virtualization/clustertype.html +++ b/netbox/templates/virtualization/clustertype.html @@ -28,6 +28,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 0d9ea4a22..068d7f164 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -90,7 +90,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='virtualization:virtualmachine_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index ef12b63a1..2646686e8 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 }} @@ -70,8 +80,8 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} - {% plugin_right_page object %} + {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index b4d097513..824f2bf24 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -17,6 +17,7 @@ {% render_field form.name %} {% render_field form.enabled %} {% render_field form.parent %} + {% render_field form.bridge %} {% render_field form.mac_address %} {% render_field form.mtu %} {% render_field form.description %} 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/serializers.py b/netbox/tenancy/api/serializers.py index 27a14b350..90c13725c 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import ContentType from rest_framework import serializers from netbox.api import ChoiceField, ContentTypeField -from netbox.api.serializers import NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer +from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer from tenancy.choices import ContactPriorityChoices from tenancy.models import * from .nested_serializers import * @@ -20,8 +20,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', ] @@ -60,18 +60,18 @@ class ContactGroupSerializer(NestedGroupModelSerializer): class Meta: model = ContactGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', - 'contact_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'contact_count', '_depth', ] -class ContactRoleSerializer(OrganizationalModelSerializer): +class ContactRoleSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail') class Meta: model = ContactRole fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 7ce16c143..8c7c33aba 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -30,7 +30,7 @@ class TenantGroupViewSet(CustomFieldModelViewSet): 'group', 'tenant_count', cumulative=True - ) + ).prefetch_related('tags') serializer_class = serializers.TenantGroupSerializer filterset_class = filtersets.TenantGroupFilterSet @@ -64,28 +64,24 @@ class ContactGroupViewSet(CustomFieldModelViewSet): 'group', 'contact_count', cumulative=True - ) + ).prefetch_related('tags') serializer_class = serializers.ContactGroupSerializer filterset_class = filtersets.ContactGroupFilterSet class ContactRoleViewSet(CustomFieldModelViewSet): - queryset = ContactRole.objects.all() + queryset = ContactRole.objects.prefetch_related('tags') serializer_class = serializers.ContactRoleSerializer filterset_class = filtersets.ContactRoleFilterSet class ContactViewSet(CustomFieldModelViewSet): - queryset = Contact.objects.prefetch_related( - 'group', 'tags' - ) + queryset = Contact.objects.prefetch_related('group', 'tags') serializer_class = serializers.ContactSerializer filterset_class = filtersets.ContactFilterSet class ContactAssignmentViewSet(CustomFieldModelViewSet): - queryset = ContactAssignment.objects.prefetch_related( - 'contact', 'role' - ) + queryset = ContactAssignment.objects.prefetch_related('contact', 'role') serializer_class = serializers.ContactAssignmentSerializer filterset_class = filtersets.ContactAssignmentFilterSet diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index f6d0ac72e..dd73edace 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -33,6 +33,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Tenant group (slug)', ) + tag = TagFilter() class Meta: model = TenantGroup @@ -118,6 +119,7 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Contact group (slug)', ) + tag = TagFilter() class Meta: model = ContactGroup @@ -125,6 +127,7 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): class ContactRoleFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = ContactRole diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index a34b8def1..f461fe73c 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -17,7 +17,7 @@ __all__ = ( # Tenants # -class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class TenantGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=TenantGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -55,7 +55,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk # Contacts # -class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ContactGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ContactGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -73,7 +73,7 @@ class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'description'] -class ContactRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ContactRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ContactRole.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 69941701f..b693db68f 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -31,6 +31,7 @@ class TenantGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Parent group'), fetch_trigger='open' ) + tag = TagFilterField(model) class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm): @@ -71,18 +72,17 @@ class ContactGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Parent group'), fetch_trigger='open' ) + tag = TagFilterField(model) class ContactRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = ContactRole - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class ContactFilterForm(BootstrapMixin, CustomFieldModelFilterForm): diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index b15065705..0237e4ef8 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/models.py @@ -28,11 +28,15 @@ class TenantGroupForm(BootstrapMixin, CustomFieldModelForm): 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', ] @@ -68,18 +72,26 @@ class ContactGroupForm(BootstrapMixin, CustomFieldModelForm): required=False ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = ContactGroup - fields = ['parent', 'name', 'slug', 'description'] + fields = ('parent', 'name', 'slug', 'description', 'tags') class ContactRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = ContactRole - fields = ['name', 'slug', 'description'] + fields = ('name', 'slug', 'description', 'tags') class ContactForm(BootstrapMixin, CustomFieldModelForm): 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.py b/netbox/tenancy/models/contacts.py similarity index 66% rename from netbox/tenancy/models.py rename to netbox/tenancy/models/contacts.py index c709236e2..2669aa121 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models/contacts.py @@ -1,117 +1,23 @@ -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +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 MPTTModel, TreeForeignKey +from mptt.models import TreeForeignKey from extras.utils import extras_features from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel +from tenancy.choices import * from utilities.querysets import RestrictedQuerySet -from .choices import * - __all__ = ( 'ContactAssignment', 'Contact', 'ContactGroup', 'ContactRole', - 'Tenant', - 'TenantGroup', ) -# -# Tenants -# - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class TenantGroup(NestedGroupModel): - """ - An arbitrary collection of Tenants. - """ - 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'] - - def get_absolute_url(self): - return reverse('tenancy:tenantgroup', args=[self.pk]) - - @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Tenant(PrimaryModel): - """ - A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal - department. - """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - group = models.ForeignKey( - to='tenancy.TenantGroup', - on_delete=models.SET_NULL, - related_name='tenants', - blank=True, - null=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - comments = models.TextField( - blank=True - ) - - # Generic relations - contacts = GenericRelation( - to='tenancy.ContactAssignment' - ) - - objects = RestrictedQuerySet.as_manager() - - clone_fields = [ - 'group', 'description', - ] - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse('tenancy:tenant', args=[self.pk]) - - -# -# Contacts -# - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class ContactGroup(NestedGroupModel): """ An arbitrary collection of Contacts. @@ -145,7 +51,7 @@ class ContactGroup(NestedGroupModel): return reverse('tenancy:contactgroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactRole(OrganizationalModel): """ Functional role for a Contact assigned to an object. diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py new file mode 100644 index 000000000..7dae2c093 --- /dev/null +++ b/netbox/tenancy/models/tenants.py @@ -0,0 +1,96 @@ +from django.contrib.contenttypes.fields import GenericRelation +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 NestedGroupModel, PrimaryModel +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'Tenant', + 'TenantGroup', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class TenantGroup(NestedGroupModel): + """ + An arbitrary collection of Tenants. + """ + 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'] + + def get_absolute_url(self): + return reverse('tenancy:tenantgroup', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Tenant(PrimaryModel): + """ + A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal + department. + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + group = models.ForeignKey( + to='tenancy.TenantGroup', + on_delete=models.SET_NULL, + related_name='tenants', + blank=True, + null=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) + + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + + objects = RestrictedQuerySet.as_manager() + + clone_fields = [ + 'group', 'description', + ] + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('tenancy:tenant', args=[self.pk]) diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 5b254842b..02c431846 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -55,11 +55,14 @@ 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', 'name', 'tenant_count', 'description', 'slug', 'actions') + fields = ('pk', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions') default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions') @@ -96,11 +99,14 @@ class ContactGroupTable(BaseTable): 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', 'actions') + fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'actions') default_columns = ('pk', 'name', 'contact_count', 'description', 'actions') diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index fb7ff3ce3..dcfcc1652 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -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 = ( @@ -90,10 +93,13 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 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 = ( @@ -120,10 +126,13 @@ class ContactRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 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 = ( diff --git a/netbox/users/views.py b/netbox/users/views.py index afee10eeb..ab17955e3 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -1,6 +1,5 @@ import logging -from django.conf import settings from django.contrib import messages from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash from django.contrib.auth.mixins import LoginRequiredMixin @@ -14,6 +13,7 @@ from django.utils.http import is_safe_url from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import View +from netbox.config import get_config from utilities.forms import ConfirmationForm from .forms import LoginForm, PasswordChangeForm, TokenForm from .models import Token @@ -53,7 +53,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') diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py index e46af4b3e..4cc3ef601 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,17 +66,19 @@ def get_paginate_count(request): Return the lesser of the calculated value and MAX_PAGE_SIZE. """ + config = get_config() + if 'per_page' in request.GET: try: per_page = int(request.GET.get('per_page')) if request.user.is_authenticated: request.user.config.set('pagination.per_page', per_page, commit=True) - return min(per_page, settings.MAX_PAGE_SIZE) + return min(per_page, config.MAX_PAGE_SIZE) except ValueError: pass if request.user.is_authenticated: - per_page = request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT) - return min(per_page, settings.MAX_PAGE_SIZE) + per_page = request.user.config.get('pagination.per_page', config.PAGINATE_COUNT) + return min(per_page, config.MAX_PAGE_SIZE) - return min(settings.PAGINATE_COUNT, settings.MAX_PAGE_SIZE) + return min(config.PAGINATE_COUNT, config.MAX_PAGE_SIZE) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 1695c8257..9b510d9ed 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 @@ -13,6 +14,7 @@ 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.utils import foreground_color @@ -43,7 +45,7 @@ def render_markdown(value): value = strip_tags(value) # Sanitize Markdown links - schemes = '|'.join(settings.ALLOWED_URL_SCHEMES) + schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES) pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)' value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) @@ -146,6 +148,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/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/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 1928960a9..6cdc0e09a 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', ] @@ -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( @@ -120,8 +121,8 @@ class VMInterfaceSerializer(PrimaryModelSerializer): 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', + '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', ] diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 8eebd2120..d07ace3d5 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 diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 3fc1da8ea..dc084a67f 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 @@ -262,6 +264,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..d6c190904 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -23,7 +23,7 @@ __all__ = ( ) -class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ClusterTypeBulkEditForm(BootstrapMixin, 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(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ClusterGroup.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..1e8156c33 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -22,26 +22,22 @@ __all__ = ( class ClusterTypeFilterForm(BootstrapMixin, 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): 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): diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index d66bc9f1f..7fa5b0fa6 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -28,22 +28,30 @@ __all__ = ( class ClusterTypeForm(BootstrapMixin, 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): 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): @@ -269,6 +277,11 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) 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/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 11792944a..db2404546 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,6 +8,7 @@ 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 @@ -30,7 +30,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. @@ -64,7 +64,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. @@ -340,7 +340,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 @@ -378,14 +378,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, @@ -423,6 +415,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({ @@ -430,15 +428,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 b0e922e71..0a605267d 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -17,8 +17,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 +38,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', 'name', 'slug', 'cluster_count', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') @@ -60,11 +61,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', 'name', 'slug', 'cluster_count', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') @@ -130,7 +134,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 +164,6 @@ class VMInterfaceTable(BaseInterfaceTable): name = tables.Column( linkify=True ) - parent = tables.Column( - linkify=True - ) tags = TagColumn( url_name='virtualization:vminterface_list' ) @@ -170,13 +171,19 @@ class VMInterfaceTable(BaseInterfaceTable): class Meta(BaseTable.Meta): model = VMInterface fields = ( - 'pk', 'name', 'virtual_machine', 'enabled', 'parent', 'mac_address', 'mtu', 'mode', 'description', 'tags', + 'pk', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses', '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 +193,8 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): class Meta(BaseTable.Meta): model = VMInterface fields = ( - 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses', - 'untagged_vlan', 'tagged_vlans', 'actions', + 'pk', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', + 'ip_addresses', '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..a74ccc4d9 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -452,6 +452,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/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..654dd843f --- /dev/null +++ b/netbox/wireless/filtersets.py @@ -0,0 +1,103 @@ +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 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', + ) + 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..4de1724f3 --- /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 BootstrapMixin, DynamicModelChoiceField +from wireless.choices import * +from wireless.constants import SSID_MAX_LENGTH +from wireless.models import * + +__all__ = ( + 'WirelessLANBulkEditForm', + 'WirelessLANGroupBulkEditForm', + 'WirelessLinkBulkEditForm', +) + + +class WirelessLANGroupBulkEditForm(BootstrapMixin, 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(BootstrapMixin, 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(BootstrapMixin, 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..b7eeec76b --- /dev/null +++ b/netbox/wireless/forms/filtersets.py @@ -0,0 +1,102 @@ +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, BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField, +) +from wireless.choices import * +from wireless.models import * + +__all__ = ( + 'WirelessLANFilterForm', + 'WirelessLANGroupFilterForm', + 'WirelessLinkFilterForm', +) + + +class WirelessLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = WirelessLANGroup + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + parent_id = DynamicModelMultipleChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + label=_('Parent group'), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = WirelessLAN + field_groups = [ + ('q', 'tag'), + ('group_id',), + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + 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(BootstrapMixin, CustomFieldModelFilterForm): + model = WirelessLink + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + 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..f7985a31d --- /dev/null +++ b/netbox/wireless/forms/models.py @@ -0,0 +1,166 @@ +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 ( + BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect, +) +from wireless.models import * + +__all__ = ( + 'WirelessLANForm', + 'WirelessLANGroupForm', + 'WirelessLinkForm', +) + + +class WirelessLANGroupForm(BootstrapMixin, 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(BootstrapMixin, 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(BootstrapMixin, 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..26f1e440b --- /dev/null +++ b/netbox/wireless/migrations/0001_wireless.py @@ -0,0 +1,80 @@ +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)), + ('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)), + ('_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/0002_wireless_auth.py b/netbox/wireless/migrations/0002_wireless_auth.py new file mode 100644 index 000000000..9ca4e351c --- /dev/null +++ b/netbox/wireless/migrations/0002_wireless_auth.py @@ -0,0 +1,41 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0001_wireless'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslan', + name='auth_cipher', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='wirelesslan', + name='auth_psk', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='wirelesslan', + name='auth_type', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='wirelesslink', + name='auth_cipher', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='wirelesslink', + name='auth_psk', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='wirelesslink', + name='auth_type', + field=models.CharField(blank=True, max_length=50), + ), + ] 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..45a7881b7 --- /dev/null +++ b/netbox/wireless/models.py @@ -0,0 +1,209 @@ +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 utilities.querysets import RestrictedQuerySet +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 + ) + + objects = RestrictedQuerySet.as_manager() + + 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 + ) + + objects = RestrictedQuerySet.as_manager() + + 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