Merge branch 'feature' of https://github.com/netbox-community/netbox into 6732-asn-model

This commit is contained in:
Daniel Sheppard 2021-10-28 11:55:20 -05:00
commit 033db83068
255 changed files with 5911 additions and 1050 deletions

View File

@ -29,7 +29,7 @@ GET /api/dcim/devices/1/napalm/?method=get_environment
## Authentication ## 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" \ $ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \

View File

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

View File

@ -1,18 +1,21 @@
# NetBox Configuration # 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 ## Configuration Parameters
* [Required settings](required-settings.md) * [Required settings](required-settings.md)
* [Optional settings](optional-settings.md) * [Optional settings](optional-settings.md)
* [Dynamic settings](dynamic-settings.md)
## Changing the Configuration ## 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 ```no-highlight
$ sudo systemctl restart netbox $ sudo systemctl restart netbox
``` ```
Configuration parameters which are set via the admin UI (those listed under "dynamic settings") take effect immediately.

View File

@ -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 ## BASE_PATH
Default: None 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 ## EXEMPT_VIEW_PERMISSIONS
Default: Empty list 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 ## MEDIA_ROOT
Default: $INSTALL_ROOT/netbox/media/ 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 ## PLUGINS
Default: Empty 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 ## REMOTE_AUTH_AUTO_CREATE_USER
Default: `False` Default: `False`

View File

@ -0,0 +1,8 @@
# Wireless Networks
{!models/wireless/wirelesslan.md!}
{!models/wireless/wirelesslangroup.md!}
---
{!models/wireless/wirelesslink.md!}

View File

@ -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 | | Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting |
| ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | | ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- |
| Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | |
| Organizational | :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: | | 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 | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | |
| Component Template | :material-check: | :material-check: | :material-check: | | | | | | Component Template | :material-check: | :material-check: | :material-check: | | | | |

View File

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

View File

@ -16,6 +16,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net
* Boolean: True or false * Boolean: True or false
* Date: A date in ISO 8601 format (YYYY-MM-DD) * Date: A date in ISO 8601 format (YYYY-MM-DD)
* URL: This will be presented as a link in the web UI * 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 * Selection: A selection of one of several pre-defined custom choices
* Multiple selection: A selection field which supports the assignment of multiple values * Multiple selection: A selection field which supports the assignment of multiple values

View File

@ -15,6 +15,3 @@ The `tag` filter can be specified multiple times to match only objects which hav
```no-highlight ```no-highlight
GET /api/dcim/devices/?tag=monitored&tag=deprecated 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.

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
}
]
}
```

View File

@ -6,6 +6,9 @@
### Breaking Changes ### 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 `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)) #### 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. 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 ### Enhancements
* [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 ### 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-groups/`
* `/api/tenancy/contact-roles/` * `/api/tenancy/contact-roles/`
* `/api/tenancy/contacts/` * `/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 * dcim.Cable
* Added `tenant` field * Added `tenant` field
* dcim.Device * dcim.Device
@ -44,6 +110,18 @@ When assigning a contact to an object, the user must select a predefined role (e
* dcim.DeviceType * dcim.DeviceType
* Added `airflow` field * Added `airflow` field
* dcim.Interface * 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 * Added `wwn` field
* `cable_peer` has been renamed to `link_peer`
* `cable_peer_type` has been renamed to `link_peer_type`
* dcim.Location * dcim.Location
* Added `tenant` field * Added `tenant` field
* extras.Webhook
* Added the `conditions` field
* virtualization.VMInterface
* Added `bridge` field

View File

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

View File

@ -51,6 +51,7 @@ nav:
- Configuring NetBox: 'configuration/index.md' - Configuring NetBox: 'configuration/index.md'
- Required Settings: 'configuration/required-settings.md' - Required Settings: 'configuration/required-settings.md'
- Optional Settings: 'configuration/optional-settings.md' - Optional Settings: 'configuration/optional-settings.md'
- Dynamic Settings: 'configuration/dynamic-settings.md'
- Core Functionality: - Core Functionality:
- IP Address Management: 'core-functionality/ipam.md' - IP Address Management: 'core-functionality/ipam.md'
- VLAN Management: 'core-functionality/vlans.md' - VLAN Management: 'core-functionality/vlans.md'
@ -60,6 +61,7 @@ nav:
- Virtualization: 'core-functionality/virtualization.md' - Virtualization: 'core-functionality/virtualization.md'
- Service Mapping: 'core-functionality/services.md' - Service Mapping: 'core-functionality/services.md'
- Circuits: 'core-functionality/circuits.md' - Circuits: 'core-functionality/circuits.md'
- Wireless: 'core-functionality/wireless.md'
- Power Tracking: 'core-functionality/power.md' - Power Tracking: 'core-functionality/power.md'
- Tenancy: 'core-functionality/tenancy.md' - Tenancy: 'core-functionality/tenancy.md'
- Contacts: 'core-functionality/contacts.md' - Contacts: 'core-functionality/contacts.md'
@ -92,6 +94,8 @@ nav:
- Authentication: 'rest-api/authentication.md' - Authentication: 'rest-api/authentication.md'
- GraphQL API: - GraphQL API:
- Overview: 'graphql-api/overview.md' - Overview: 'graphql-api/overview.md'
- Reference:
- Conditions: 'reference/conditions.md'
- Development: - Development:
- Introduction: 'development/index.md' - Introduction: 'development/index.md'
- Getting Started: 'development/getting-started.md' - Getting Started: 'development/getting-started.md'

View File

@ -3,11 +3,9 @@ from rest_framework import serializers
from circuits.choices import CircuitStatusChoices from circuits.choices import CircuitStatusChoices
from circuits.models import * from circuits.models import *
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.serializers import CableTerminationSerializer from dcim.api.serializers import LinkTerminationSerializer
from netbox.api import ChoiceField from netbox.api import ChoiceField
from netbox.api.serializers import ( from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
)
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from .nested_serializers import * from .nested_serializers import *
@ -48,14 +46,14 @@ class ProviderNetworkSerializer(PrimaryModelSerializer):
# Circuits # Circuits
# #
class CircuitTypeSerializer(OrganizationalModelSerializer): class CircuitTypeSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
circuit_count = serializers.IntegerField(read_only=True) circuit_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = [ 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', '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') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer() circuit = NestedCircuitSerializer()
site = NestedSiteSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True)
@ -101,6 +99,6 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSer
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', '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', '_occupied',
] ]

View File

@ -34,7 +34,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
# #
class CircuitTypeViewSet(CustomFieldModelViewSet): class CircuitTypeViewSet(CustomFieldModelViewSet):
queryset = CircuitType.objects.annotate( queryset = CircuitType.objects.prefetch_related('tags').annotate(
circuit_count=count_related(Circuit, 'type') circuit_count=count_related(Circuit, 'type')
) )
serializer_class = serializers.CircuitTypeSerializer serializer_class = serializers.CircuitTypeSerializer

View File

@ -111,6 +111,7 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet):
class CircuitTypeFilterSet(OrganizationalModelFilterSet): class CircuitTypeFilterSet(OrganizationalModelFilterSet):
tag = TagFilter()
class Meta: class Meta:
model = CircuitType model = CircuitType

View File

@ -79,7 +79,7 @@ class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
] ]
class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class CircuitTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput

View File

@ -79,14 +79,12 @@ class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = CircuitType model = CircuitType
field_groups = [
['q'],
]
q = forms.CharField( q = forms.CharField(
required=False, required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search') label=_('Search')
) )
tag = TagFilterField(model)
class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):

View File

@ -75,11 +75,15 @@ class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm): class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = [ fields = [
'name', 'slug', 'description', 'name', 'slug', 'description', 'tags',
] ]

View File

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

View File

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

View File

@ -4,7 +4,7 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from dcim.fields import ASNField from dcim.fields import ASNField
from dcim.models import CableTermination, PathEndpoint from dcim.models import LinkTermination, PathEndpoint
from extras.models import ObjectChange from extras.models import ObjectChange
from extras.utils import extras_features from extras.utils import extras_features
from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel
@ -128,7 +128,7 @@ class ProviderNetwork(PrimaryModel):
return reverse('circuits:providernetwork', args=[self.pk]) 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): class CircuitType(OrganizationalModel):
""" """
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named 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') @extras_features('webhooks')
class CircuitTermination(ChangeLoggedModel, CableTermination): class CircuitTermination(ChangeLoggedModel, LinkTermination):
circuit = models.ForeignKey( circuit = models.ForeignKey(
to='circuits.Circuit', to='circuits.Circuit',
on_delete=models.CASCADE, on_delete=models.CASCADE,

View File

@ -82,6 +82,9 @@ class CircuitTypeTable(BaseTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
tags = TagColumn(
url_name='circuits:circuittype_list'
)
circuit_count = tables.Column( circuit_count = tables.Column(
verbose_name='Circuits' verbose_name='Circuits'
) )
@ -89,7 +92,7 @@ class CircuitTypeTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = CircuitType 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') default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')

View File

@ -64,10 +64,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
CircuitType(name='Circuit Type 3', slug='circuit-type-3'), CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Circuit Type X', 'name': 'Circuit Type X',
'slug': 'circuit-type-x', 'slug': 'circuit-type-x',
'description': 'A new circuit type', 'description': 'A new circuit type',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (

View File

@ -11,35 +11,37 @@ from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSer
from ipam.models import VLAN from ipam.models import VLAN
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import ( from netbox.api.serializers import (
NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
WritableNestedSerializer,
) )
from netbox.config import ConfigItem
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from users.api.nested_serializers import NestedUserSerializer from users.api.nested_serializers import NestedUserSerializer
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from virtualization.api.nested_serializers import NestedClusterSerializer from virtualization.api.nested_serializers import NestedClusterSerializer
from wireless.api.nested_serializers import NestedWirelessLinkSerializer
from wireless.choices import *
from .nested_serializers import * from .nested_serializers import *
class CableTerminationSerializer(serializers.ModelSerializer): class LinkTerminationSerializer(serializers.ModelSerializer):
cable_peer_type = serializers.SerializerMethodField(read_only=True) link_peer_type = serializers.SerializerMethodField(read_only=True)
cable_peer = serializers.SerializerMethodField(read_only=True) link_peer = serializers.SerializerMethodField(read_only=True)
_occupied = serializers.SerializerMethodField(read_only=True) _occupied = serializers.SerializerMethodField(read_only=True)
def get_cable_peer_type(self, obj): def get_link_peer_type(self, obj):
if obj._cable_peer is not None: if obj._link_peer is not None:
return f'{obj._cable_peer._meta.app_label}.{obj._cable_peer._meta.model_name}' return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}'
return None return None
@swagger_serializer_method(serializer_or_field=serializers.DictField) @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: if obj._link_peer is not None:
serializer = get_serializer_for_model(obj._cable_peer, prefix='Nested') serializer = get_serializer_for_model(obj._link_peer, prefix='Nested')
context = {'request': self.context['request']} context = {'request': self.context['request']}
return serializer(obj._cable_peer, context=context).data return serializer(obj._link_peer, context=context).data
return None return None
@swagger_serializer_method(serializer_or_field=serializers.BooleanField) @swagger_serializer_method(serializer_or_field=serializers.BooleanField)
@ -87,8 +89,8 @@ class RegionSerializer(NestedGroupModelSerializer):
class Meta: class Meta:
model = Region model = Region
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
'site_count', '_depth', 'last_updated', 'site_count', '_depth',
] ]
@ -100,8 +102,8 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
class Meta: class Meta:
model = SiteGroup model = SiteGroup
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
'site_count', '_depth', 'last_updated', 'site_count', '_depth',
] ]
@ -147,20 +149,20 @@ class LocationSerializer(NestedGroupModelSerializer):
class Meta: class Meta:
model = Location model = Location
fields = [ 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', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
] ]
class RackRoleSerializer(OrganizationalModelSerializer): class RackRoleSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
rack_count = serializers.IntegerField(read_only=True) rack_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = RackRole model = RackRole
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated', 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'rack_count', 'last_updated', 'rack_count',
] ]
@ -231,10 +233,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
default=RackElevationDetailRenderChoices.RENDER_JSON default=RackElevationDetailRenderChoices.RENDER_JSON
) )
unit_width = serializers.IntegerField( unit_width = serializers.IntegerField(
default=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_WIDTH')
) )
unit_height = serializers.IntegerField( unit_height = serializers.IntegerField(
default=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
) )
legend_width = serializers.IntegerField( legend_width = serializers.IntegerField(
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
@ -257,7 +259,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
# Device types # Device types
# #
class ManufacturerSerializer(OrganizationalModelSerializer): class ManufacturerSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
devicetype_count = serializers.IntegerField(read_only=True) devicetype_count = serializers.IntegerField(read_only=True)
inventoryitem_count = serializers.IntegerField(read_only=True) inventoryitem_count = serializers.IntegerField(read_only=True)
@ -266,7 +268,7 @@ class ManufacturerSerializer(OrganizationalModelSerializer):
class Meta: class Meta:
model = Manufacturer model = Manufacturer
fields = [ 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', 'devicetype_count', 'inventoryitem_count', 'platform_count',
] ]
@ -414,7 +416,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
# Devices # Devices
# #
class DeviceRoleSerializer(OrganizationalModelSerializer): class DeviceRoleSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True)
@ -422,12 +424,12 @@ class DeviceRoleSerializer(OrganizationalModelSerializer):
class Meta: class Meta:
model = DeviceRole model = DeviceRole
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'tags', 'custom_fields',
'last_updated', 'device_count', 'virtualmachine_count', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
] ]
class PlatformSerializer(OrganizationalModelSerializer): class PlatformSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
@ -437,7 +439,7 @@ class PlatformSerializer(OrganizationalModelSerializer):
model = Platform model = Platform
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', '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 # Device components
# #
class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
@ -526,12 +528,12 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerial
model = ConsoleServerPort model = ConsoleServerPort
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', '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', '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') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
@ -550,12 +552,12 @@ class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer,
model = ConsolePort model = ConsolePort
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', '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', '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') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
@ -579,12 +581,12 @@ class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer,
model = PowerOutlet model = PowerOutlet
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', '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', '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') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
@ -598,18 +600,21 @@ class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
model = PowerPort model = PowerPort
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', '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', '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') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=InterfaceTypeChoices) type = ChoiceField(choices=InterfaceTypeChoices)
parent = NestedInterfaceSerializer(required=False, allow_null=True) parent = NestedInterfaceSerializer(required=False, allow_null=True)
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
lag = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) 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) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField( tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
@ -618,14 +623,16 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
many=True many=True
) )
cable = NestedCableSerializer(read_only=True) cable = NestedCableSerializer(read_only=True)
wireless_link = NestedWirelessLinkSerializer(read_only=True)
count_ipaddresses = serializers.IntegerField(read_only=True) count_ipaddresses = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Interface model = Interface
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu',
'wwn', 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', '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', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
'_occupied', '_occupied',
] ]
@ -644,7 +651,7 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
return super().validate(data) return super().validate(data)
class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): class RearPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices) type = ChoiceField(choices=PortTypeChoices)
@ -654,7 +661,7 @@ class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
model = RearPort model = RearPort
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'positions', 'description', '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', 'last_updated', '_occupied',
] ]
@ -670,7 +677,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name', 'label'] fields = ['id', 'url', 'display', 'name', 'label']
class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices) type = ChoiceField(choices=PortTypeChoices)
@ -681,7 +688,7 @@ class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
model = FrontPort model = FrontPort
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', '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', 'created', 'last_updated', '_occupied',
] ]
@ -732,7 +739,7 @@ class CableSerializer(PrimaryModelSerializer):
) )
termination_a = serializers.SerializerMethodField(read_only=True) termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = 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) tenant = NestedTenantSerializer(required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) 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'] 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') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
power_panel = NestedPowerPanelSerializer() power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer( rack = NestedRackSerializer(
@ -887,7 +894,7 @@ class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
model = PowerFeed model = PowerFeed
fields = [ fields = [
'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', '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', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied', 'created', 'last_updated', '_occupied',
] ]

View File

@ -1,7 +1,6 @@
import socket import socket
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings
from django.http import Http404, HttpResponse, HttpResponseForbidden from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_yasg import openapi from drf_yasg import openapi
@ -21,6 +20,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet from netbox.api.views import ModelViewSet
from netbox.config import get_config
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.utils import count_related, decode_dict from utilities.utils import count_related, decode_dict
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
@ -110,7 +110,7 @@ class RegionViewSet(CustomFieldModelViewSet):
'region', 'region',
'site_count', 'site_count',
cumulative=True cumulative=True
) ).prefetch_related('tags')
serializer_class = serializers.RegionSerializer serializer_class = serializers.RegionSerializer
filterset_class = filtersets.RegionFilterSet filterset_class = filtersets.RegionFilterSet
@ -126,7 +126,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet):
'group', 'group',
'site_count', 'site_count',
cumulative=True cumulative=True
) ).prefetch_related('tags')
serializer_class = serializers.SiteGroupSerializer serializer_class = serializers.SiteGroupSerializer
filterset_class = filtersets.SiteGroupFilterSet filterset_class = filtersets.SiteGroupFilterSet
@ -168,7 +168,7 @@ class LocationViewSet(CustomFieldModelViewSet):
'location', 'location',
'rack_count', 'rack_count',
cumulative=True cumulative=True
).prefetch_related('site') ).prefetch_related('site', 'tags')
serializer_class = serializers.LocationSerializer serializer_class = serializers.LocationSerializer
filterset_class = filtersets.LocationFilterSet filterset_class = filtersets.LocationFilterSet
@ -178,7 +178,7 @@ class LocationViewSet(CustomFieldModelViewSet):
# #
class RackRoleViewSet(CustomFieldModelViewSet): class RackRoleViewSet(CustomFieldModelViewSet):
queryset = RackRole.objects.annotate( queryset = RackRole.objects.prefetch_related('tags').annotate(
rack_count=count_related(Rack, 'role') rack_count=count_related(Rack, 'role')
) )
serializer_class = serializers.RackRoleSerializer serializer_class = serializers.RackRoleSerializer
@ -262,7 +262,7 @@ class RackReservationViewSet(ModelViewSet):
# #
class ManufacturerViewSet(CustomFieldModelViewSet): class ManufacturerViewSet(CustomFieldModelViewSet):
queryset = Manufacturer.objects.annotate( queryset = Manufacturer.objects.prefetch_related('tags').annotate(
devicetype_count=count_related(DeviceType, 'manufacturer'), devicetype_count=count_related(DeviceType, 'manufacturer'),
inventoryitem_count=count_related(InventoryItem, 'manufacturer'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer') platform_count=count_related(Platform, 'manufacturer')
@ -341,7 +341,7 @@ class DeviceBayTemplateViewSet(ModelViewSet):
# #
class DeviceRoleViewSet(CustomFieldModelViewSet): class DeviceRoleViewSet(CustomFieldModelViewSet):
queryset = DeviceRole.objects.annotate( queryset = DeviceRole.objects.prefetch_related('tags').annotate(
device_count=count_related(Device, 'device_role'), device_count=count_related(Device, 'device_role'),
virtualmachine_count=count_related(VirtualMachine, 'role') virtualmachine_count=count_related(VirtualMachine, 'role')
) )
@ -354,7 +354,7 @@ class DeviceRoleViewSet(CustomFieldModelViewSet):
# #
class PlatformViewSet(CustomFieldModelViewSet): class PlatformViewSet(CustomFieldModelViewSet):
queryset = Platform.objects.annotate( queryset = Platform.objects.prefetch_related('tags').annotate(
device_count=count_related(Device, 'platform'), device_count=count_related(Device, 'platform'),
virtualmachine_count=count_related(VirtualMachine, 'platform') virtualmachine_count=count_related(VirtualMachine, 'platform')
) )
@ -458,9 +458,12 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
napalm_methods = request.GET.getlist('method') napalm_methods = request.GET.getlist('method')
response = OrderedDict([(m, None) for m in napalm_methods]) response = OrderedDict([(m, None) for m in napalm_methods])
username = settings.NAPALM_USERNAME
password = settings.NAPALM_PASSWORD config = get_config()
optional_args = settings.NAPALM_ARGS.copy() 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: if device.platform.napalm_args is not None:
optional_args.update(device.platform.napalm_args) optional_args.update(device.platform.napalm_args)
@ -482,7 +485,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
hostname=host, hostname=host,
username=username, username=username,
password=password, password=password,
timeout=settings.NAPALM_TIMEOUT, timeout=timeout,
optional_args=optional_args optional_args=optional_args
) )
try: try:
@ -514,7 +517,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
# #
class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): 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 serializer_class = serializers.ConsolePortSerializer
filterset_class = filtersets.ConsolePortFilterSet filterset_class = filtersets.ConsolePortFilterSet
brief_prefetch_fields = ['device'] brief_prefetch_fields = ['device']
@ -522,7 +525,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
queryset = ConsoleServerPort.objects.prefetch_related( queryset = ConsoleServerPort.objects.prefetch_related(
'device', '_path__destination', 'cable', '_cable_peer', 'tags' 'device', '_path__destination', 'cable', '_link_peer', 'tags'
) )
serializer_class = serializers.ConsoleServerPortSerializer serializer_class = serializers.ConsoleServerPortSerializer
filterset_class = filtersets.ConsoleServerPortFilterSet filterset_class = filtersets.ConsoleServerPortFilterSet
@ -530,14 +533,14 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
class PowerPortViewSet(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 serializer_class = serializers.PowerPortSerializer
filterset_class = filtersets.PowerPortFilterSet filterset_class = filtersets.PowerPortFilterSet
brief_prefetch_fields = ['device'] brief_prefetch_fields = ['device']
class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): 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 serializer_class = serializers.PowerOutletSerializer
filterset_class = filtersets.PowerOutletFilterSet filterset_class = filtersets.PowerOutletFilterSet
brief_prefetch_fields = ['device'] brief_prefetch_fields = ['device']
@ -545,7 +548,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
class InterfaceViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
queryset = Interface.objects.prefetch_related( 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 serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet filterset_class = filtersets.InterfaceFilterSet
@ -626,7 +629,7 @@ class PowerPanelViewSet(ModelViewSet):
class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet): class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet):
queryset = PowerFeed.objects.prefetch_related( 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 serializer_class = serializers.PowerFeedSerializer
filterset_class = filtersets.PowerFeedFilterSet filterset_class = filtersets.PowerFeedFilterSet

View File

@ -720,6 +720,7 @@ class InterfaceTypeChoices(ChoiceSet):
# Virtual # Virtual
TYPE_VIRTUAL = 'virtual' TYPE_VIRTUAL = 'virtual'
TYPE_BRIDGE = 'bridge'
TYPE_LAG = 'lag' TYPE_LAG = 'lag'
# Ethernet # Ethernet
@ -820,6 +821,7 @@ class InterfaceTypeChoices(ChoiceSet):
'Virtual interfaces', 'Virtual interfaces',
( (
(TYPE_VIRTUAL, 'Virtual'), (TYPE_VIRTUAL, 'Virtual'),
(TYPE_BRIDGE, 'Bridge'),
(TYPE_LAG, 'Link Aggregation Group (LAG)'), (TYPE_LAG, 'Link Aggregation Group (LAG)'),
), ),
), ),
@ -1061,7 +1063,7 @@ class PortTypeChoices(ChoiceSet):
# #
# Cables # Cables/links
# #
class CableTypeChoices(ChoiceSet): class CableTypeChoices(ChoiceSet):
@ -1125,7 +1127,7 @@ class CableTypeChoices(ChoiceSet):
) )
class CableStatusChoices(ChoiceSet): class LinkStatusChoices(ChoiceSet):
STATUS_CONNECTED = 'connected' STATUS_CONNECTED = 'connected'
STATUS_PLANNED = 'planned' STATUS_PLANNED = 'planned'

View File

@ -34,6 +34,7 @@ INTERFACE_MTU_MAX = 65536
VIRTUAL_IFACE_TYPES = [ VIRTUAL_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_VIRTUAL,
InterfaceTypeChoices.TYPE_LAG, InterfaceTypeChoices.TYPE_LAG,
InterfaceTypeChoices.TYPE_BRIDGE,
] ]
WIRELESS_IFACE_TYPES = [ WIRELESS_IFACE_TYPES = [
@ -42,6 +43,7 @@ WIRELESS_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_80211N, InterfaceTypeChoices.TYPE_80211N,
InterfaceTypeChoices.TYPE_80211AC, InterfaceTypeChoices.TYPE_80211AC,
InterfaceTypeChoices.TYPE_80211AD, InterfaceTypeChoices.TYPE_80211AD,
InterfaceTypeChoices.TYPE_80211AX,
] ]
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES

View File

@ -15,6 +15,7 @@ from utilities.filters import (
TreeNodeMultipleChoiceFilter, TreeNodeMultipleChoiceFilter,
) )
from virtualization.models import Cluster from virtualization.models import Cluster
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
from .choices import * from .choices import *
from .constants import * from .constants import *
from .models import * from .models import *
@ -72,6 +73,7 @@ class RegionFilterSet(OrganizationalModelFilterSet):
to_field_name='slug', to_field_name='slug',
label='Parent region (slug)', label='Parent region (slug)',
) )
tag = TagFilter()
class Meta: class Meta:
model = Region model = Region
@ -89,6 +91,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet):
to_field_name='slug', to_field_name='slug',
label='Parent site group (slug)', label='Parent site group (slug)',
) )
tag = TagFilter()
class Meta: class Meta:
model = SiteGroup model = SiteGroup
@ -220,6 +223,7 @@ class LocationFilterSet(OrganizationalModelFilterSet):
to_field_name='slug', to_field_name='slug',
label='Location (slug)', label='Location (slug)',
) )
tag = TagFilter()
class Meta: class Meta:
model = Location model = Location
@ -235,6 +239,7 @@ class LocationFilterSet(OrganizationalModelFilterSet):
class RackRoleFilterSet(OrganizationalModelFilterSet): class RackRoleFilterSet(OrganizationalModelFilterSet):
tag = TagFilter()
class Meta: class Meta:
model = RackRole model = RackRole
@ -400,6 +405,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class ManufacturerFilterSet(OrganizationalModelFilterSet): class ManufacturerFilterSet(OrganizationalModelFilterSet):
tag = TagFilter()
class Meta: class Meta:
model = Manufacturer model = Manufacturer
@ -582,6 +588,7 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
class DeviceRoleFilterSet(OrganizationalModelFilterSet): class DeviceRoleFilterSet(OrganizationalModelFilterSet):
tag = TagFilter()
class Meta: class Meta:
model = DeviceRole model = DeviceRole
@ -600,6 +607,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
to_field_name='slug', to_field_name='slug',
label='Manufacturer (slug)', label='Manufacturer (slug)',
) )
tag = TagFilter()
class Meta: class Meta:
model = Platform model = Platform
@ -980,6 +988,11 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
label='Parent interface (ID)', 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( lag_id = django_filters.ModelMultipleChoiceFilter(
field_name='lag', field_name='lag',
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
@ -1000,10 +1013,19 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT
choices=InterfaceTypeChoices, choices=InterfaceTypeChoices,
null_value=None null_value=None
) )
rf_role = django_filters.MultipleChoiceFilter(
choices=WirelessRoleChoices
)
rf_channel = django_filters.MultipleChoiceFilter(
choices=WirelessChannelChoices
)
class Meta: class Meta:
model = Interface 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): def filter_device(self, queryset, name, value):
try: try:
@ -1215,7 +1237,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
choices=CableTypeChoices choices=CableTypeChoices
) )
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=CableStatusChoices choices=LinkStatusChoices
) )
color = django_filters.MultipleChoiceFilter( color = django_filters.MultipleChoiceFilter(
choices=ColorChoices choices=ColorChoices

View File

@ -51,7 +51,7 @@ __all__ = (
) )
class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class RegionBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -69,7 +69,7 @@ class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['parent', 'description'] nullable_fields = ['parent', 'description']
class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class SiteGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -131,7 +131,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
] ]
class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class LocationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Location.objects.all(), queryset=Location.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -160,7 +160,7 @@ class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['parent', 'tenant', 'description'] nullable_fields = ['parent', 'tenant', 'description']
class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class RackRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=RackRole.objects.all(), queryset=RackRole.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -302,7 +302,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
nullable_fields = [] nullable_fields = []
class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class ManufacturerBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -344,7 +344,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel
nullable_fields = ['airflow'] nullable_fields = ['airflow']
class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class DeviceRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -366,7 +366,7 @@ class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['color', 'description'] nullable_fields = ['color', 'description']
class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class PlatformBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -462,7 +462,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
widget=StaticSelect() widget=StaticSelect()
) )
status = forms.ChoiceField( status = forms.ChoiceField(
choices=add_blank_choice(CableStatusChoices), choices=add_blank_choice(LinkStatusChoices),
required=False, required=False,
widget=StaticSelect(), widget=StaticSelect(),
initial='' initial=''
@ -938,8 +938,8 @@ class PowerOutletBulkEditForm(
class InterfaceBulkEditForm( class InterfaceBulkEditForm(
form_from_model(Interface, [ form_from_model(Interface, [
'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'label', 'type', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
'mode', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
]), ]),
BootstrapMixin, BootstrapMixin,
AddRemoveTagsForm, AddRemoveTagsForm,
@ -963,6 +963,10 @@ class InterfaceBulkEditForm(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False required=False
) )
bridge = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False
)
lag = DynamicModelChoiceField( lag = DynamicModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
@ -990,8 +994,8 @@ class InterfaceBulkEditForm(
class Meta: class Meta:
nullable_fields = [ nullable_fields = [
'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'untagged_vlan', 'label', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel',
'tagged_vlans', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -999,8 +1003,9 @@ class InterfaceBulkEditForm(
if 'device' in self.initial: if 'device' in self.initial:
device = Device.objects.filter(pk=self.initial['device']).first() 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['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) self.fields['lag'].widget.add_query_param('device_id', device.pk)
# Limit VLAN choices by device # Limit VLAN choices by device
@ -1028,6 +1033,8 @@ class InterfaceBulkEditForm(
self.fields['parent'].choices = () self.fields['parent'].choices = ()
self.fields['parent'].widget.attrs['disabled'] = True 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'].choices = ()
self.fields['lag'].widget.attrs['disabled'] = True self.fields['lag'].widget.attrs['disabled'] = True

View File

@ -11,6 +11,7 @@ from extras.forms import CustomFieldModelCSVForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
from virtualization.models import Cluster from virtualization.models import Cluster
from wireless.choices import WirelessRoleChoices
__all__ = ( __all__ = (
'CableCSVForm', 'CableCSVForm',
@ -569,6 +570,12 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
to_field_name='name', to_field_name='name',
help_text='Parent interface' help_text='Parent interface'
) )
bridge = CSVModelChoiceField(
queryset=Interface.objects.all(),
required=False,
to_field_name='name',
help_text='Bridged interface'
)
lag = CSVModelChoiceField( lag = CSVModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
@ -584,42 +591,20 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
required=False, required=False,
help_text='IEEE 802.1Q operational mode (for L2 interfaces)' 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: class Meta:
model = Interface model = Interface
fields = ( fields = (
'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn', 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address',
'mtu', 'mgmt_only', 'description', 'mode', '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): def clean_enabled(self):
# Make sure enabled is True when it's not included in the uploaded data # Make sure enabled is True when it's not included in the uploaded data
if 'enabled' not in self.data: if 'enabled' not in self.data:
@ -812,7 +797,7 @@ class CableCSVForm(CustomFieldModelCSVForm):
# Cable attributes # Cable attributes
status = CSVChoiceField( status = CSVChoiceField(
choices=CableStatusChoices, choices=LinkStatusChoices,
required=False, required=False,
help_text='Connection status' help_text='Connection status'
) )

View File

@ -12,6 +12,7 @@ from utilities.forms import (
APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect, APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect,
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
) )
from wireless.choices import *
__all__ = ( __all__ = (
'CableFilterForm', 'CableFilterForm',
@ -106,10 +107,6 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = Region model = Region
field_groups = [
['q'],
['parent_id'],
]
q = forms.CharField( q = forms.CharField(
required=False, required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
@ -121,14 +118,11 @@ class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Parent region'), label=_('Parent region'),
fetch_trigger='open' fetch_trigger='open'
) )
tag = TagFilterField(model)
class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = SiteGroup model = SiteGroup
field_groups = [
['q'],
['parent_id'],
]
q = forms.CharField( q = forms.CharField(
required=False, required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
@ -140,6 +134,7 @@ class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Parent group'), label=_('Parent group'),
fetch_trigger='open' fetch_trigger='open'
) )
tag = TagFilterField(model)
class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
@ -226,18 +221,17 @@ class LocationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilt
label=_('Parent'), label=_('Parent'),
fetch_trigger='open' fetch_trigger='open'
) )
tag = TagFilterField(model)
class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = RackRole model = RackRole
field_groups = [
['q'],
]
q = forms.CharField( q = forms.CharField(
required=False, required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search') label=_('Search')
) )
tag = TagFilterField(model)
class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
@ -378,14 +372,12 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMo
class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = Manufacturer model = Manufacturer
field_groups = [
['q'],
]
q = forms.CharField( q = forms.CharField(
required=False, required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search') label=_('Search')
) )
tag = TagFilterField(model)
class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
@ -463,14 +455,12 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = DeviceRole model = DeviceRole
field_groups = [
['q'],
]
q = forms.CharField( q = forms.CharField(
required=False, required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search') label=_('Search')
) )
tag = TagFilterField(model)
class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
@ -486,6 +476,7 @@ class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Manufacturer'), label=_('Manufacturer'),
fetch_trigger='open' fetch_trigger='open'
) )
tag = TagFilterField(model)
class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
@ -743,7 +734,7 @@ class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterF
) )
status = forms.ChoiceField( status = forms.ChoiceField(
required=False, required=False,
choices=add_blank_choice(CableStatusChoices), choices=add_blank_choice(LinkStatusChoices),
widget=StaticSelect() widget=StaticSelect()
) )
color = ColorField( color = ColorField(
@ -974,6 +965,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], ['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'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
] ]
kind = forms.MultipleChoiceField( kind = forms.MultipleChoiceField(
@ -1006,6 +998,32 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
required=False, required=False,
label='WWN' 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) tag = TagFilterField(model)

View File

@ -17,6 +17,7 @@ from utilities.forms import (
SlugField, StaticSelect, SlugField, StaticSelect,
) )
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from wireless.models import WirelessLAN, WirelessLANGroup
from .common import InterfaceCommonForm from .common import InterfaceCommonForm
__all__ = ( __all__ = (
@ -71,11 +72,15 @@ class RegionForm(BootstrapMixin, CustomFieldModelForm):
required=False required=False
) )
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = Region model = Region
fields = ( fields = (
'parent', 'name', 'slug', 'description', 'parent', 'name', 'slug', 'description', 'tags',
) )
@ -85,11 +90,15 @@ class SiteGroupForm(BootstrapMixin, CustomFieldModelForm):
required=False required=False
) )
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = SiteGroup model = SiteGroup
fields = ( fields = (
'parent', 'name', 'slug', 'description', 'parent', 'name', 'slug', 'description', 'tags',
) )
@ -208,15 +217,19 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
} }
) )
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = Location model = Location
fields = ( 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 = ( fieldsets = (
('Location', ( ('Location', (
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
)), )),
('Tenancy', ('tenant_group', 'tenant')), ('Tenancy', ('tenant_group', 'tenant')),
) )
@ -224,11 +237,15 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class RackRoleForm(BootstrapMixin, CustomFieldModelForm): class RackRoleForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = RackRole model = RackRole
fields = [ fields = [
'name', 'slug', 'color', 'description', 'name', 'slug', 'color', 'description', 'tags',
] ]
@ -364,11 +381,15 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class ManufacturerForm(BootstrapMixin, CustomFieldModelForm): class ManufacturerForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = Manufacturer model = Manufacturer
fields = [ fields = [
'name', 'slug', 'description', 'name', 'slug', 'description', 'tags',
] ]
@ -413,11 +434,15 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm): class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = DeviceRole model = DeviceRole
fields = [ fields = [
'name', 'slug', 'color', 'vm_role', 'description', 'name', 'slug', 'color', 'vm_role', 'description', 'tags',
] ]
@ -429,11 +454,15 @@ class PlatformForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField( slug = SlugField(
max_length=64 max_length=64
) )
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = Platform model = Platform
fields = [ fields = [
'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
] ]
widgets = { widgets = {
'napalm_args': SmallTextarea(), 'napalm_args': SmallTextarea(),
@ -1085,6 +1114,11 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
required=False, required=False,
label='Parent interface' label='Parent interface'
) )
bridge = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
label='Bridged interface'
)
lag = DynamicModelChoiceField( lag = DynamicModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
@ -1093,6 +1127,19 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
'type': 'lag', '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( vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
required=False, required=False,
@ -1122,19 +1169,24 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
class Meta: class Meta:
model = Interface model = Interface
fields = [ fields = [
'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu',
'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', '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 = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
'type': StaticSelect(), 'type': StaticSelect(),
'mode': StaticSelect(), 'mode': StaticSelect(),
'rf_role': StaticSelect(),
'rf_channel': StaticSelect(),
} }
labels = { labels = {
'mode': '802.1Q Mode', 'mode': '802.1Q Mode',
} }
help_texts = { help_texts = {
'mode': INTERFACE_MODE_HELP_TEXT, '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): 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 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['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: 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) 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 # Limit VLAN choices by device
self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)

View File

@ -10,6 +10,7 @@ from utilities.forms import (
add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
ExpandableNameField, StaticSelect, ExpandableNameField, StaticSelect,
) )
from wireless.choices import *
from .common import InterfaceCommonForm from .common import InterfaceCommonForm
__all__ = ( __all__ = (
@ -445,6 +446,13 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
'device_id': '$device', 'device_id': '$device',
} }
) )
bridge = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
query_params={
'device_id': '$device',
}
)
lag = DynamicModelChoiceField( lag = DynamicModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
@ -465,7 +473,27 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
mode = forms.ChoiceField( mode = forms.ChoiceField(
choices=add_blank_choice(InterfaceModeChoices), choices=add_blank_choice(InterfaceModeChoices),
required=False, required=False,
widget=StaticSelect()
)
rf_role = forms.ChoiceField(
choices=add_blank_choice(WirelessRoleChoices),
required=False,
widget=StaticSelect(), 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( untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
@ -476,8 +504,9 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
required=False required=False
) )
field_order = ( field_order = (
'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address',
'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' '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): def __init__(self, *args, **kwargs):

View File

@ -215,6 +215,12 @@ class InterfaceType(IPAddressesMixin, ComponentObjectType):
def resolve_mode(self, info): def resolve_mode(self, info):
return self.mode or None 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): class InterfaceTemplateType(ComponentTemplateObjectType):

View File

@ -1,6 +1,7 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.core.management.color import no_style from django.core.management.color import no_style
from django.db import connection from django.db import connection
from django.db.models import Q
from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort
from dcim.signals import create_cablepath from dcim.signals import create_cablepath
@ -67,7 +68,10 @@ class Command(BaseCommand):
# Retrace paths # Retrace paths
for model in ENDPOINT_MODELS: 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']: if not options['force']:
origins = origins.filter(_path__isnull=True) origins = origins.filter(_path__isnull=True)
origins_count = origins.count() origins_count = origins.count()

View File

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

View File

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

View File

@ -6,7 +6,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('tenancy', '0002_tenant_ordering'), ('tenancy', '0002_tenant_ordering'),
('dcim', '0134_interface_wwn'), ('dcim', '0134_interface_wwn_bridge'),
] ]
operations = [ operations = [

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ __all__ = (
'BaseInterface', 'BaseInterface',
'Cable', 'Cable',
'CablePath', 'CablePath',
'CableTermination', 'LinkTermination',
'ConsolePort', 'ConsolePort',
'ConsolePortTemplate', 'ConsolePortTemplate',
'ConsoleServerPort', 'ConsoleServerPort',

View File

@ -64,8 +64,8 @@ class Cable(PrimaryModel):
) )
status = models.CharField( status = models.CharField(
max_length=50, max_length=50,
choices=CableStatusChoices, choices=LinkStatusChoices,
default=CableStatusChoices.STATUS_CONNECTED default=LinkStatusChoices.STATUS_CONNECTED
) )
tenant = models.ForeignKey( tenant = models.ForeignKey(
to='tenancy.Tenant', to='tenancy.Tenant',
@ -292,7 +292,7 @@ class Cable(PrimaryModel):
self._pk = self.pk self._pk = self.pk
def get_status_class(self): def get_status_class(self):
return CableStatusChoices.CSS_CLASSES.get(self.status) return LinkStatusChoices.CSS_CLASSES.get(self.status)
def get_compatible_types(self): def get_compatible_types(self):
""" """
@ -386,7 +386,7 @@ class CablePath(BigIDModel):
""" """
from circuits.models import CircuitTermination 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 return None
destination = None destination = None
@ -396,13 +396,13 @@ class CablePath(BigIDModel):
is_split = False is_split = False
node = origin node = origin
while node.cable is not None: while node.link is not None:
if node.cable.status != CableStatusChoices.STATUS_CONNECTED: if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED:
is_active = False is_active = False
# Follow the cable to its far-end termination # Follow the link to its far-end termination
path.append(object_to_path_node(node.cable)) path.append(object_to_path_node(node.link))
peer_termination = node.get_cable_peer() peer_termination = node.get_link_peer()
# Follow a FrontPort to its corresponding RearPort # Follow a FrontPort to its corresponding RearPort
if isinstance(peer_termination, FrontPort): if isinstance(peer_termination, FrontPort):

View File

@ -18,11 +18,13 @@ from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from wireless.choices import *
from wireless.utils import get_channel_attr
__all__ = ( __all__ = (
'BaseInterface', 'BaseInterface',
'CableTermination', 'LinkTermination',
'ConsolePort', 'ConsolePort',
'ConsoleServerPort', 'ConsoleServerPort',
'DeviceBay', 'DeviceBay',
@ -87,14 +89,14 @@ class ComponentModel(PrimaryModel):
return self.device 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 An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples
CircuitTermination instances). The `cable` field indicates the Cable instance which is terminated to this instance. 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 `_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination 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 shortcut to referencing `instance.link.termination_b`, for example.
dcim.signals when a Cable instance is created or deleted, respectively.
""" """
cable = models.ForeignKey( cable = models.ForeignKey(
to='dcim.Cable', to='dcim.Cable',
@ -103,20 +105,20 @@ class CableTermination(models.Model):
blank=True, blank=True,
null=True null=True
) )
_cable_peer_type = models.ForeignKey( _link_peer_type = models.ForeignKey(
to=ContentType, to=ContentType,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name='+', related_name='+',
blank=True, blank=True,
null=True null=True
) )
_cable_peer_id = models.PositiveIntegerField( _link_peer_id = models.PositiveIntegerField(
blank=True, blank=True,
null=True null=True
) )
_cable_peer = GenericForeignKey( _link_peer = GenericForeignKey(
ct_field='_cable_peer_type', ct_field='_link_peer_type',
fk_field='_cable_peer_id' fk_field='_link_peer_id'
) )
mark_connected = models.BooleanField( mark_connected = models.BooleanField(
default=False, default=False,
@ -146,8 +148,8 @@ class CableTermination(models.Model):
"mark_connected": "Cannot mark as connected with a cable attached." "mark_connected": "Cannot mark as connected with a cable attached."
}) })
def get_cable_peer(self): def get_link_peer(self):
return self._cable_peer return self._link_peer
@property @property
def _occupied(self): def _occupied(self):
@ -157,6 +159,13 @@ class CableTermination(models.Model):
def parent_object(self): def parent_object(self):
raise NotImplementedError("CableTermination models must implement parent_object()") 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): class PathEndpoint(models.Model):
""" """
@ -219,7 +228,7 @@ class PathEndpoint(models.Model):
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @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. 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') @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. 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') @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. 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) poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet)
outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True) outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
utilization = PowerPort.objects.filter( utilization = PowerPort.objects.filter(
_cable_peer_type=poweroutlet_ct, _link_peer_type=poweroutlet_ct,
_cable_peer_id__in=outlet_ids _link_peer_id__in=outlet_ids
).aggregate( ).aggregate(
maximum_draw_total=Sum('maximum_draw'), maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'), allocated_draw_total=Sum('allocated_draw'),
@ -347,12 +356,12 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
} }
# Calculate per-leg aggregates for three-phase feeds # 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: for leg, leg_name in PowerOutletFeedLegChoices:
outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True) outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
utilization = PowerPort.objects.filter( utilization = PowerPort.objects.filter(
_cable_peer_type=poweroutlet_ct, _link_peer_type=poweroutlet_ct,
_cable_peer_id__in=outlet_ids _link_peer_id__in=outlet_ids
).aggregate( ).aggregate(
maximum_draw_total=Sum('maximum_draw'), maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_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') @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. A physical power outlet (output) within a Device which provides power to a PowerPort.
""" """
@ -453,6 +462,22 @@ class BaseInterface(models.Model):
choices=InterfaceModeChoices, choices=InterfaceModeChoices,
blank=True 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: class Meta:
abstract = True abstract = True
@ -475,7 +500,7 @@ class BaseInterface(models.Model):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @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. 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, max_length=100,
blank=True 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( lag = models.ForeignKey(
to='self', to='self',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -517,6 +534,51 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
verbose_name='WWN', verbose_name='WWN',
help_text='64-bit World Wide Name' 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( untagged_vlan = models.ForeignKey(
to='ipam.VLAN', to='ipam.VLAN',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -538,7 +600,7 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
related_query_name='interface' related_query_name='interface'
) )
clone_fields = ['device', 'parent', 'lag', 'type', 'mgmt_only'] clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only']
class Meta: class Meta:
ordering = ('device', CollateAsChar('_name')) ordering = ('device', CollateAsChar('_name'))
@ -550,18 +612,28 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
def clean(self): def clean(self):
super().clean() super().clean()
# Virtual interfaces cannot be connected # Virtual Interfaces cannot have a Cable attached
if not self.is_connectable and self.cable: if self.is_virtual and self.cable:
raise ValidationError({ raise ValidationError({
'type': f"{self.get_type_display()} interfaces cannot have a cable attached." 'type': f"{self.get_type_display()} interfaces cannot have a cable attached."
}) })
# Non-connectable interfaces cannot be marked as connected # Virtual Interfaces cannot be marked as connected
if not self.is_connectable and self.mark_connected: if self.is_virtual and self.mark_connected:
raise ValidationError({ raise ValidationError({
'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected." '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 # An interface's parent must belong to the same device or virtual chassis
if self.parent and self.parent.device != self.device: if self.parent and self.parent.device != self.device:
if self.device.virtual_chassis is None: 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}." f"is not part of virtual chassis {self.device.virtual_chassis}."
}) })
# An interface cannot be its own parent # Bridge validation
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 # An interface cannot be bridged to itself
if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None: if self.pk and self.bridge_id == self.pk:
raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."}) 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 # An interface's LAG must belong to the same device or virtual chassis
if self.lag and self.lag.device != self.device: 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}." f"of virtual chassis {self.device.virtual_chassis}."
}) })
# A virtual interface cannot have a parent LAG # Wireless validation
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 # RF role & channel may only be set for wireless interfaces
if self.pk and self.lag_id == self.pk: if self.rf_role and not self.is_wireless:
raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) 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 # Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
raise ValidationError({ raise ValidationError({
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent " 'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the "
"device, or it must be global".format(self.untagged_vlan) f"interface's parent device, or it must be global."
}) })
@property @property
def is_connectable(self): def _occupied(self):
return self.type not in NONCONNECTABLE_IFACE_TYPES 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 @property
def is_virtual(self): def is_virtual(self):
@ -626,13 +747,17 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
def is_lag(self): def is_lag(self):
return self.type == InterfaceTypeChoices.TYPE_LAG return self.type == InterfaceTypeChoices.TYPE_LAG
@property
def link(self):
return self.cable or self.wireless_link
# #
# Pass-through ports # Pass-through ports
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @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. 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') @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. A pass-through port on the rear of a Device.
""" """

View File

@ -1,7 +1,6 @@
from collections import OrderedDict from collections import OrderedDict
import yaml import yaml
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@ -15,6 +14,7 @@ from dcim.constants import *
from extras.models import ConfigContextModel from extras.models import ConfigContextModel
from extras.querysets import ConfigContextModelQuerySet from extras.querysets import ConfigContextModelQuerySet
from extras.utils import extras_features from extras.utils import extras_features
from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
@ -36,7 +36,7 @@ __all__ = (
# Device Types # Device Types
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Manufacturer(OrganizationalModel): class Manufacturer(OrganizationalModel):
""" """
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@ -351,7 +351,7 @@ class DeviceType(PrimaryModel):
# Devices # Devices
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceRole(OrganizationalModel): class DeviceRole(OrganizationalModel):
""" """
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a 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]) 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): class Platform(OrganizationalModel):
""" """
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". 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 @property
def primary_ip(self): def primary_ip(self):
if settings.PREFER_IPV4 and self.primary_ip4: if ConfigItem('PREFER_IPV4')() and self.primary_ip4:
return self.primary_ip4 return self.primary_ip4
elif self.primary_ip6: elif self.primary_ip6:
return self.primary_ip6 return self.primary_ip6

View File

@ -10,7 +10,7 @@ from extras.utils import extras_features
from netbox.models import PrimaryModel from netbox.models import PrimaryModel
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.validators import ExclusionValidator from utilities.validators import ExclusionValidator
from .device_components import CableTermination, PathEndpoint from .device_components import LinkTermination, PathEndpoint
__all__ = ( __all__ = (
'PowerFeed', 'PowerFeed',
@ -72,7 +72,7 @@ class PowerPanel(PrimaryModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @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. An electrical circuit delivered from a PowerPanel.
""" """

View File

@ -1,6 +1,5 @@
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -15,6 +14,7 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.svg import RackElevationSVG from dcim.svg import RackElevationSVG
from extras.utils import extras_features from extras.utils import extras_features
from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
@ -35,7 +35,7 @@ __all__ = (
# Racks # Racks
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RackRole(OrganizationalModel): class RackRole(OrganizationalModel):
""" """
Racks can be organized by functional role, similar to Devices. Racks can be organized by functional role, similar to Devices.
@ -373,8 +373,8 @@ class Rack(PrimaryModel):
self, self,
face=DeviceFaceChoices.FACE_FRONT, face=DeviceFaceChoices.FACE_FRONT,
user=None, user=None,
unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH, unit_width=None,
unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT, unit_height=None,
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
include_images=True, include_images=True,
base_url=None 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. :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) 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) return elevation.render(face, unit_width, unit_height, legend_width)
@ -427,13 +431,13 @@ class Rack(PrimaryModel):
return 0 return 0
pf_powerports = PowerPort.objects.filter( pf_powerports = PowerPort.objects.filter(
_cable_peer_type=ContentType.objects.get_for_model(PowerFeed), _link_peer_type=ContentType.objects.get_for_model(PowerFeed),
_cable_peer_id__in=powerfeeds.values_list('id', flat=True) _link_peer_id__in=powerfeeds.values_list('id', flat=True)
) )
poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports) poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports)
allocated_draw_total = PowerPort.objects.filter( allocated_draw_total = PowerPort.objects.filter(
_cable_peer_type=ContentType.objects.get_for_model(PowerOutlet), _link_peer_type=ContentType.objects.get_for_model(PowerOutlet),
_cable_peer_id__in=poweroutlets.values_list('id', flat=True) _link_peer_id__in=poweroutlets.values_list('id', flat=True)
).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0 ).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0
return int(allocated_draw_total / available_power_total * 100) return int(allocated_draw_total / available_power_total * 100)

View File

@ -25,7 +25,7 @@ __all__ = (
# Regions # Regions
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Region(NestedGroupModel): class Region(NestedGroupModel):
""" """
A region represents a geographic collection of sites. For example, you might create regions representing countries, 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 # Site groups
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class SiteGroup(NestedGroupModel): class SiteGroup(NestedGroupModel):
""" """
A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and 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 # Locations
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Location(NestedGroupModel): class Location(NestedGroupModel):
""" """
A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a

View File

@ -2,37 +2,11 @@ import logging
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import post_save, post_delete, pre_delete from django.db.models.signals import post_save, post_delete, pre_delete
from django.db import transaction
from django.dispatch import receiver from django.dispatch import receiver
from .choices import CableStatusChoices from .choices import LinkStatusChoices
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
from .utils import create_cablepath, rebuild_paths
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)
# #
@ -109,12 +83,12 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
if instance.termination_a.cable != instance: if instance.termination_a.cable != instance:
logger.debug(f"Updating termination A for cable {instance}") logger.debug(f"Updating termination A for cable {instance}")
instance.termination_a.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() instance.termination_a.save()
if instance.termination_b.cable != instance: if instance.termination_b.cable != instance:
logger.debug(f"Updating termination B for cable {instance}") logger.debug(f"Updating termination B for cable {instance}")
instance.termination_b.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() instance.termination_b.save()
# Create/update cable paths # 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 # 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 # may change in the future.) However, we do need to capture status changes and update
# any CablePaths accordingly. # 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) CablePath.objects.filter(path__contains=instance).update(is_active=False)
else: else:
rebuild_paths(instance) rebuild_paths(instance)
@ -145,11 +119,11 @@ def nullify_connected_endpoints(instance, **kwargs):
if instance.termination_a is not None: if instance.termination_a is not None:
logger.debug(f"Nullifying termination A for cable {instance}") logger.debug(f"Nullifying termination A for cable {instance}")
model = instance.termination_a._meta.model 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: if instance.termination_b is not None:
logger.debug(f"Nullifying termination B for cable {instance}") logger.debug(f"Nullifying termination B for cable {instance}")
model = instance.termination_b._meta.model 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 # Delete and retrace any dependent cable paths
for cablepath in CablePath.objects.filter(path__contains=instance): for cablepath in CablePath.objects.filter(path__contains=instance):

View File

@ -398,6 +398,39 @@ class CableTraceSVG:
return group 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): def _draw_attachment(self):
""" """
Return an SVG group containing a line element and "Attachment" label. 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. Return an SVG document representing a cable trace.
""" """
from dcim.models import Cable
from wireless.models import WirelessLink
traced_path = self.origin.trace() traced_path = self.origin.trace()
# Prep elements list # Prep elements list
@ -452,24 +488,39 @@ class CableTraceSVG:
) )
terminations.append(termination) terminations.append(termination)
# Connector (either a Cable or attachment to a ProviderNetwork) # Connector (a Cable or WirelessLink)
if connector is not None: if connector is not None:
# Cable # Cable
cable_labels = [ if type(connector) is Cable:
f'Cable {connector}', connector_labels = [
connector.get_status_display() f'Cable {connector}',
] connector.get_status_display()
if connector.type: ]
cable_labels.append(connector.get_type_display()) if connector.type:
if connector.length and connector.length_unit: connector_labels.append(connector.get_type_display())
cable_labels.append(f'{connector.length} {connector.get_length_unit_display()}') if connector.length and connector.length_unit:
cable = self._draw_cable( connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
color=connector.color or '000000', cable = self._draw_cable(
url=connector.get_absolute_url(), color=connector.color or '000000',
labels=cable_labels url=connector.get_absolute_url(),
) labels=connector_labels
connectors.append(cable) )
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 # Far end termination
termination = self._draw_box( termination = self._draw_box(

View File

@ -11,11 +11,7 @@ from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn,
) )
from .template_code import ( 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,
)
__all__ = ( __all__ = (
'BaseInterfaceTable', 'BaseInterfaceTable',
@ -84,11 +80,16 @@ class DeviceRoleTable(BaseTable):
) )
color = ColorColumn() color = ColorColumn()
vm_role = BooleanColumn() vm_role = BooleanColumn()
tags = TagColumn(
url_name='dcim:devicerole_list'
)
actions = ButtonsColumn(DeviceRole) actions = ButtonsColumn(DeviceRole)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = DeviceRole 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') 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'}, url_params={'platform_id': 'pk'},
verbose_name='VMs' verbose_name='VMs'
) )
tags = TagColumn(
url_name='dcim:platform_list'
)
actions = ButtonsColumn(Platform) actions = ButtonsColumn(Platform)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Platform model = Platform
fields = ( fields = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
'description', 'actions', 'description', 'tags', 'actions',
) )
default_columns = ( default_columns = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions',
@ -156,18 +160,11 @@ class DeviceTable(BaseTable):
linkify=True, linkify=True,
verbose_name='Type' verbose_name='Type'
) )
if settings.PREFER_IPV4: primary_ip = tables.Column(
primary_ip = tables.Column( linkify=True,
linkify=True, order_by=('primary_ip4', 'primary_ip6'),
order_by=('primary_ip4', 'primary_ip6'), verbose_name='IP Address'
verbose_name='IP Address' )
)
else:
primary_ip = tables.Column(
linkify=True,
order_by=('primary_ip6', 'primary_ip4'),
verbose_name='IP Address'
)
primary_ip4 = tables.Column( primary_ip4 = tables.Column(
linkify=True, linkify=True,
verbose_name='IPv4 Address' verbose_name='IPv4 Address'
@ -258,11 +255,11 @@ class CableTerminationTable(BaseTable):
orderable=False, orderable=False,
verbose_name='Cable Color' verbose_name='Cable Color'
) )
cable_peer = TemplateColumn( link_peer = TemplateColumn(
accessor='_cable_peer', accessor='_link_peer',
template_code=CABLETERMINATION, template_code=LINKTERMINATION,
orderable=False, orderable=False,
verbose_name='Cable Peer' verbose_name='Link Peer'
) )
mark_connected = BooleanColumn() mark_connected = BooleanColumn()
@ -270,7 +267,7 @@ class CableTerminationTable(BaseTable):
class PathEndpointTable(CableTerminationTable): class PathEndpointTable(CableTerminationTable):
connection = TemplateColumn( connection = TemplateColumn(
accessor='_path.last_node', accessor='_path.last_node',
template_code=CABLETERMINATION, template_code=LINKTERMINATION,
verbose_name='Connection', verbose_name='Connection',
orderable=False orderable=False
) )
@ -291,7 +288,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
model = ConsolePort model = ConsolePort
fields = ( fields = (
'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@ -312,7 +309,7 @@ class DeviceConsolePortTable(ConsolePortTable):
model = ConsolePort model = ConsolePort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', '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') default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
row_attrs = { row_attrs = {
@ -335,7 +332,7 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
model = ConsoleServerPort model = ConsoleServerPort
fields = ( fields = (
'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@ -357,7 +354,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
model = ConsoleServerPort model = ConsoleServerPort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', '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') default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
row_attrs = { row_attrs = {
@ -380,7 +377,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
model = PowerPort model = PowerPort
fields = ( fields = (
'pk', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@ -402,7 +399,7 @@ class DevicePowerPortTable(PowerPortTable):
model = PowerPort model = PowerPort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable', '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 = ( default_columns = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
@ -431,7 +428,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
model = PowerOutlet model = PowerOutlet
fields = ( fields = (
'pk', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
@ -452,7 +449,7 @@ class DevicePowerOutletTable(PowerOutletTable):
model = PowerOutlet model = PowerOutlet
fields = ( fields = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', '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 = ( default_columns = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions',
@ -485,6 +482,14 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
} }
) )
mgmt_only = BooleanColumn() 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( tags = TagColumn(
url_name='dcim:interface_list' url_name='dcim:interface_list'
) )
@ -493,23 +498,26 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
model = Interface model = Interface
fields = ( fields = (
'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', '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', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
'untagged_vlan', 'tagged_vlans', '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') default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
class DeviceInterfaceTable(InterfaceTable): class DeviceInterfaceTable(InterfaceTable):
name = tables.TemplateColumn( name = tables.TemplateColumn(
template_code='<i class="mdi mdi-{% if iface.mgmt_only %}wrench{% elif iface.is_lag %}drag-horizontal-variant' template_code='<i class="mdi mdi-{% if record.mgmt_only %}wrench{% elif record.is_lag %}drag-horizontal-variant'
'{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}ethernet' '{% elif record.is_virtual %}circle{% elif record.is_wireless %}wifi{% else %}ethernet'
'{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>', '{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'), order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
parent = tables.Column( parent = tables.Column(
linkify=True, linkify=True
verbose_name='Parent' )
bridge = tables.Column(
linkify=True
) )
lag = tables.Column( lag = tables.Column(
linkify=True, linkify=True,
@ -524,9 +532,10 @@ class DeviceInterfaceTable(InterfaceTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = Interface model = Interface
fields = ( fields = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode',
'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', 'description',
'untagged_vlan', 'tagged_vlans', 'actions', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions',
) )
order_by = ('name',) order_by = ('name',)
default_columns = ( default_columns = (
@ -562,7 +571,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
model = FrontPort model = FrontPort
fields = ( fields = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', '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 = ( default_columns = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
@ -586,10 +595,10 @@ class DeviceFrontPortTable(FrontPortTable):
model = FrontPort model = FrontPort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', '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 = ( 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', 'actions',
) )
row_attrs = { row_attrs = {
@ -613,7 +622,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
model = RearPort model = RearPort
fields = ( fields = (
'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
@ -635,10 +644,10 @@ class DeviceRearPortTable(RearPortTable):
model = RearPort model = RearPort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', 'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'tags', 'actions', 'link_peer', 'tags', 'actions',
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions', 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions',
) )
row_attrs = { row_attrs = {
'class': get_cabletermination_row_class 'class': get_cabletermination_row_class

View File

@ -41,12 +41,16 @@ class ManufacturerTable(BaseTable):
verbose_name='Platforms' verbose_name='Platforms'
) )
slug = tables.Column() slug = tables.Column()
tags = TagColumn(
url_name='dcim:manufacturer_list'
)
actions = ButtonsColumn(Manufacturer) actions = ButtonsColumn(Manufacturer)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Manufacturer model = Manufacturer
fields = ( fields = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'tags',
'actions',
) )

View File

@ -71,10 +71,10 @@ class PowerFeedTable(CableTerminationTable):
model = PowerFeed model = PowerFeed
fields = ( fields = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', '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', 'comments', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
'cable_peer', 'link_peer',
) )

View File

@ -24,11 +24,14 @@ class RackRoleTable(BaseTable):
name = tables.Column(linkify=True) name = tables.Column(linkify=True)
rack_count = tables.Column(verbose_name='Racks') rack_count = tables.Column(verbose_name='Racks')
color = ColorColumn() color = ColorColumn()
tags = TagColumn(
url_name='dcim:rackrole_list'
)
actions = ButtonsColumn(RackRole) actions = ButtonsColumn(RackRole)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = RackRole 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') default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')

View File

@ -29,11 +29,14 @@ class RegionTable(BaseTable):
url_params={'region_id': 'pk'}, url_params={'region_id': 'pk'},
verbose_name='Sites' verbose_name='Sites'
) )
tags = TagColumn(
url_name='dcim:region_list'
)
actions = ButtonsColumn(Region) actions = ButtonsColumn(Region)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Region 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') default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
@ -51,11 +54,14 @@ class SiteGroupTable(BaseTable):
url_params={'group_id': 'pk'}, url_params={'group_id': 'pk'},
verbose_name='Sites' verbose_name='Sites'
) )
tags = TagColumn(
url_name='dcim:sitegroup_list'
)
actions = ButtonsColumn(SiteGroup) actions = ButtonsColumn(SiteGroup)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = SiteGroup 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') default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
@ -119,6 +125,9 @@ class LocationTable(BaseTable):
url_params={'location_id': 'pk'}, url_params={'location_id': 'pk'},
verbose_name='Devices' verbose_name='Devices'
) )
tags = TagColumn(
url_name='dcim:location_list'
)
actions = ButtonsColumn( actions = ButtonsColumn(
model=Location, model=Location,
prepend_template=LOCATION_ELEVATIONS prepend_template=LOCATION_ELEVATIONS
@ -126,5 +135,7 @@ class LocationTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Location 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') default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')

View File

@ -1,4 +1,4 @@
CABLETERMINATION = """ LINKTERMINATION = """
{% if value %} {% if value %}
{% if value.parent_object %} {% if value.parent_object %}
<a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a> <a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a>
@ -64,6 +64,12 @@ INTERFACE_TAGGED_VLANS = """
{% endif %} {% endif %}
""" """
INTERFACE_WIRELESS_LANS = """
{% for wlan in record.wireless_lans.all %}
<a href="{{ wlan.get_absolute_url }}">{{ wlan }}</a><br />
{% endfor %}
"""
POWERFEED_CABLE = """ POWERFEED_CABLE = """
<a href="{{ value.get_absolute_url }}">{{ value }}</a> <a href="{{ value.get_absolute_url }}">{{ value }}</a>
<a href="{% url 'dcim:powerfeed_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"> <a href="{% url 'dcim:powerfeed_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace">
@ -195,15 +201,23 @@ INTERFACE_BUTTONS = """
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}
{% if record.cable %} {% if record.link %}
<a href="{% url 'dcim:interface_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a> <a href="{% url 'dcim:interface_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% endif %}
{% if record.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %} {% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm"> <a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> <i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}
{% elif record.is_connectable and perms.dcim.add_cable %} {% elif record.wireless_link %}
{% if perms.wireless.delete_wirelesslink %}
<a href="{% url 'wireless:wirelesslink_delete' pk=record.wireless_link.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Delete wireless link" class="btn btn-danger btn-sm">
<i class="mdi mdi-wifi-off" aria-hidden="true"></i>
</a>
{% endif %}
{% elif record.is_wired and perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a> <a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a> <a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %} {% if not record.mark_connected %}
@ -221,6 +235,10 @@ INTERFACE_BUTTONS = """
{% else %} {% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a> <a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %} {% endif %}
{% elif record.is_wireless and perms.wireless.add_wirelesslink %}
<a href="{% url 'wireless:wirelesslink_add' %}?site_a={{ record.device.site.pk }}&location_a={{ record.device.location.pk }}&device_a={{ record.device.pk }}&interface_a={{ record.pk }}&site_b={{ record.device.site.pk }}&location_b={{ record.device.location.pk }}" class="btn btn-success btn-sm">
<span class="mdi mdi-wifi-plus" aria-hidden="true"></span>
</a>
{% endif %} {% endif %}
""" """

View File

@ -1198,6 +1198,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'name': 'Interface 4', 'name': 'Interface 4',
'type': '1000base-t', 'type': '1000base-t',
'mode': InterfaceModeChoices.MODE_TAGGED, 'mode': InterfaceModeChoices.MODE_TAGGED,
'tx_power': 10,
'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk, 'untagged_vlan': vlans[2].pk,
}, },
@ -1206,6 +1207,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'name': 'Interface 5', 'name': 'Interface 5',
'type': '1000base-t', 'type': '1000base-t',
'mode': InterfaceModeChoices.MODE_TAGGED, 'mode': InterfaceModeChoices.MODE_TAGGED,
'bridge': interfaces[0].pk,
'tx_power': 10,
'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk, 'untagged_vlan': vlans[2].pk,
}, },
@ -1214,7 +1217,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'name': 'Interface 6', 'name': 'Interface 6',
'type': 'virtual', 'type': 'virtual',
'mode': InterfaceModeChoices.MODE_TAGGED, 'mode': InterfaceModeChoices.MODE_TAGGED,
'parent': interfaces[0].pk, 'parent': interfaces[1].pk,
'tx_power': 10,
'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk, 'untagged_vlan': vlans[2].pk,
}, },

View File

@ -2,7 +2,7 @@ from django.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
from circuits.models import * from circuits.models import *
from dcim.choices import CableStatusChoices from dcim.choices import LinkStatusChoices
from dcim.models import * from dcim.models import *
from dcim.utils import object_to_path_node from dcim.utils import object_to_path_node
@ -1142,7 +1142,7 @@ class CablePathTestCase(TestCase):
self.assertEqual(CablePath.objects.count(), 2) self.assertEqual(CablePath.objects.count(), 2)
# Change cable 2's status to "planned" # Change cable 2's status to "planned"
cable2.status = CableStatusChoices.STATUS_PLANNED cable2.status = LinkStatusChoices.STATUS_PLANNED
cable2.save() cable2.save()
self.assertPathExists( self.assertPathExists(
origin=interface1, origin=interface1,
@ -1160,7 +1160,7 @@ class CablePathTestCase(TestCase):
# Change cable 2's status to "connected" # Change cable 2's status to "connected"
cable2 = Cable.objects.get(pk=cable2.pk) cable2 = Cable.objects.get(pk=cable2.pk)
cable2.status = CableStatusChoices.STATUS_CONNECTED cable2.status = LinkStatusChoices.STATUS_CONNECTED
cable2.save() cable2.save()
self.assertPathExists( self.assertPathExists(
origin=interface1, origin=interface1,

View File

@ -9,6 +9,7 @@ from tenancy.models import Tenant, TenantGroup
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.testing import ChangeLoggedFilterSetTests from utilities.testing import ChangeLoggedFilterSetTests
from virtualization.models import Cluster, ClusterType from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): 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[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[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[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 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), 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), 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) Interface.objects.bulk_create(interfaces)
@ -2100,11 +2103,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'connected': True} params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'connected': False} 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): def test_enabled(self):
params = {'enabled': 'true'} 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'} params = {'enabled': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -2116,7 +2119,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'mgmt_only': 'true'} params = {'mgmt_only': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'mgmt_only': 'false'} 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): def test_mode(self):
params = {'mode': InterfaceModeChoices.MODE_ACCESS} params = {'mode': InterfaceModeChoices.MODE_ACCESS}
@ -2139,6 +2142,19 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'parent_id': [parent_interface.pk]} params = {'parent_id': [parent_interface.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) 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): def test_lag(self):
# Create LAG members # Create LAG members
device = Device.objects.first() device = Device.objects.first()
@ -2193,7 +2209,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'cabled': 'true'} params = {'cabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'cabled': 'false'} 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): def test_kind(self):
params = {'kind': 'physical'} params = {'kind': 'physical'}
@ -2209,6 +2225,26 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]} params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = FrontPort.objects.all() 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') console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
# Cables # 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[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=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, 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=CableStatusChoices.STATUS_CONNECTED, color='f44336', length=30, 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=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, 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=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).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=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, 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() Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
def test_label(self): def test_label(self):
@ -2906,9 +2942,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_status(self): def test_status(self):
params = {'status': [CableStatusChoices.STATUS_CONNECTED]} params = {'status': [LinkStatusChoices.STATUS_CONNECTED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) 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) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_color(self): def test_color(self):

View File

@ -494,9 +494,9 @@ class CableTestCase(TestCase):
interface1 = Interface.objects.get(pk=self.interface1.pk) interface1 = Interface.objects.get(pk=self.interface1.pk)
interface2 = Interface.objects.get(pk=self.interface2.pk) interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertEqual(self.cable.termination_a, interface1) 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(self.cable.termination_b, interface2)
self.assertEqual(interface2._cable_peer, interface1) self.assertEqual(interface2._link_peer, interface1)
def test_cable_deletion(self): def test_cable_deletion(self):
""" """
@ -508,10 +508,10 @@ class CableTestCase(TestCase):
self.assertNotEqual(str(self.cable), '#None') self.assertNotEqual(str(self.cable), '#None')
interface1 = Interface.objects.get(pk=self.interface1.pk) interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.cable) self.assertIsNone(interface1.cable)
self.assertIsNone(interface1._cable_peer) self.assertIsNone(interface1._link_peer)
interface2 = Interface.objects.get(pk=self.interface2.pk) interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertIsNone(interface2.cable) self.assertIsNone(interface2.cable)
self.assertIsNone(interface2._cable_peer) self.assertIsNone(interface2._link_peer)
def test_cabletermination_deletion(self): def test_cabletermination_deletion(self):
""" """

View File

@ -31,11 +31,14 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
for region in regions: for region in regions:
region.save() region.save()
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Region X', 'name': 'Region X',
'slug': 'region-x', 'slug': 'region-x',
'parent': regions[2].pk, 'parent': regions[2].pk,
'description': 'A new region', 'description': 'A new region',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -65,11 +68,14 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
for sitegroup in sitegroups: for sitegroup in sitegroups:
sitegroup.save() sitegroup.save()
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Site Group X', 'name': 'Site Group X',
'slug': 'site-group-x', 'slug': 'site-group-x',
'parent': sitegroups[2].pk, 'parent': sitegroups[2].pk,
'description': 'A new site group', 'description': 'A new site group',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -194,12 +200,15 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
for location in locations: for location in locations:
location.save() location.save()
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Location X', 'name': 'Location X',
'slug': 'location-x', 'slug': 'location-x',
'site': site.pk, 'site': site.pk,
'tenant': tenant.pk, 'tenant': tenant.pk,
'description': 'A new location', 'description': 'A new location',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -226,11 +235,14 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
RackRole(name='Rack Role 3', slug='rack-role-3'), RackRole(name='Rack Role 3', slug='rack-role-3'),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Rack Role X', 'name': 'Rack Role X',
'slug': 'rack-role-x', 'slug': 'rack-role-x',
'color': 'c0c0c0', 'color': 'c0c0c0',
'description': 'New role', 'description': 'New role',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -393,10 +405,13 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Manufacturer X', 'name': 'Manufacturer X',
'slug': 'manufacturer-x', 'slug': 'manufacturer-x',
'description': 'A new manufacturer', 'description': 'A new manufacturer',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -1059,12 +1074,15 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
DeviceRole(name='Device Role 3', slug='device-role-3'), DeviceRole(name='Device Role 3', slug='device-role-3'),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Devie Role X', 'name': 'Devie Role X',
'slug': 'device-role-x', 'slug': 'device-role-x',
'color': 'c0c0c0', 'color': 'c0c0c0',
'vm_role': False, 'vm_role': False,
'description': 'New device role', 'description': 'New device role',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -1094,6 +1112,8 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer), Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Platform X', 'name': 'Platform X',
'slug': 'platform-x', 'slug': 'platform-x',
@ -1101,6 +1121,7 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'napalm_driver': 'junos', 'napalm_driver': 'junos',
'napalm_args': None, 'napalm_args': None,
'description': 'A new platform', 'description': 'A new platform',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -1585,6 +1606,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
Interface(device=device, name='Interface 2'), Interface(device=device, name='Interface 2'),
Interface(device=device, name='Interface 3'), Interface(device=device, name='Interface 3'),
Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG), 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) Interface.objects.bulk_create(interfaces)
@ -1600,10 +1622,10 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
'virtual_machine': None,
'name': 'Interface X', 'name': 'Interface X',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'enabled': False, 'enabled': False,
'bridge': interfaces[4].pk,
'lag': interfaces[3].pk, 'lag': interfaces[3].pk,
'mac_address': EUI('01:02:03:04:05:06'), 'mac_address': EUI('01:02:03:04:05:06'),
'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64),
@ -1611,6 +1633,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'mgmt_only': True, 'mgmt_only': True,
'description': 'A front port', 'description': 'A front port',
'mode': InterfaceModeChoices.MODE_TAGGED, 'mode': InterfaceModeChoices.MODE_TAGGED,
'tx_power': 10,
'untagged_vlan': vlans[0].pk, 'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]], 'tagged_vlans': [v.pk for v in vlans[1:4]],
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
@ -1621,6 +1644,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'name_pattern': 'Interface [4-6]', 'name_pattern': 'Interface [4-6]',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'enabled': False, 'enabled': False,
'bridge': interfaces[4].pk,
'lag': interfaces[3].pk, 'lag': interfaces[3].pk,
'mac_address': EUI('01:02:03:04:05:06'), 'mac_address': EUI('01:02:03:04:05:06'),
'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64),
@ -1643,6 +1667,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'mgmt_only': True, 'mgmt_only': True,
'description': 'New description', 'description': 'New description',
'mode': InterfaceModeChoices.MODE_TAGGED, 'mode': InterfaceModeChoices.MODE_TAGGED,
'tx_power': 10,
'untagged_vlan': vlans[0].pk, 'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]], 'tagged_vlans': [v.pk for v in vlans[1:4]],
} }
@ -1948,7 +1973,7 @@ class CableTestCase(
'termination_b_type': interface_ct.pk, 'termination_b_type': interface_ct.pk,
'termination_b_id': interfaces[3].pk, 'termination_b_id': interfaces[3].pk,
'type': CableTypeChoices.TYPE_CAT6, 'type': CableTypeChoices.TYPE_CAT6,
'status': CableStatusChoices.STATUS_PLANNED, 'status': LinkStatusChoices.STATUS_PLANNED,
'label': 'Label', 'label': 'Label',
'color': 'c0c0c0', 'color': 'c0c0c0',
'length': 100, 'length': 100,
@ -1965,7 +1990,7 @@ class CableTestCase(
cls.bulk_edit_data = { cls.bulk_edit_data = {
'type': CableTypeChoices.TYPE_CAT5E, 'type': CableTypeChoices.TYPE_CAT5E,
'status': CableStatusChoices.STATUS_CONNECTED, 'status': LinkStatusChoices.STATUS_CONNECTED,
'label': 'New label', 'label': 'New label',
'color': '00ff00', 'color': '00ff00',
'length': 50, 'length': 50,

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import transaction
def compile_path_node(ct_id, object_id): 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_id, object_id = decompile_path_node(repr)
ct = ContentType.objects.get_for_id(ct_id) ct = ContentType.objects.get_for_id(ct_id)
return ct.model_class().objects.get(pk=object_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)

View File

@ -1,10 +1,128 @@
from django.contrib import admin 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(
'<a href="{url}" class="button">Restore</a>',
url=reverse('admin:extras_configrevision_restore', args=(obj.pk,))
)
restore_link.short_description = "Actions"
# URLs
def get_urls(self):
urls = [
path('<int:pk>/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) @admin.register(JobResult)

View File

@ -61,7 +61,7 @@ class WebhookSerializer(ValidatedModelSerializer):
fields = [ fields = [
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', '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', 'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
'ssl_verification', 'ca_file_path', 'conditions', 'ssl_verification', 'ca_file_path',
] ]

View File

@ -13,6 +13,7 @@ class CustomFieldTypeChoices(ChoiceSet):
TYPE_BOOLEAN = 'boolean' TYPE_BOOLEAN = 'boolean'
TYPE_DATE = 'date' TYPE_DATE = 'date'
TYPE_URL = 'url' TYPE_URL = 'url'
TYPE_JSON = 'json'
TYPE_SELECT = 'select' TYPE_SELECT = 'select'
TYPE_MULTISELECT = 'multiselect' TYPE_MULTISELECT = 'multiselect'
@ -23,6 +24,7 @@ class CustomFieldTypeChoices(ChoiceSet):
(TYPE_BOOLEAN, 'Boolean (true/false)'), (TYPE_BOOLEAN, 'Boolean (true/false)'),
(TYPE_DATE, 'Date'), (TYPE_DATE, 'Date'),
(TYPE_URL, 'URL'), (TYPE_URL, 'URL'),
(TYPE_JSON, 'JSON'),
(TYPE_SELECT, 'Selection'), (TYPE_SELECT, 'Selection'),
(TYPE_MULTISELECT, 'Multiple selection'), (TYPE_MULTISELECT, 'Multiple selection'),
) )

144
netbox/extras/conditions.py Normal file
View File

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

View File

@ -3,4 +3,5 @@ from .filtersets import *
from .bulk_edit import * from .bulk_edit import *
from .bulk_import import * from .bulk_import import *
from .customfields import * from .customfields import *
from .config import *
from .scripts import * from .scripts import *

View File

@ -137,7 +137,7 @@ class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
) )
class Meta: class Meta:
nullable_fields = ['secret', 'ca_file_path'] nullable_fields = ['secret', 'conditions', 'ca_file_path']
class TagBulkEditForm(BootstrapMixin, BulkEditForm): class TagBulkEditForm(BootstrapMixin, BulkEditForm):

View File

@ -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'<br />Current value: <strong>{value}</strong>'
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

View File

@ -1,5 +1,6 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
@ -115,9 +116,10 @@ class CustomFieldModelFilterForm(forms.Form):
# Add all applicable CustomFields to the form # Add all applicable CustomFields to the form
self.custom_field_filters = [] self.custom_field_filters = []
custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude( 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: 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.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
self.custom_field_filters.append(field_name) self.custom_field_filters.append(field_name)

View File

@ -102,6 +102,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
('HTTP Request', ( ('HTTP Request', (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
)), )),
('Conditions', ('conditions',)),
('SSL', ('ssl_verification', 'ca_file_path')), ('SSL', ('ssl_verification', 'ca_file_path')),
) )
widgets = { widgets = {

View File

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

View File

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

View File

@ -1,12 +1,13 @@
from .change_logging import ObjectChange from .change_logging import ObjectChange
from .configcontexts import ConfigContext, ConfigContextModel from .configcontexts import ConfigContext, ConfigContextModel
from .customfields import CustomField from .customfields import CustomField
from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook from .models import *
from .tags import Tag, TaggedItem from .tags import Tag, TaggedItem
__all__ = ( __all__ = (
'ConfigContext', 'ConfigContext',
'ConfigContextModel', 'ConfigContextModel',
'ConfigRevision',
'CustomField', 'CustomField',
'CustomLink', 'CustomLink',
'ExportTemplate', 'ExportTemplate',

View File

@ -280,6 +280,10 @@ class CustomField(ChangeLoggedModel):
elif self.type == CustomFieldTypeChoices.TYPE_URL: elif self.type == CustomFieldTypeChoices.TYPE_URL:
field = LaxURLField(required=required, initial=initial) field = LaxURLField(required=required, initial=initial)
# JSON
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
field = forms.JSONField(required=required, initial=initial)
# Text # Text
else: else:
if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT: if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT:

View File

@ -1,26 +1,29 @@
import json import json
import uuid import uuid
from django.contrib import admin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.http import HttpResponse from django.http import HttpResponse
from django.urls import reverse from django.urls import reverse
from django.utils import timezone 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 rest_framework.utils.encoders import JSONEncoder
from extras.choices import * from extras.choices import *
from extras.constants import * from extras.constants import *
from extras.conditions import ConditionSet
from extras.utils import extras_features, FeatureQuery, image_upload from extras.utils import extras_features, FeatureQuery, image_upload
from netbox.models import BigIDModel, ChangeLoggedModel from netbox.models import BigIDModel, ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.utils import render_jinja2 from utilities.utils import render_jinja2
__all__ = ( __all__ = (
'ConfigRevision',
'CustomLink', 'CustomLink',
'ExportTemplate', 'ExportTemplate',
'ImageAttachment', 'ImageAttachment',
@ -32,10 +35,6 @@ __all__ = (
) )
#
# Webhooks
#
@extras_features('webhooks') @extras_features('webhooks')
class Webhook(ChangeLoggedModel): class Webhook(ChangeLoggedModel):
""" """
@ -107,6 +106,11 @@ class Webhook(ChangeLoggedModel):
"the secret as the key. The secret is not transmitted in " "the secret as the key. The secret is not transmitted in "
"the request." "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( ssl_verification = models.BooleanField(
default=True, default=True,
verbose_name='SSL verification', verbose_name='SSL verification',
@ -138,9 +142,13 @@ class Webhook(ChangeLoggedModel):
# At least one action type must be selected # At least one action type must be selected
if not self.type_create and not self.type_delete and not self.type_update: if not self.type_create and not self.type_delete and not self.type_update:
raise ValidationError( raise ValidationError("At least one type must be selected: create, update, and/or delete.")
"You must select at least one type: 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 # CA file path requires SSL verification enabled
if not self.ssl_verification and self.ca_file_path: if not self.ssl_verification and self.ca_file_path:
@ -171,10 +179,6 @@ class Webhook(ChangeLoggedModel):
return json.dumps(context, cls=JSONEncoder) return json.dumps(context, cls=JSONEncoder)
#
# Custom links
#
@extras_features('webhooks') @extras_features('webhooks')
class CustomLink(ChangeLoggedModel): class CustomLink(ChangeLoggedModel):
""" """
@ -230,10 +234,6 @@ class CustomLink(ChangeLoggedModel):
return reverse('extras:customlink', args=[self.pk]) return reverse('extras:customlink', args=[self.pk])
#
# Export templates
#
@extras_features('webhooks') @extras_features('webhooks')
class ExportTemplate(ChangeLoggedModel): class ExportTemplate(ChangeLoggedModel):
content_type = models.ForeignKey( content_type = models.ForeignKey(
@ -323,10 +323,6 @@ class ExportTemplate(ChangeLoggedModel):
return response return response
#
# Image attachments
#
class ImageAttachment(BigIDModel): class ImageAttachment(BigIDModel):
""" """
An uploaded image which is associated with an object. An uploaded image which is associated with an object.
@ -399,11 +395,6 @@ class ImageAttachment(BigIDModel):
return None return None
#
# Journal entries
#
@extras_features('webhooks') @extras_features('webhooks')
class JournalEntry(ChangeLoggedModel): class JournalEntry(ChangeLoggedModel):
""" """
@ -453,36 +444,6 @@ class JournalEntry(ChangeLoggedModel):
return JournalEntryKindChoices.CSS_CLASSES.get(self.kind) 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): class JobResult(BigIDModel):
""" """
This model stores the results from running a user-defined report. 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) func.delay(*args, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
return job_result 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

View File

@ -8,7 +8,7 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates
from netbox.signals import post_clean from netbox.signals import post_clean
from .choices import ObjectChangeActionChoices 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 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, []) validators = settings.CUSTOM_VALIDATORS.get(model_name, [])
for validator in validators: for validator in validators:
validator(instance) 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()

View File

@ -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}))

View File

@ -64,6 +64,11 @@ class CustomFieldTest(TestCase):
'field_value': 'http://example.com/', 'field_value': 'http://example.com/',
'empty_value': '', 'empty_value': '',
}, },
{
'field_type': CustomFieldTypeChoices.TYPE_JSON,
'field_value': '{"foo": 1, "bar": 2}',
'empty_value': 'null',
},
) )
obj_type = ContentType.objects.get_for_model(Site) obj_type = ContentType.objects.get_for_model(Site)
@ -207,6 +212,11 @@ class CustomFieldAPITest(APITestCase):
cls.cf_url.save() cls.cf_url.save()
cls.cf_url.content_types.set([content_type]) 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 # Select custom field
cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', choices=['Foo', 'Bar', 'Baz']) cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', choices=['Foo', 'Bar', 'Baz'])
cls.cf_select.default = 'Foo' cls.cf_select.default = 'Foo'
@ -228,6 +238,7 @@ class CustomFieldAPITest(APITestCase):
cls.cf_boolean.name: True, cls.cf_boolean.name: True,
cls.cf_date.name: '2020-01-02', cls.cf_date.name: '2020-01-02',
cls.cf_url.name: 'http://example.com/2', cls.cf_url.name: 'http://example.com/2',
cls.cf_json.name: '{"foo": 1, "bar": 2}',
cls.cf_select.name: 'Bar', cls.cf_select.name: 'Bar',
} }
cls.sites[1].save() cls.sites[1].save()
@ -248,6 +259,7 @@ class CustomFieldAPITest(APITestCase):
'boolean_field': None, 'boolean_field': None,
'date_field': None, 'date_field': None,
'url_field': None, 'url_field': None,
'json_field': None,
'choice_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']['boolean_field'], site2_cfvs['boolean_field'])
self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_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']['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']) self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field'])
def test_create_single_object_with_defaults(self): 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['boolean_field'], self.cf_boolean.default)
self.assertEqual(response_cf['date_field'], self.cf_date.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['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) self.assertEqual(response_cf['choice_field'], self.cf_select.default)
# Validate database data # Validate database data
@ -301,6 +315,7 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default)
self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) self.assertEqual(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['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) self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default)
def test_create_single_object_with_values(self): def test_create_single_object_with_values(self):
@ -317,6 +332,7 @@ class CustomFieldAPITest(APITestCase):
'boolean_field': True, 'boolean_field': True,
'date_field': '2020-01-02', 'date_field': '2020-01-02',
'url_field': 'http://example.com/2', 'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 2}',
'choice_field': 'Bar', 'choice_field': 'Bar',
}, },
} }
@ -335,6 +351,7 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field']) self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field'])
self.assertEqual(response_cf['date_field'], data_cf['date_field']) self.assertEqual(response_cf['date_field'], data_cf['date_field'])
self.assertEqual(response_cf['url_field'], data_cf['url_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']) self.assertEqual(response_cf['choice_field'], data_cf['choice_field'])
# Validate database data # Validate database data
@ -345,6 +362,7 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field']) self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field']) self.assertEqual(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['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']) self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field'])
def test_create_multiple_objects_with_defaults(self): 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['boolean_field'], self.cf_boolean.default)
self.assertEqual(response_cf['date_field'], self.cf_date.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['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) self.assertEqual(response_cf['choice_field'], self.cf_select.default)
# Validate database data # Validate database data
@ -393,6 +412,7 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default)
self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) self.assertEqual(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['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) self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default)
def test_create_multiple_objects_with_values(self): def test_create_multiple_objects_with_values(self):
@ -406,6 +426,7 @@ class CustomFieldAPITest(APITestCase):
'boolean_field': True, 'boolean_field': True,
'date_field': '2020-01-02', 'date_field': '2020-01-02',
'url_field': 'http://example.com/2', 'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 2}',
'choice_field': 'Bar', 'choice_field': 'Bar',
} }
data = ( data = (
@ -442,6 +463,7 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field']) self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field'])
self.assertEqual(response_cf['date_field'], custom_field_data['date_field']) self.assertEqual(response_cf['date_field'], custom_field_data['date_field'])
self.assertEqual(response_cf['url_field'], custom_field_data['url_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']) self.assertEqual(response_cf['choice_field'], custom_field_data['choice_field'])
# Validate database data # 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(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(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['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']) self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field'])
def test_update_single_object_with_values(self): 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['boolean_field'], original_cfvs['boolean_field'])
self.assertEqual(response_cf['date_field'], original_cfvs['date_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['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']) self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field'])
# Validate database data # 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['boolean_field'], original_cfvs['boolean_field'])
self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_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['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']) self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field'])
def test_minimum_maximum_values_validation(self): def test_minimum_maximum_values_validation(self):
@ -549,6 +574,7 @@ class CustomFieldImportTest(TestCase):
CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN), CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE), CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON),
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[ CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
'Choice A', 'Choice B', 'Choice C', '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. Import a Site in CSV format, including a value for each CustomField.
""" """
data = ( data = (
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_select'), ('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', 'Choice A'), ('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', 'Choice B'), ('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', '', '', '', '', '', '', ''), ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', ''),
) )
csv_data = '\n'.join(','.join(row) for row in data) csv_data = '\n'.join(','.join(row) for row in data)
@ -574,24 +600,26 @@ class CustomFieldImportTest(TestCase):
# Validate data for site 1 # Validate data for site 1
site1 = Site.objects.get(name='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['text'], 'ABC')
self.assertEqual(site1.custom_field_data['longtext'], 'Foo') self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
self.assertEqual(site1.custom_field_data['integer'], 123) self.assertEqual(site1.custom_field_data['integer'], 123)
self.assertEqual(site1.custom_field_data['boolean'], True) self.assertEqual(site1.custom_field_data['boolean'], True)
self.assertEqual(site1.custom_field_data['date'], '2020-01-01') 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['url'], 'http://example.com/1')
self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
self.assertEqual(site1.custom_field_data['select'], 'Choice A') self.assertEqual(site1.custom_field_data['select'], 'Choice A')
# Validate data for site 2 # Validate data for site 2
site2 = Site.objects.get(name='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['text'], 'DEF')
self.assertEqual(site2.custom_field_data['longtext'], 'Bar') self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
self.assertEqual(site2.custom_field_data['integer'], 456) self.assertEqual(site2.custom_field_data['integer'], 456)
self.assertEqual(site2.custom_field_data['boolean'], False) self.assertEqual(site2.custom_field_data['boolean'], False)
self.assertEqual(site2.custom_field_data['date'], '2020-01-02') 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['url'], 'http://example.com/2')
self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
self.assertEqual(site2.custom_field_data['select'], 'Choice B') self.assertEqual(site2.custom_field_data['select'], 'Choice B')
# No custom field data should be set for site 3 # No custom field data should be set for site 3

View File

@ -32,6 +32,9 @@ class CustomFieldModelFormTest(TestCase):
cf_url = CustomField.objects.create(name='url', type=CustomFieldTypeChoices.TYPE_URL) cf_url = CustomField.objects.create(name='url', type=CustomFieldTypeChoices.TYPE_URL)
cf_url.content_types.set([obj_type]) 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 = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES)
cf_select.content_types.set([obj_type]) cf_select.content_types.set([obj_type])

View File

@ -145,6 +145,7 @@ class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'payload_url': 'http://example.com/?x', 'payload_url': 'http://example.com/?x',
'http_method': 'GET', 'http_method': 'GET',
'http_content_type': 'application/foo', 'http_content_type': 'application/foo',
'conditions': None,
} }
cls.csv_data = ( cls.csv_data = (

View File

@ -6,6 +6,7 @@ from django_rq import job
from jinja2.exceptions import TemplateError from jinja2.exceptions import TemplateError
from .choices import ObjectChangeActionChoices from .choices import ObjectChangeActionChoices
from .conditions import ConditionSet
from .webhooks import generate_signature from .webhooks import generate_signature
logger = logging.getLogger('netbox.webhooks_worker') 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 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 = { context = {
'event': dict(ObjectChangeActionChoices)[event].lower(), 'event': dict(ObjectChangeActionChoices)[event].lower(),
'timestamp': timestamp, 'timestamp': timestamp,
@ -33,14 +40,14 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user
try: try:
headers.update(webhook.render_headers(context)) headers.update(webhook.render_headers(context))
except (TemplateError, ValueError) as e: 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 raise e
# Render the request body # Render the request body
try: try:
body = webhook.render_body(context) body = webhook.render_body(context)
except TemplateError as e: 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 raise e
# Prepare the HTTP request # Prepare the HTTP request
@ -51,15 +58,13 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user
'data': body.encode('utf8'), 'data': body.encode('utf8'),
} }
logger.info( logger.info(
"Sending {} request to {} ({} {})".format( f"Sending {params['method']} request to {params['url']} ({context['model']} {context['event']})"
params['method'], params['url'], context['model'], context['event']
)
) )
logger.debug(params) logger.debug(params)
try: try:
prepared_request = requests.Request(**params).prepare() prepared_request = requests.Request(**params).prepare()
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logger.error("Error forming HTTP request: {}".format(e)) logger.error(f"Error forming HTTP request: {e}")
raise e raise e
# If a secret key is defined, sign the request with a hash of the key and its content # 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) response = session.send(prepared_request, proxies=settings.HTTP_PROXIES)
if 200 <= response.status_code <= 299: if 200 <= response.status_code <= 299:
logger.info("Request succeeded; response status {}".format(response.status_code)) logger.info(f"Request succeeded; response status {response.status_code}")
return 'Status {} returned, webhook successfully processed.'.format(response.status_code) return f"Status {response.status_code} returned, webhook successfully processed."
else: 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( raise requests.exceptions.RequestException(
"Status {} returned with content '{}', webhook FAILED to process.".format( f"Status {response.status_code} returned with content '{response.content}', webhook FAILED to process."
response.status_code, response.content
)
) )

View File

@ -1,4 +1,3 @@
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404 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 rest_framework.response import Response
from ipam.models import * from ipam.models import *
from netbox.config import get_config
from utilities.constants import ADVISORY_LOCK_KEYS from utilities.constants import ADVISORY_LOCK_KEYS
from . import serializers from . import serializers
@ -160,12 +160,15 @@ class AvailableIPsMixin:
# Determine the maximum number of IPs to return # Determine the maximum number of IPs to return
else: else:
config = get_config()
PAGINATE_COUNT = config.PAGINATE_COUNT
MAX_PAGE_SIZE = config.MAX_PAGE_SIZE
try: try:
limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT)) limit = int(request.query_params.get('limit', PAGINATE_COUNT))
except ValueError: except ValueError:
limit = settings.PAGINATE_COUNT limit = PAGINATE_COUNT
if settings.MAX_PAGE_SIZE: if MAX_PAGE_SIZE:
limit = min(limit, settings.MAX_PAGE_SIZE) limit = min(limit, MAX_PAGE_SIZE)
# Calculate available IPs within the parent # Calculate available IPs within the parent
ip_list = [] ip_list = []

View File

@ -9,7 +9,6 @@ from ipam.choices import *
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
from ipam.models import * from ipam.models import *
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import OrganizationalModelSerializer
from netbox.api.serializers import PrimaryModelSerializer from netbox.api.serializers import PrimaryModelSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
@ -86,14 +85,14 @@ class RouteTargetSerializer(PrimaryModelSerializer):
# RIRs/aggregates # RIRs/aggregates
# #
class RIRSerializer(OrganizationalModelSerializer): class RIRSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
aggregate_count = serializers.IntegerField(read_only=True) aggregate_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = RIR model = RIR
fields = [ 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', 'last_updated', 'aggregate_count',
] ]
@ -117,7 +116,7 @@ class AggregateSerializer(PrimaryModelSerializer):
# VLANs # VLANs
# #
class RoleSerializer(OrganizationalModelSerializer): class RoleSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
prefix_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True)
@ -125,12 +124,12 @@ class RoleSerializer(OrganizationalModelSerializer):
class Meta: class Meta:
model = Role model = Role
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'custom_fields', 'created', 'last_updated', 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created',
'prefix_count', 'vlan_count', 'last_updated', 'prefix_count', 'vlan_count',
] ]
class VLANGroupSerializer(OrganizationalModelSerializer): class VLANGroupSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
scope_type = ContentTypeField( scope_type = ContentTypeField(
queryset=ContentType.objects.filter( queryset=ContentType.objects.filter(
@ -146,8 +145,8 @@ class VLANGroupSerializer(OrganizationalModelSerializer):
class Meta: class Meta:
model = VLANGroup model = VLANGroup
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'custom_fields', 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'tags',
'created', 'last_updated', 'vlan_count', 'custom_fields', 'created', 'last_updated', 'vlan_count',
] ]
validators = [] validators = []

View File

@ -60,7 +60,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet):
class RIRViewSet(CustomFieldModelViewSet): class RIRViewSet(CustomFieldModelViewSet):
queryset = RIR.objects.annotate( queryset = RIR.objects.annotate(
aggregate_count=count_related(Aggregate, 'rir') aggregate_count=count_related(Aggregate, 'rir')
) ).prefetch_related('tags')
serializer_class = serializers.RIRSerializer serializer_class = serializers.RIRSerializer
filterset_class = filtersets.RIRFilterSet filterset_class = filtersets.RIRFilterSet
@ -83,7 +83,7 @@ class RoleViewSet(CustomFieldModelViewSet):
queryset = Role.objects.annotate( queryset = Role.objects.annotate(
prefix_count=count_related(Prefix, 'role'), prefix_count=count_related(Prefix, 'role'),
vlan_count=count_related(VLAN, 'role') vlan_count=count_related(VLAN, 'role')
) ).prefetch_related('tags')
serializer_class = serializers.RoleSerializer serializer_class = serializers.RoleSerializer
filterset_class = filtersets.RoleFilterSet filterset_class = filtersets.RoleFilterSet
@ -138,7 +138,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
class VLANGroupViewSet(CustomFieldModelViewSet): class VLANGroupViewSet(CustomFieldModelViewSet):
queryset = VLANGroup.objects.annotate( queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group') vlan_count=count_related(VLAN, 'group')
) ).prefetch_related('tags')
serializer_class = serializers.VLANGroupSerializer serializer_class = serializers.VLANGroupSerializer
filterset_class = filtersets.VLANGroupFilterSet filterset_class = filtersets.VLANGroupFilterSet

View File

@ -122,6 +122,7 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class RIRFilterSet(OrganizationalModelFilterSet): class RIRFilterSet(OrganizationalModelFilterSet):
tag = TagFilter()
class Meta: class Meta:
model = RIR model = RIR
@ -218,6 +219,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
method='search', method='search',
label='Search', label='Search',
) )
tag = TagFilter()
class Meta: class Meta:
model = Role model = Role
@ -675,6 +677,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
cluster = django_filters.NumberFilter( cluster = django_filters.NumberFilter(
method='filter_scope' method='filter_scope'
) )
tag = TagFilter()
class Meta: class Meta:
model = VLANGroup model = VLANGroup

View File

@ -73,7 +73,7 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
] ]
class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class RIRBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=RIR.objects.all(), queryset=RIR.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -154,7 +154,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
} }
class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class RoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Role.objects.all(), queryset=Role.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -314,7 +314,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
] ]
class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput

View File

@ -94,10 +94,6 @@ class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelF
class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = RIR model = RIR
field_groups = [
['q'],
['is_private'],
]
q = forms.CharField( q = forms.CharField(
required=False, required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
@ -110,6 +106,7 @@ class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
tag = TagFilterField(model)
class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
@ -168,14 +165,12 @@ class ASNFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFor
class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = Role model = Role
field_groups = [
['q'],
]
q = forms.CharField( q = forms.CharField(
required=False, required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search') label=_('Search')
) )
tag = TagFilterField(model)
class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
@ -393,7 +388,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil
class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
field_groups = [ field_groups = [
['q'], ['q', 'tag'],
['region', 'sitegroup', 'site', 'location', 'rack'] ['region', 'sitegroup', 'site', 'location', 'rack']
] ]
model = VLANGroup model = VLANGroup
@ -432,6 +427,7 @@ class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Rack'), label=_('Rack'),
fetch_trigger='open' fetch_trigger='open'
) )
tag = TagFilterField(model)
class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):

View File

@ -84,11 +84,15 @@ class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class RIRForm(BootstrapMixin, CustomFieldModelForm): class RIRForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = RIR model = RIR
fields = [ 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): class RoleForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = Role model = Role
fields = [ fields = [
'name', 'slug', 'weight', 'description', 'name', 'slug', 'weight', 'description', 'tags',
] ]
@ -556,15 +564,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
} }
) )
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = VLANGroup model = VLANGroup
fields = [ fields = [
'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
'clustergroup', 'cluster', 'clustergroup', 'cluster', 'tags',
] ]
fieldsets = ( fieldsets = (
('VLAN Group', ('name', 'slug', 'description')), ('VLAN Group', ('name', 'slug', 'description', 'tags')),
('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
) )
widgets = { widgets = {

View File

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

View File

@ -1,10 +1,9 @@
import netaddr import netaddr
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models 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.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
@ -18,6 +17,7 @@ from ipam.fields import IPNetworkField, IPAddressField
from ipam.managers import IPAddressManager from ipam.managers import IPAddressManager
from ipam.querysets import PrefixQuerySet from ipam.querysets import PrefixQuerySet
from ipam.validators import DNSValidator from ipam.validators import DNSValidator
from netbox.config import get_config
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from virtualization.models import VirtualMachine 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): class RIR(OrganizationalModel):
""" """
A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address 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) 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): class Role(OrganizationalModel):
""" """
A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or 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) # 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() duplicate_prefixes = self.get_duplicates()
if duplicate_prefixes: if duplicate_prefixes:
raise ValidationError({ raise ValidationError({
@ -860,7 +860,7 @@ class IPAddress(PrimaryModel):
}) })
# Enforce unique IP space (if applicable) # 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() duplicate_ips = self.get_duplicates()
if duplicate_ips and ( if duplicate_ips and (
self.role not in IPADDRESS_ROLES_NONUNIQUE or self.role not in IPADDRESS_ROLES_NONUNIQUE or

View File

@ -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): class VLANGroup(OrganizationalModel):
""" """
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.

View File

@ -87,11 +87,14 @@ class RIRTable(BaseTable):
url_params={'rir_id': 'pk'}, url_params={'rir_id': 'pk'},
verbose_name='Aggregates' verbose_name='Aggregates'
) )
tags = TagColumn(
url_name='ipam:rir_list'
)
actions = ButtonsColumn(RIR) actions = ButtonsColumn(RIR)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = RIR 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') default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
@ -169,11 +172,14 @@ class RoleTable(BaseTable):
url_params={'role_id': 'pk'}, url_params={'role_id': 'pk'},
verbose_name='VLANs' verbose_name='VLANs'
) )
tags = TagColumn(
url_name='ipam:role_list'
)
actions = ButtonsColumn(Role) actions = ButtonsColumn(Role)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Role 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') default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions')

View File

@ -74,6 +74,9 @@ class VLANGroupTable(BaseTable):
url_params={'group_id': 'pk'}, url_params={'group_id': 'pk'},
verbose_name='VLANs' verbose_name='VLANs'
) )
tags = TagColumn(
url_name='ipam:vlangroup_list'
)
actions = ButtonsColumn( actions = ButtonsColumn(
model=VLANGroup, model=VLANGroup,
prepend_template=VLANGROUP_ADD_VLAN prepend_template=VLANGROUP_ADD_VLAN
@ -81,7 +84,7 @@ class VLANGroupTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VLANGroup 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') default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')

View File

@ -159,11 +159,14 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
RIR(name='RIR 3', slug='rir-3'), RIR(name='RIR 3', slug='rir-3'),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'RIR X', 'name': 'RIR X',
'slug': 'rir-x', 'slug': 'rir-x',
'is_private': True, 'is_private': True,
'description': 'A new RIR', 'description': 'A new RIR',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -232,11 +235,14 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
Role(name='Role 3', slug='role-3'), Role(name='Role 3', slug='role-3'),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Role X', 'name': 'Role X',
'slug': 'role-x', 'slug': 'role-x',
'weight': 200, 'weight': 200,
'description': 'A new role', 'description': 'A new role',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -439,10 +445,13 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]), VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'VLAN Group X', 'name': 'VLAN Group X',
'slug': 'vlan-group-x', 'slug': 'vlan-group-x',
'description': 'A new VLAN group', 'description': 'A new VLAN group',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (

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