Просмотр исходного кода

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

Daniel Sheppard 4 лет назад
Родитель
Сommit
033db83068
100 измененных файлов с 2295 добавлено и 670 удалено
  1. 1 1
      docs/additional-features/napalm.md
  2. 137 0
      docs/configuration/dynamic-settings.md
  3. 6 3
      docs/configuration/index.md
  4. 0 134
      docs/configuration/optional-settings.md
  5. 8 0
      docs/core-functionality/wireless.md
  6. 2 2
      docs/development/models.md
  7. 11 0
      docs/models/dcim/interface.md
  8. 1 0
      docs/models/extras/customfield.md
  9. 0 3
      docs/models/extras/tag.md
  10. 14 0
      docs/models/extras/webhook.md
  11. 11 0
      docs/models/wireless/wirelesslan.md
  12. 3 0
      docs/models/wireless/wirelesslangroup.md
  13. 9 0
      docs/models/wireless/wirelesslink.md
  14. 89 0
      docs/reference/conditions.md
  15. 79 1
      docs/release-notes/version-3.1.md
  16. 2 2
      docs/rest-api/overview.md
  17. 4 0
      mkdocs.yml
  18. 6 8
      netbox/circuits/api/serializers.py
  19. 1 1
      netbox/circuits/api/views.py
  20. 1 0
      netbox/circuits/filtersets.py
  21. 1 1
      netbox/circuits/forms/bulk_edit.py
  22. 1 3
      netbox/circuits/forms/filtersets.py
  23. 5 1
      netbox/circuits/forms/models.py
  24. 20 0
      netbox/circuits/migrations/0003_extend_tag_support.py
  25. 21 0
      netbox/circuits/migrations/0004_rename_cable_peer.py
  26. 3 3
      netbox/circuits/models.py
  27. 4 1
      netbox/circuits/tables.py
  28. 3 0
      netbox/circuits/tests/test_views.py
  29. 56 49
      netbox/dcim/api/serializers.py
  30. 21 18
      netbox/dcim/api/views.py
  31. 4 2
      netbox/dcim/choices.py
  32. 2 0
      netbox/dcim/constants.py
  33. 24 2
      netbox/dcim/filtersets.py
  34. 20 13
      netbox/dcim/forms/bulk_edit.py
  35. 16 31
      netbox/dcim/forms/bulk_import.py
  36. 36 18
      netbox/dcim/forms/filtersets.py
  37. 67 14
      netbox/dcim/forms/models.py
  38. 31 2
      netbox/dcim/forms/object_create.py
  39. 6 0
      netbox/dcim/graphql/types.py
  40. 5 1
      netbox/dcim/management/commands/trace_paths.py
  41. 0 17
      netbox/dcim/migrations/0134_interface_wwn.py
  42. 23 0
      netbox/dcim/migrations/0134_interface_wwn_bridge.py
  43. 1 1
      netbox/dcim/migrations/0135_tenancy_extensions.py
  44. 50 0
      netbox/dcim/migrations/0138_extend_tag_support.py
  45. 91 0
      netbox/dcim/migrations/0139_rename_cable_peer.py
  46. 49 0
      netbox/dcim/migrations/0140_wireless.py
  47. 1 1
      netbox/dcim/models/__init__.py
  48. 9 9
      netbox/dcim/models/cables.py
  49. 180 55
      netbox/dcim/models/device_components.py
  50. 5 5
      netbox/dcim/models/devices.py
  51. 2 2
      netbox/dcim/models/power.py
  52. 12 8
      netbox/dcim/models/racks.py
  53. 3 3
      netbox/dcim/models/sites.py
  54. 7 33
      netbox/dcim/signals.py
  55. 66 15
      netbox/dcim/svg.py
  56. 56 47
      netbox/dcim/tables/devices.py
  57. 5 1
      netbox/dcim/tables/devicetypes.py
  58. 2 2
      netbox/dcim/tables/power.py
  59. 4 1
      netbox/dcim/tables/racks.py
  60. 14 3
      netbox/dcim/tables/sites.py
  61. 21 3
      netbox/dcim/tables/template_code.py
  62. 5 1
      netbox/dcim/tests/test_api.py
  63. 3 3
      netbox/dcim/tests/test_cablepaths.py
  64. 51 15
      netbox/dcim/tests/test_filtersets.py
  65. 4 4
      netbox/dcim/tests/test_models.py
  66. 28 3
      netbox/dcim/tests/test_views.py
  67. 27 0
      netbox/dcim/utils.py
  68. 120 2
      netbox/extras/admin.py
  69. 1 1
      netbox/extras/api/serializers.py
  70. 2 0
      netbox/extras/choices.py
  71. 144 0
      netbox/extras/conditions.py
  72. 1 0
      netbox/extras/forms/__init__.py
  73. 1 1
      netbox/extras/forms/bulk_edit.py
  74. 79 0
      netbox/extras/forms/config.py
  75. 4 2
      netbox/extras/forms/customfields.py
  76. 1 0
      netbox/extras/forms/models.py
  77. 18 0
      netbox/extras/migrations/0063_webhook_conditions.py
  78. 20 0
      netbox/extras/migrations/0064_configrevision.py
  79. 2 1
      netbox/extras/models/__init__.py
  80. 4 0
      netbox/extras/models/customfields.py
  81. 80 56
      netbox/extras/models/models.py
  82. 13 1
      netbox/extras/signals.py
  83. 199 0
      netbox/extras/tests/test_conditions.py
  84. 34 6
      netbox/extras/tests/test_customfields.py
  85. 3 0
      netbox/extras/tests/test_forms.py
  86. 1 0
      netbox/extras/tests/test_views.py
  87. 15 12
      netbox/extras/webhooks_worker.py
  88. 8 5
      netbox/ipam/api/mixins.py
  89. 8 9
      netbox/ipam/api/serializers.py
  90. 3 3
      netbox/ipam/api/views.py
  91. 3 0
      netbox/ipam/filtersets.py
  92. 3 3
      netbox/ipam/forms/bulk_edit.py
  93. 4 8
      netbox/ipam/forms/filtersets.py
  94. 16 4
      netbox/ipam/forms/models.py
  95. 30 0
      netbox/ipam/migrations/0051_extend_tag_support.py
  96. 6 6
      netbox/ipam/models/ip.py
  97. 1 1
      netbox/ipam/models/vlans.py
  98. 8 2
      netbox/ipam/tables/ip.py
  99. 4 1
      netbox/ipam/tables/vlans.py
  100. 9 0
      netbox/ipam/tests/test_views.py

+ 1 - 1
docs/additional-features/napalm.md

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

+ 137 - 0
docs/configuration/dynamic-settings.md

@@ -0,0 +1,137 @@
+# Dynamic Configuration Settings
+
+These configuration parameters are primarily controlled via NetBox's admin interface (under Admin > Extras > Configuration Revisions). These setting may also be overridden in `configuration.py`; this will prevent them from being modified via the UI.
+
+---
+
+## ALLOWED_URL_SCHEMES
+
+Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
+
+A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all of the default values as well (excluding those schemes which are not desirable).
+
+---
+
+## BANNER_TOP
+
+## BANNER_BOTTOM
+
+Setting these variables will display custom content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
+
+```python
+BANNER_TOP = 'Your banner text'
+BANNER_BOTTOM = BANNER_TOP
+```
+
+---
+
+## BANNER_LOGIN
+
+This defines custom content to be displayed on the login page above the login form. HTML is allowed.
+
+---
+
+## ENFORCE_GLOBAL_UNIQUE
+
+Default: False
+
+By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to True.
+
+---
+
+## MAINTENANCE_MODE
+
+Default: False
+
+Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
+
+---
+
+## MAPS_URL
+
+Default: `https://maps.google.com/?q=` (Google Maps)
+
+This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it.
+
+---
+
+## MAX_PAGE_SIZE
+
+Default: 1000
+
+A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`.
+
+---
+
+## NAPALM_USERNAME
+
+## NAPALM_PASSWORD
+
+NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../additional-features/napalm.md), if installed. Both parameters are optional.
+
+!!! note
+    If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed.
+
+---
+
+## NAPALM_ARGS
+
+A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
+
+```python
+NAPALM_ARGS = {
+    'api_key': '472071a93b60a1bd1fafb401d9f8ef41',
+    'port': 2222,
+}
+```
+
+Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument:
+
+```python
+NAPALM_USERNAME = 'username'
+NAPALM_PASSWORD = 'MySecretPassword'
+NAPALM_ARGS = {
+    'secret': NAPALM_PASSWORD,
+    # Include any additional args here
+}
+```
+
+---
+
+## NAPALM_TIMEOUT
+
+Default: 30 seconds
+
+The amount of time (in seconds) to wait for NAPALM to connect to a device.
+
+---
+
+## PAGINATE_COUNT
+
+Default: 50
+
+The default maximum number of objects to display per page within each list of objects.
+
+---
+
+## PREFER_IPV4
+
+Default: False
+
+When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead.
+
+---
+
+## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
+
+Default: 22
+
+Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`.
+
+---
+
+## RACK_ELEVATION_DEFAULT_UNIT_WIDTH
+
+Default: 220
+
+Default width (in pixels) of a unit within a rack elevation.

+ 6 - 3
docs/configuration/index.md

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

+ 0 - 134
docs/configuration/optional-settings.md

@@ -13,33 +13,6 @@ ADMINS = [
 
 
 ---
 ---
 
 
-## ALLOWED_URL_SCHEMES
-
-Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
-
-A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all of the default values as well (excluding those schemes which are not desirable).
-
----
-
-## BANNER_TOP
-
-## BANNER_BOTTOM
-
-Setting these variables will display custom content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
-
-```python
-BANNER_TOP = 'Your banner text'
-BANNER_BOTTOM = BANNER_TOP
-```
-
----
-
-## BANNER_LOGIN
-
-This defines custom content to be displayed on the login page above the login form. HTML is allowed.
-
----
-
 ## BASE_PATH
 ## 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`

+ 8 - 0
docs/core-functionality/wireless.md

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

+ 2 - 2
docs/development/models.md

@@ -19,8 +19,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
 | Type               | Change Logging   | Webhooks         | Custom Fields    | Export Templates | Tags             | Journaling       | Nesting          |
 | 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: |                  |                  |                  |
-| Nested Group       | :material-check: | :material-check: | :material-check: | :material-check: |                  |                  | :material-check: |
+| Organizational     | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: |                  |                  |
+| Nested Group       | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: |                  | :material-check: |
 | Component          | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: |                  |                  |
 | Component          | :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: |                  |                  |                  |                  |
 
 

+ 11 - 0
docs/models/dcim/interface.md

@@ -11,6 +11,17 @@ Interfaces may be physical or virtual in nature, but only physical interfaces ma
 
 
 Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
 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.)

+ 1 - 0
docs/models/extras/customfield.md

@@ -16,6 +16,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net
 * Boolean: True or false
 * 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
 
 

+ 0 - 3
docs/models/extras/tag.md

@@ -15,6 +15,3 @@ The `tag` filter can be specified multiple times to match only objects which hav
 ```no-highlight
 ```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.

+ 14 - 0
docs/models/extras/webhook.md

@@ -17,6 +17,7 @@ A webhook is a mechanism for conveying to some external system a change that too
 * **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below).
 * **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).

+ 11 - 0
docs/models/wireless/wirelesslan.md

@@ -0,0 +1,11 @@
+# Wireless LANs
+
+A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups.
+
+An interface may be attached to multiple wireless LANs, provided they are all operating on the same channel. Only wireless interfaces may be attached to wireless LANs.
+
+Each wireless LAN may have authentication attributes associated with it, including:
+
+* Authentication type
+* Cipher
+* Pre-shared key

+ 3 - 0
docs/models/wireless/wirelesslangroup.md

@@ -0,0 +1,3 @@
+# Wireless LAN Groups
+
+Wireless LAN groups can be used to organize and classify wireless LANs. These groups are hierarchical: groups can be nested within parent groups. However, each wireless LAN may assigned only to one group.

+ 9 - 0
docs/models/wireless/wirelesslink.md

@@ -0,0 +1,9 @@
+# Wireless Links
+
+A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model.
+
+Each wireless link may have authentication attributes associated with it, including:
+
+* Authentication type
+* Cipher
+* Pre-shared key

+ 89 - 0
docs/reference/conditions.md

@@ -0,0 +1,89 @@
+# Conditions
+
+Conditions are NetBox's mechanism for evaluating whether a set data meets a prescribed set of conditions. It allows the author to convey simple logic by declaring an arbitrary number of attribute-value-operation tuples nested within a hierarchy of logical AND and OR statements.
+
+## Conditions
+
+A condition is expressed as a JSON object with the following keys:
+
+| Key name | Required | Default | Description |
+|----------|----------|---------|-------------|
+| attr     | Yes      | -       | Name of the key within the data being evaluated |
+| value    | Yes      | -       | The reference value to which the given data will be compared |
+| op       | No       | `eq`    | The logical operation to be performed |
+| negate   | No       | False   | Negate (invert) the result of the condition's evaluation |
+
+### Available Operations
+
+* `eq`: Equals
+* `gt`: Greater than
+* `gte`: Greater than or equal to
+* `lt`: Less than
+* `lte`: Less than or equal to
+* `in`: Is present within a list of values
+* `contains`: Contains the specified value
+
+### Examples
+
+`name` equals "foobar":
+
+```json
+{
+  "attr": "name",
+  "value": "foobar"
+}
+```
+
+`asn` is greater than 65000:
+
+```json
+{
+  "attr": "asn",
+  "value": 65000,
+  "op": "gt"
+}
+```
+
+`status` is not "planned" or "staging":
+
+```json
+{
+  "attr": "status",
+  "value": ["planned", "staging"],
+  "op": "in",
+  "negate": true
+}
+```
+
+## Condition Sets
+
+Multiple conditions can be combined into nested sets using AND or OR logic. This is done by declaring a JSON object with a single key (`and` or `or`) containing a list of condition objects and/or child condition sets.
+
+### Examples
+
+`status` is "active" and `primary_ip` is defined _or_ the "exempt" tag is applied.
+
+```json
+{
+  "or": [
+    {
+      "and": [
+        {
+          "attr": "status",
+          "value": "active"
+        },
+        {
+          "attr": "primary_ip",
+          "value": "",
+          "negate": true
+        }
+      ]
+    },
+    {
+      "attr": "tags",
+      "value": "exempt",
+      "op": "contains"
+    }
+  ]
+}
+```

+ 79 - 1
docs/release-notes/version-3.1.md

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

+ 2 - 2
docs/rest-api/overview.md

@@ -308,7 +308,7 @@ Vary: Accept
 }
 }
 ```
 ```
 
 
-The default page is determined by the [`PAGINATE_COUNT`](../configuration/optional-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
+The default page is determined by the [`PAGINATE_COUNT`](../configuration/dynamic-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
 
 
 ```
 ```
 http://netbox/api/dcim/devices/?limit=100
 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.

+ 4 - 0
mkdocs.yml

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

+ 6 - 8
netbox/circuits/api/serializers.py

@@ -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 (
-    OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
-)
+from netbox.api.serializers import 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',
         ]
         ]

+ 1 - 1
netbox/circuits/api/views.py

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

+ 1 - 0
netbox/circuits/filtersets.py

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

+ 1 - 1
netbox/circuits/forms/bulk_edit.py

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

+ 1 - 3
netbox/circuits/forms/filtersets.py

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

+ 5 - 1
netbox/circuits/forms/models.py

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

+ 20 - 0
netbox/circuits/migrations/0003_extend_tag_support.py

@@ -0,0 +1,20 @@
+# Generated by Django 3.2.8 on 2021-10-21 14:50
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0062_clear_secrets_changelog'),
+        ('circuits', '0002_squashed_0029'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuittype',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 21 - 0
netbox/circuits/migrations/0004_rename_cable_peer.py

@@ -0,0 +1,21 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0003_extend_tag_support'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='circuittermination',
+            old_name='_cable_peer_id',
+            new_name='_link_peer_id',
+        ),
+        migrations.RenameField(
+            model_name='circuittermination',
+            old_name='_cable_peer_type',
+            new_name='_link_peer_type',
+        ),
+    ]

+ 3 - 3
netbox/circuits/models.py

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

+ 4 - 1
netbox/circuits/tables.py

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

+ 3 - 0
netbox/circuits/tests/test_views.py

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

+ 56 - 49
netbox/dcim/api/serializers.py

@@ -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,
-    WritableNestedSerializer,
+    NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, 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):
-    cable_peer_type = serializers.SerializerMethodField(read_only=True)
-    cable_peer = serializers.SerializerMethodField(read_only=True)
+class LinkTerminationSerializer(serializers.ModelSerializer):
+    link_peer_type = serializers.SerializerMethodField(read_only=True)
+    link_peer = serializers.SerializerMethodField(read_only=True)
     _occupied = serializers.SerializerMethodField(read_only=True)
     _occupied = serializers.SerializerMethodField(read_only=True)
 
 
-    def get_cable_peer_type(self, obj):
-        if obj._cable_peer is not None:
-            return f'{obj._cable_peer._meta.app_label}.{obj._cable_peer._meta.model_name}'
+    def get_link_peer_type(self, obj):
+        if obj._link_peer is not None:
+            return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}'
         return None
         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:
-            serializer = get_serializer_for_model(obj._cable_peer, prefix='Nested')
+        if obj._link_peer is not None:
+            serializer = get_serializer_for_model(obj._link_peer, prefix='Nested')
             context = {'request': self.context['request']}
             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',
-            'site_count', '_depth',
+            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'site_count', '_depth',
         ]
         ]
 
 
 
 
@@ -100,8 +102,8 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
     class Meta:
     class Meta:
         model = SiteGroup
         model = SiteGroup
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
-            'site_count', '_depth',
+            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'site_count', '_depth',
         ]
         ]
 
 
 
 
@@ -147,20 +149,20 @@ class LocationSerializer(NestedGroupModelSerializer):
     class Meta:
     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',
-            'rack_count',
+            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'rack_count',
         ]
         ]
 
 
 
 
@@ -231,10 +233,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
         default=RackElevationDetailRenderChoices.RENDER_JSON
         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',
-            'last_updated', 'device_count', 'virtualmachine_count',
+            'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'tags', 'custom_fields',
+            'created', 'last_updated', 'device_count', 'virtualmachine_count',
         ]
         ]
 
 
 
 
-class PlatformSerializer(OrganizationalModelSerializer):
+class PlatformSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
     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',
-            'wwn', 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable',
-            'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+            'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu',
+            'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+            'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link',
+            'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
             'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
             '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',
         ]
         ]

+ 21 - 18
netbox/dcim/api/views.py

@@ -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
-        optional_args = settings.NAPALM_ARGS.copy()
+
+        config = get_config()
+        username = config.NAPALM_USERNAME
+        password = config.NAPALM_PASSWORD
+        timeout = config.NAPALM_TIMEOUT
+        optional_args = config.NAPALM_ARGS.copy()
         if device.platform.napalm_args is not None:
         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

+ 4 - 2
netbox/dcim/choices.py

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

+ 2 - 0
netbox/dcim/constants.py

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

+ 24 - 2
netbox/dcim/filtersets.py

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

+ 20 - 13
netbox/dcim/forms/bulk_edit.py

@@ -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',
-        'mode',
+        'label', 'type', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
+        'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
     ]),
     ]),
     BootstrapMixin,
     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',
-            'tagged_vlans',
+            'label', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel',
+            'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans',
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     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
 
 

+ 16 - 31
netbox/dcim/forms/bulk_import.py

@@ -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',
-            'mtu', 'mgmt_only', 'description', 'mode',
+            'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address',
+            'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+            'rf_channel_width', 'tx_power',
         )
         )
 
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit LAG choices to interfaces belonging to this device (or virtual chassis)
-        device = None
-        if self.is_bound and 'device' in self.data:
-            try:
-                device = self.fields['device'].to_python(self.data['device'])
-            except forms.ValidationError:
-                pass
-        if device and device.virtual_chassis:
-            self.fields['lag'].queryset = Interface.objects.filter(
-                Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis),
-                type=InterfaceTypeChoices.TYPE_LAG
-            )
-            self.fields['parent'].queryset = Interface.objects.filter(
-                Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis)
-            )
-        elif device:
-            self.fields['lag'].queryset = Interface.objects.filter(
-                device=device,
-                type=InterfaceTypeChoices.TYPE_LAG
-            )
-            self.fields['parent'].queryset = Interface.objects.filter(device=device)
-        else:
-            self.fields['lag'].queryset = Interface.objects.none()
-            self.fields['parent'].queryset = Interface.objects.none()
-
     def clean_enabled(self):
     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'
     )
     )

+ 36 - 18
netbox/dcim/forms/filtersets.py

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

+ 67 - 14
netbox/dcim/forms/models.py

@@ -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',
-            'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
+            'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu',
+            'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+            'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags',
         ]
         ]
         widgets = {
         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)

+ 31 - 2
netbox/dcim/forms/object_create.py

@@ -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(),
+        label='Wireless role'
+    )
+    rf_channel = forms.ChoiceField(
+        choices=add_blank_choice(WirelessChannelChoices),
+        required=False,
         widget=StaticSelect(),
         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',
-        'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
+        'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address',
+        'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+        'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
     )
     )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):

+ 6 - 0
netbox/dcim/graphql/types.py

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

+ 5 - 1
netbox/dcim/management/commands/trace_paths.py

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

+ 0 - 17
netbox/dcim/migrations/0134_interface_wwn.py

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

+ 23 - 0
netbox/dcim/migrations/0134_interface_wwn_bridge.py

@@ -0,0 +1,23 @@
+import dcim.fields
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0133_port_colors'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='wwn',
+            field=dcim.fields.WWNField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='bridge',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='dcim.interface'),
+        ),
+    ]

+ 1 - 1
netbox/dcim/migrations/0135_tenancy_extensions.py

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

+ 50 - 0
netbox/dcim/migrations/0138_extend_tag_support.py

@@ -0,0 +1,50 @@
+# Generated by Django 3.2.8 on 2021-10-21 14:50
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0062_clear_secrets_changelog'),
+        ('dcim', '0137_relax_uniqueness_constraints'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='devicerole',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='location',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='manufacturer',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='platform',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='rackrole',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='region',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='sitegroup',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 91 - 0
netbox/dcim/migrations/0139_rename_cable_peer.py

@@ -0,0 +1,91 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0138_extend_tag_support'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='consoleport',
+            old_name='_cable_peer_id',
+            new_name='_link_peer_id',
+        ),
+        migrations.RenameField(
+            model_name='consoleport',
+            old_name='_cable_peer_type',
+            new_name='_link_peer_type',
+        ),
+        migrations.RenameField(
+            model_name='consoleserverport',
+            old_name='_cable_peer_id',
+            new_name='_link_peer_id',
+        ),
+        migrations.RenameField(
+            model_name='consoleserverport',
+            old_name='_cable_peer_type',
+            new_name='_link_peer_type',
+        ),
+        migrations.RenameField(
+            model_name='frontport',
+            old_name='_cable_peer_id',
+            new_name='_link_peer_id',
+        ),
+        migrations.RenameField(
+            model_name='frontport',
+            old_name='_cable_peer_type',
+            new_name='_link_peer_type',
+        ),
+        migrations.RenameField(
+            model_name='interface',
+            old_name='_cable_peer_id',
+            new_name='_link_peer_id',
+        ),
+        migrations.RenameField(
+            model_name='interface',
+            old_name='_cable_peer_type',
+            new_name='_link_peer_type',
+        ),
+        migrations.RenameField(
+            model_name='powerfeed',
+            old_name='_cable_peer_id',
+            new_name='_link_peer_id',
+        ),
+        migrations.RenameField(
+            model_name='powerfeed',
+            old_name='_cable_peer_type',
+            new_name='_link_peer_type',
+        ),
+        migrations.RenameField(
+            model_name='poweroutlet',
+            old_name='_cable_peer_id',
+            new_name='_link_peer_id',
+        ),
+        migrations.RenameField(
+            model_name='poweroutlet',
+            old_name='_cable_peer_type',
+            new_name='_link_peer_type',
+        ),
+        migrations.RenameField(
+            model_name='powerport',
+            old_name='_cable_peer_id',
+            new_name='_link_peer_id',
+        ),
+        migrations.RenameField(
+            model_name='powerport',
+            old_name='_cable_peer_type',
+            new_name='_link_peer_type',
+        ),
+        migrations.RenameField(
+            model_name='rearport',
+            old_name='_cable_peer_id',
+            new_name='_link_peer_id',
+        ),
+        migrations.RenameField(
+            model_name='rearport',
+            old_name='_cable_peer_type',
+            new_name='_link_peer_type',
+        ),
+    ]

+ 49 - 0
netbox/dcim/migrations/0140_wireless.py

@@ -0,0 +1,49 @@
+from django.db import migrations, models
+import django.core.validators
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0139_rename_cable_peer'),
+        ('wireless', '0001_wireless'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='rf_role',
+            field=models.CharField(blank=True, max_length=30),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='rf_channel',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='rf_channel_frequency',
+            field=models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='rf_channel_width',
+            field=models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='tx_power',
+            field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(127)]),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='wireless_lans',
+            field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='wireless_link',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wireless.wirelesslink'),
+        ),
+    ]

+ 1 - 1
netbox/dcim/models/__init__.py

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

+ 9 - 9
netbox/dcim/models/cables.py

@@ -64,8 +64,8 @@ class Cable(PrimaryModel):
     )
     )
     status = models.CharField(
     status = models.CharField(
         max_length=50,
         max_length=50,
-        choices=CableStatusChoices,
-        default=CableStatusChoices.STATUS_CONNECTED
+        choices=LinkStatusChoices,
+        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:
-            if node.cable.status != CableStatusChoices.STATUS_CONNECTED:
+        while node.link is not None:
+            if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED:
                 is_active = False
                 is_active = False
 
 
-            # Follow the cable to its far-end termination
-            path.append(object_to_path_node(node.cable))
-            peer_termination = node.get_cable_peer()
+            # Follow the link to its far-end termination
+            path.append(object_to_path_node(node.link))
+            peer_termination = node.get_link_peer()
 
 
             # Follow a FrontPort to its corresponding RearPort
             # Follow a FrontPort to its corresponding RearPort
             if isinstance(peer_termination, FrontPort):
             if isinstance(peer_termination, FrontPort):

+ 180 - 55
netbox/dcim/models/device_components.py

@@ -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
-    CircuitTermination instances). The `cable` field indicates the Cable instance which is terminated to this instance.
+    An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples
+    include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields
+    reference the attached Cable or WirelessLink instance, respectively.
 
 
-    `_cable_peer` is a GenericForeignKey used to cache the far-end CableTermination on the local instance; this is a
-    shortcut to referencing `cable.termination_b`, for example. `_cable_peer` is set or cleared by the receivers in
-    dcim.signals when a Cable instance is created or deleted, respectively.
+    `_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination on the local instance; this is a
+    shortcut to referencing `instance.link.termination_b`, for example.
     """
     """
     cable = models.ForeignKey(
     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(
-        ct_field='_cable_peer_type',
-        fk_field='_cable_peer_id'
+    _link_peer = GenericForeignKey(
+        ct_field='_link_peer_type',
+        fk_field='_link_peer_id'
     )
     )
     mark_connected = models.BooleanField(
     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):
-        return self._cable_peer
+    def get_link_peer(self):
+        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,
-                _cable_peer_id__in=outlet_ids
+                _link_peer_type=poweroutlet_ct,
+                _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,
-                        _cable_peer_id__in=outlet_ids
+                        _link_peer_type=poweroutlet_ct,
+                        _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
-        if not self.is_connectable and self.cable:
+        # Virtual Interfaces cannot have a Cable attached
+        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
-        if not self.is_connectable and self.mark_connected:
+        # Virtual Interfaces cannot be marked as connected
+        if self.is_virtual and self.mark_connected:
             raise ValidationError({
             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,26 +647,27 @@ 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
-        if self.pk and self.parent_id == self.pk:
-            raise ValidationError({'parent': "An interface cannot be its own parent."})
+        # Bridge validation
 
 
-        # A physical interface cannot have a parent interface
-        if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
-            raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."})
+        # An interface cannot be bridged to itself
+        if self.pk and self.bridge_id == self.pk:
+            raise ValidationError({'bridge': "An interface cannot be bridged to itself."})
 
 
-        # An interface's LAG must belong to the same device or virtual chassis
-        if self.lag and self.lag.device != self.device:
+        # 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:
             if self.device.virtual_chassis is None:
                 raise ValidationError({
                 raise ValidationError({
-                    'lag': f"The selected LAG interface ({self.lag}) belongs to a different device ({self.lag.device})."
+                    'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different device "
+                              f"({self.bridge.device})."
                 })
                 })
-            elif self.lag.device.virtual_chassis != self.device.virtual_chassis:
+            elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
                 raise ValidationError({
                 raise ValidationError({
-                    'lag': f"The selected LAG interface ({self.lag}) belongs to {self.lag.device}, which is not part "
-                           f"of virtual chassis {self.device.virtual_chassis}."
+                    '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
         # A virtual interface cannot have a parent LAG
         if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
         if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
             raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."})
             raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."})
@@ -603,16 +676,64 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
         if self.pk and self.lag_id == self.pk:
         if self.pk and self.lag_id == self.pk:
             raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
             raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
 
 
+        # An interface's LAG must belong to the same device or virtual chassis
+        if self.lag and self.lag.device != self.device:
+            if self.device.virtual_chassis is None:
+                raise ValidationError({
+                    'lag': f"The selected LAG interface ({self.lag}) belongs to a different device ({self.lag.device})."
+                })
+            elif self.lag.device.virtual_chassis != self.device.virtual_chassis:
+                raise ValidationError({
+                    'lag': f"The selected LAG interface ({self.lag}) belongs to {self.lag.device}, which is not part "
+                           f"of virtual chassis {self.device.virtual_chassis}."
+                })
+
+        # Wireless validation
+
+        # RF role & channel may only be set for wireless interfaces
+        if self.rf_role and not self.is_wireless:
+            raise ValidationError({'rf_role': "Wireless role may be set only on wireless interfaces."})
+        if self.rf_channel and not self.is_wireless:
+            raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."})
+
+        # Validate channel frequency against interface type and selected channel (if any)
+        if self.rf_channel_frequency:
+            if not self.is_wireless:
+                raise ValidationError({
+                    'rf_channel_frequency': "Channel frequency may be set only on wireless interfaces.",
+                })
+            if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'):
+                raise ValidationError({
+                    'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
+                })
+        elif self.rf_channel:
+            self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
+
+        # Validate channel width against interface type and selected channel (if any)
+        if self.rf_channel_width:
+            if not self.is_wireless:
+                raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."})
+            if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
+                raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."})
+        elif self.rf_channel:
+            self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
+
+        # VLAN validation
+
         # Validate untagged VLAN
         # 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 "
-                                 "device, or it must be global".format(self.untagged_vlan)
+                'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the "
+                                 f"interface's parent device, or it must be global."
             })
             })
 
 
     @property
     @property
-    def is_connectable(self):
-        return self.type not in NONCONNECTABLE_IFACE_TYPES
+    def _occupied(self):
+        return super()._occupied or bool(self.wireless_link_id)
+
+    @property
+    def is_wired(self):
+        return not self.is_virtual and not self.is_wireless
 
 
     @property
     @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.
     """
     """

+ 5 - 5
netbox/dcim/models/devices.py

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

+ 2 - 2
netbox/dcim/models/power.py

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

+ 12 - 8
netbox/dcim/models/racks.py

@@ -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_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
+            unit_width=None,
+            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),
-            _cable_peer_id__in=powerfeeds.values_list('id', flat=True)
+            _link_peer_type=ContentType.objects.get_for_model(PowerFeed),
+            _link_peer_id__in=powerfeeds.values_list('id', flat=True)
         )
         )
         poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports)
         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),
-            _cable_peer_id__in=poweroutlets.values_list('id', flat=True)
+            _link_peer_type=ContentType.objects.get_for_model(PowerOutlet),
+            _link_peer_id__in=poweroutlets.values_list('id', flat=True)
         ).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0
         ).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)

+ 3 - 3
netbox/dcim/models/sites.py

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

+ 7 - 33
netbox/dcim/signals.py

@@ -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
-
-
-def create_cablepath(node):
-    """
-    Create CablePaths for all paths originating from the specified node.
-    """
-    cp = CablePath.from_origin(node)
-    if cp:
-        try:
-            cp.save()
-        except Exception as e:
-            print(node, node.pk)
-            raise e
-
-
-def rebuild_paths(obj):
-    """
-    Rebuild all CablePaths which traverse the specified node
-    """
-    cable_paths = CablePath.objects.filter(path__contains=obj)
-
-    with transaction.atomic():
-        for cp in cable_paths:
-            cp.delete()
-            if cp.origin:
-                create_cablepath(cp.origin)
+from .utils import create_cablepath, rebuild_paths
 
 
 
 
 #
 #
@@ -109,12 +83,12 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
     if instance.termination_a.cable != instance:
     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):

+ 66 - 15
netbox/dcim/svg.py

@@ -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 = [
-                    f'Cable {connector}',
-                    connector.get_status_display()
-                ]
-                if connector.type:
-                    cable_labels.append(connector.get_type_display())
-                if connector.length and connector.length_unit:
-                    cable_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
-                cable = self._draw_cable(
-                    color=connector.color or '000000',
-                    url=connector.get_absolute_url(),
-                    labels=cable_labels
-                )
-                connectors.append(cable)
+                if type(connector) is Cable:
+                    connector_labels = [
+                        f'Cable {connector}',
+                        connector.get_status_display()
+                    ]
+                    if connector.type:
+                        connector_labels.append(connector.get_type_display())
+                    if connector.length and connector.length_unit:
+                        connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
+                    cable = self._draw_cable(
+                        color=connector.color or '000000',
+                        url=connector.get_absolute_url(),
+                        labels=connector_labels
+                    )
+                    connectors.append(cable)
+
+                # WirelessLink
+                elif type(connector) is WirelessLink:
+                    connector_labels = [
+                        f'Wireless link {connector}',
+                        connector.get_status_display()
+                    ]
+                    if connector.ssid:
+                        connector_labels.append(connector.ssid)
+                    wirelesslink = self._draw_wirelesslink(
+                        url=connector.get_absolute_url(),
+                        labels=connector_labels
+                    )
+                    connectors.append(wirelesslink)
 
 
                 # Far end termination
                 # Far end termination
                 termination = self._draw_box(
                 termination = self._draw_box(

+ 56 - 47
netbox/dcim/tables/devices.py

@@ -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 (
-    CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
-    FRONTPORT_BUTTONS, INTERFACE_BUTTONS, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, POWEROUTLET_BUTTONS,
-    POWERPORT_BUTTONS, REARPORT_BUTTONS,
-)
+from .template_code import *
 
 
 __all__ = (
 __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(
-            linkify=True,
-            order_by=('primary_ip4', 'primary_ip6'),
-            verbose_name='IP Address'
-        )
-    else:
-        primary_ip = tables.Column(
-            linkify=True,
-            order_by=('primary_ip6', 'primary_ip4'),
-            verbose_name='IP Address'
-        )
+    primary_ip = tables.Column(
+        linkify=True,
+        order_by=('primary_ip4', 'primary_ip6'),
+        verbose_name='IP Address'
+    )
     primary_ip4 = tables.Column(
     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(
-        accessor='_cable_peer',
-        template_code=CABLETERMINATION,
+    link_peer = TemplateColumn(
+        accessor='_link_peer',
+        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',
-            'untagged_vlan', 'tagged_vlans',
+            'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
+            'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
+            'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
         )
         )
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
         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'
-                      '{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}ethernet'
+        template_code='<i class="mdi mdi-{% if record.mgmt_only %}wrench{% elif record.is_lag %}drag-horizontal-variant'
+                      '{% 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,
-        verbose_name='Parent'
+        linkify=True
+    )
+    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',
-            'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses',
-            'untagged_vlan', 'tagged_vlans', 'actions',
+            'pk', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode',
+            'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', 'description',
+            'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
+            'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions',
         )
         )
         order_by = ('name',)
         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

+ 5 - 1
netbox/dcim/tables/devicetypes.py

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

+ 2 - 2
netbox/dcim/tables/power.py

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

+ 4 - 1
netbox/dcim/tables/racks.py

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

+ 14 - 3
netbox/dcim/tables/sites.py

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

+ 21 - 3
netbox/dcim/tables/template_code.py

@@ -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 %}
 """
 """
 
 

+ 5 - 1
netbox/dcim/tests/test_api.py

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

+ 3 - 3
netbox/dcim/tests/test_cablepaths.py

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

+ 51 - 15
netbox/dcim/tests/test_filtersets.py

@@ -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 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True),
-            Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False),
+            Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40),
+            Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40),
+            Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40),
+            Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22),
+            Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20),
         )
         )
         Interface.objects.bulk_create(interfaces)
         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[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
-        Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=CableStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
-        Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
-        Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
-        Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
+        Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+        Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+        Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+        Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+        Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
+        Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
         Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
         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):

+ 4 - 4
netbox/dcim/tests/test_models.py

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

+ 28 - 3
netbox/dcim/tests/test_views.py

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

+ 27 - 0
netbox/dcim/utils.py

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

+ 120 - 2
netbox/extras/admin.py

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

+ 1 - 1
netbox/extras/api/serializers.py

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

+ 2 - 0
netbox/extras/choices.py

@@ -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 - 0
netbox/extras/conditions.py

@@ -0,0 +1,144 @@
+import functools
+import re
+
+__all__ = (
+    'Condition',
+    'ConditionSet',
+)
+
+
+AND = 'and'
+OR = 'or'
+
+
+def is_ruleset(data):
+    """
+    Determine whether the given dictionary looks like a rule set.
+    """
+    return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR)
+
+
+class Condition:
+    """
+    An individual conditional rule that evaluates a single attribute and its value.
+
+    :param attr: The name of the attribute being evaluated
+    :param value: The value being compared
+    :param op: The logical operation to use when evaluating the value (default: 'eq')
+    """
+    EQ = 'eq'
+    GT = 'gt'
+    GTE = 'gte'
+    LT = 'lt'
+    LTE = 'lte'
+    IN = 'in'
+    CONTAINS = 'contains'
+    REGEX = 'regex'
+
+    OPERATORS = (
+        EQ, GT, GTE, LT, LTE, IN, CONTAINS, REGEX
+    )
+
+    TYPES = {
+        str: (EQ, CONTAINS, REGEX),
+        bool: (EQ, CONTAINS),
+        int: (EQ, GT, GTE, LT, LTE, CONTAINS),
+        float: (EQ, GT, GTE, LT, LTE, CONTAINS),
+        list: (EQ, IN, CONTAINS)
+    }
+
+    def __init__(self, attr, value, op=EQ, negate=False):
+        if op not in self.OPERATORS:
+            raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}")
+        if type(value) not in self.TYPES:
+            raise ValueError(f"Unsupported value type: {type(value)}")
+        if op not in self.TYPES[type(value)]:
+            raise ValueError(f"Invalid type for {op} operation: {type(value)}")
+
+        self.attr = attr
+        self.value = value
+        self.eval_func = getattr(self, f'eval_{op}')
+        self.negate = negate
+
+    def eval(self, data):
+        """
+        Evaluate the provided data to determine whether it matches the condition.
+        """
+        value = functools.reduce(dict.get, self.attr.split('.'), data)
+        result = self.eval_func(value)
+
+        if self.negate:
+            return not result
+        return result
+
+    # Equivalency
+
+    def eval_eq(self, value):
+        return value == self.value
+
+    def eval_neq(self, value):
+        return value != self.value
+
+    # Numeric comparisons
+
+    def eval_gt(self, value):
+        return value > self.value
+
+    def eval_gte(self, value):
+        return value >= self.value
+
+    def eval_lt(self, value):
+        return value < self.value
+
+    def eval_lte(self, value):
+        return value <= self.value
+
+    # Membership
+
+    def eval_in(self, value):
+        return value in self.value
+
+    def eval_contains(self, value):
+        return self.value in value
+
+    # Regular expressions
+
+    def eval_regex(self, value):
+        return re.match(self.value, value) is not None
+
+
+class ConditionSet:
+    """
+    A set of one or more Condition to be evaluated per the prescribed logic (AND or OR). Example:
+
+    {"and": [
+        {"attr": "foo", "op": "eq", "value": 1},
+        {"attr": "bar", "op": "eq", "value": 2, "negate": true}
+    ]}
+
+    :param ruleset: A dictionary mapping a logical operator to a list of conditional rules
+    """
+    def __init__(self, ruleset):
+        if type(ruleset) is not dict:
+            raise ValueError(f"Ruleset must be a dictionary, not {type(ruleset)}.")
+        if len(ruleset) != 1:
+            raise ValueError(f"Ruleset must have exactly one logical operator (found {len(ruleset)})")
+
+        # Determine the logic type
+        logic = list(ruleset.keys())[0]
+        if type(logic) is not str or logic.lower() not in (AND, OR):
+            raise ValueError(f"Invalid logic type: {logic} (must be '{AND}' or '{OR}')")
+        self.logic = logic.lower()
+
+        # Compile the set of Conditions
+        self.conditions = [
+            ConditionSet(rule) if is_ruleset(rule) else Condition(**rule)
+            for rule in ruleset[self.logic]
+        ]
+
+    def eval(self, data):
+        """
+        Evaluate the provided data to determine whether it matches this set of conditions.
+        """
+        func = any if self.logic == 'or' else all
+        return func(d.eval(data) for d in self.conditions)

+ 1 - 0
netbox/extras/forms/__init__.py

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

+ 1 - 1
netbox/extras/forms/bulk_edit.py

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

+ 79 - 0
netbox/extras/forms/config.py

@@ -0,0 +1,79 @@
+from django import forms
+from django.conf import settings
+
+from netbox.config import get_config, PARAMS
+
+__all__ = (
+    'ConfigRevisionForm',
+)
+
+
+EMPTY_VALUES = ('', None, [], ())
+
+
+class FormMetaclass(forms.models.ModelFormMetaclass):
+
+    def __new__(mcs, name, bases, attrs):
+
+        # Emulate a declared field for each supported configuration parameter
+        param_fields = {}
+        for param in PARAMS:
+            field_kwargs = {
+                'required': False,
+                'label': param.label,
+                'help_text': param.description,
+            }
+            field_kwargs.update(**param.field_kwargs)
+            param_fields[param.name] = param.field(**field_kwargs)
+        attrs.update(param_fields)
+
+        return super().__new__(mcs, name, bases, attrs)
+
+
+class ConfigRevisionForm(forms.BaseModelForm, metaclass=FormMetaclass):
+    """
+    Form for creating a new ConfigRevision.
+    """
+    class Meta:
+        widgets = {
+            'comment': forms.Textarea(),
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Append current parameter values to form field help texts and check for static configurations
+        config = get_config()
+        for param in PARAMS:
+            value = getattr(config, param.name)
+            is_static = hasattr(settings, param.name)
+            if value:
+                help_text = f'<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

+ 4 - 2
netbox/extras/forms/customfields.py

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

+ 1 - 0
netbox/extras/forms/models.py

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

+ 18 - 0
netbox/extras/migrations/0063_webhook_conditions.py

@@ -0,0 +1,18 @@
+# Generated by Django 3.2.8 on 2021-10-22 20:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0062_clear_secrets_changelog'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='webhook',
+            name='conditions',
+            field=models.JSONField(blank=True, null=True),
+        ),
+    ]

+ 20 - 0
netbox/extras/migrations/0064_configrevision.py

@@ -0,0 +1,20 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0063_webhook_conditions'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ConfigRevision',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('comment', models.CharField(blank=True, max_length=200)),
+                ('data', models.JSONField(blank=True, null=True)),
+            ],
+        ),
+    ]

+ 2 - 1
netbox/extras/models/__init__.py

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

+ 4 - 0
netbox/extras/models/customfields.py

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

+ 80 - 56
netbox/extras/models/models.py

@@ -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(
-                "You must select at least one type: create, update, and/or delete."
-            )
+            raise ValidationError("At least one type must be selected: create, update, and/or delete.")
+
+        if self.conditions:
+            try:
+                ConditionSet(self.conditions)
+            except ValueError as e:
+                raise ValidationError({'conditions': e})
 
 
         # CA file path requires SSL verification enabled
         # 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

+ 13 - 1
netbox/extras/signals.py

@@ -8,7 +8,7 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates
 
 
 from netbox.signals import post_clean
 from 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()

+ 199 - 0
netbox/extras/tests/test_conditions.py

@@ -0,0 +1,199 @@
+from django.test import TestCase
+
+from extras.conditions import Condition, ConditionSet
+
+
+class ConditionTestCase(TestCase):
+
+    def test_dotted_path_access(self):
+        c = Condition('a.b.c', 1, 'eq')
+        self.assertTrue(c.eval({'a': {'b': {'c': 1}}}))
+        self.assertFalse(c.eval({'a': {'b': {'c': 2}}}))
+        self.assertFalse(c.eval({'a': {'b': {'x': 1}}}))
+
+    def test_undefined_attr(self):
+        c = Condition('x', 1, 'eq')
+        self.assertFalse(c.eval({}))
+        self.assertTrue(c.eval({'x': 1}))
+
+    #
+    # Validation tests
+    #
+
+    def test_invalid_op(self):
+        with self.assertRaises(ValueError):
+            # 'blah' is not a valid operator
+            Condition('x', 1, 'blah')
+
+    def test_invalid_type(self):
+        with self.assertRaises(ValueError):
+            # dict type is unsupported
+            Condition('x', 1, dict())
+
+    def test_invalid_op_type(self):
+        with self.assertRaises(ValueError):
+            # 'gt' supports only numeric values
+            Condition('x', 'foo', 'gt')
+
+    #
+    # Operator tests
+    #
+
+    def test_default_operator(self):
+        c = Condition('x', 1)
+        self.assertEqual(c.eval_func, c.eval_eq)
+
+    def test_eq(self):
+        c = Condition('x', 1, 'eq')
+        self.assertTrue(c.eval({'x': 1}))
+        self.assertFalse(c.eval({'x': 2}))
+
+    def test_eq_negated(self):
+        c = Condition('x', 1, 'eq', negate=True)
+        self.assertFalse(c.eval({'x': 1}))
+        self.assertTrue(c.eval({'x': 2}))
+
+    def test_gt(self):
+        c = Condition('x', 1, 'gt')
+        self.assertTrue(c.eval({'x': 2}))
+        self.assertFalse(c.eval({'x': 1}))
+
+    def test_gte(self):
+        c = Condition('x', 1, 'gte')
+        self.assertTrue(c.eval({'x': 2}))
+        self.assertTrue(c.eval({'x': 1}))
+        self.assertFalse(c.eval({'x': 0}))
+
+    def test_lt(self):
+        c = Condition('x', 2, 'lt')
+        self.assertTrue(c.eval({'x': 1}))
+        self.assertFalse(c.eval({'x': 2}))
+
+    def test_lte(self):
+        c = Condition('x', 2, 'lte')
+        self.assertTrue(c.eval({'x': 1}))
+        self.assertTrue(c.eval({'x': 2}))
+        self.assertFalse(c.eval({'x': 3}))
+
+    def test_in(self):
+        c = Condition('x', [1, 2, 3], 'in')
+        self.assertTrue(c.eval({'x': 1}))
+        self.assertFalse(c.eval({'x': 9}))
+
+    def test_in_negated(self):
+        c = Condition('x', [1, 2, 3], 'in', negate=True)
+        self.assertFalse(c.eval({'x': 1}))
+        self.assertTrue(c.eval({'x': 9}))
+
+    def test_contains(self):
+        c = Condition('x', 1, 'contains')
+        self.assertTrue(c.eval({'x': [1, 2, 3]}))
+        self.assertFalse(c.eval({'x': [2, 3, 4]}))
+
+    def test_contains_negated(self):
+        c = Condition('x', 1, 'contains', negate=True)
+        self.assertFalse(c.eval({'x': [1, 2, 3]}))
+        self.assertTrue(c.eval({'x': [2, 3, 4]}))
+
+    def test_regex(self):
+        c = Condition('x', '[a-z]+', 'regex')
+        self.assertTrue(c.eval({'x': 'abc'}))
+        self.assertFalse(c.eval({'x': '123'}))
+
+    def test_regex_negated(self):
+        c = Condition('x', '[a-z]+', 'regex', negate=True)
+        self.assertFalse(c.eval({'x': 'abc'}))
+        self.assertTrue(c.eval({'x': '123'}))
+
+
+class ConditionSetTest(TestCase):
+
+    def test_empty(self):
+        with self.assertRaises(ValueError):
+            ConditionSet({})
+
+    def test_invalid_logic(self):
+        with self.assertRaises(ValueError):
+            ConditionSet({'foo': []})
+
+    def test_and_single_depth(self):
+        cs = ConditionSet({
+            'and': [
+                {'attr': 'a', 'value': 1, 'op': 'eq'},
+                {'attr': 'b', 'value': 1, 'op': 'eq', 'negate': True},
+            ]
+        })
+        self.assertTrue(cs.eval({'a': 1, 'b': 2}))
+        self.assertFalse(cs.eval({'a': 1, 'b': 1}))
+
+    def test_or_single_depth(self):
+        cs = ConditionSet({
+            'or': [
+                {'attr': 'a', 'value': 1, 'op': 'eq'},
+                {'attr': 'b', 'value': 1, 'op': 'eq'},
+            ]
+        })
+        self.assertTrue(cs.eval({'a': 1, 'b': 2}))
+        self.assertTrue(cs.eval({'a': 2, 'b': 1}))
+        self.assertFalse(cs.eval({'a': 2, 'b': 2}))
+
+    def test_and_multi_depth(self):
+        cs = ConditionSet({
+            'and': [
+                {'attr': 'a', 'value': 1, 'op': 'eq'},
+                {'and': [
+                    {'attr': 'b', 'value': 2, 'op': 'eq'},
+                    {'attr': 'c', 'value': 3, 'op': 'eq'},
+                ]}
+            ]
+        })
+        self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 3}))
+        self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 3}))
+        self.assertFalse(cs.eval({'a': 1, 'b': 9, 'c': 3}))
+        self.assertFalse(cs.eval({'a': 1, 'b': 2, 'c': 9}))
+
+    def test_or_multi_depth(self):
+        cs = ConditionSet({
+            'or': [
+                {'attr': 'a', 'value': 1, 'op': 'eq'},
+                {'or': [
+                    {'attr': 'b', 'value': 2, 'op': 'eq'},
+                    {'attr': 'c', 'value': 3, 'op': 'eq'},
+                ]}
+            ]
+        })
+        self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 9}))
+        self.assertTrue(cs.eval({'a': 9, 'b': 2, 'c': 9}))
+        self.assertTrue(cs.eval({'a': 9, 'b': 9, 'c': 3}))
+        self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 9}))
+
+    def test_mixed_and(self):
+        cs = ConditionSet({
+            'and': [
+                {'attr': 'a', 'value': 1, 'op': 'eq'},
+                {'or': [
+                    {'attr': 'b', 'value': 2, 'op': 'eq'},
+                    {'attr': 'c', 'value': 3, 'op': 'eq'},
+                ]}
+            ]
+        })
+        self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9}))
+        self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 3}))
+        self.assertFalse(cs.eval({'a': 1, 'b': 9, 'c': 9}))
+        self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 3}))
+
+    def test_mixed_or(self):
+        cs = ConditionSet({
+            'or': [
+                {'attr': 'a', 'value': 1, 'op': 'eq'},
+                {'and': [
+                    {'attr': 'b', 'value': 2, 'op': 'eq'},
+                    {'attr': 'c', 'value': 3, 'op': 'eq'},
+                ]}
+            ]
+        })
+        self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 9}))
+        self.assertTrue(cs.eval({'a': 9, 'b': 2, 'c': 3}))
+        self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9}))
+        self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9}))
+        self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3}))

+ 34 - 6
netbox/extras/tests/test_customfields.py

@@ -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'),
-            ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', 'Choice A'),
-            ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', 'Choice B'),
-            ('Site 3', 'site-3', 'active', '', '', '', '', '', '', ''),
+            ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select'),
+            ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A'),
+            ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B'),
+            ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', ''),
         )
         )
         csv_data = '\n'.join(','.join(row) for row in data)
         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

+ 3 - 0
netbox/extras/tests/test_forms.py

@@ -32,6 +32,9 @@ class CustomFieldModelFormTest(TestCase):
         cf_url = CustomField.objects.create(name='url', type=CustomFieldTypeChoices.TYPE_URL)
         cf_url = 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])
 
 

+ 1 - 0
netbox/extras/tests/test_views.py

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

+ 15 - 12
netbox/extras/webhooks_worker.py

@@ -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(
-            params['method'], params['url'], context['model'], context['event']
-        )
+        f"Sending {params['method']} request to {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))
-        return 'Status {} returned, webhook successfully processed.'.format(response.status_code)
+        logger.info(f"Request succeeded; response status {response.status_code}")
+        return f"Status {response.status_code} returned, webhook successfully processed."
     else:
     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(
-                response.status_code, response.content
-            )
+            f"Status {response.status_code} returned with content '{response.content}', webhook FAILED to process."
         )
         )

+ 8 - 5
netbox/ipam/api/mixins.py

@@ -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
-            if settings.MAX_PAGE_SIZE:
-                limit = min(limit, settings.MAX_PAGE_SIZE)
+                limit = PAGINATE_COUNT
+            if MAX_PAGE_SIZE:
+                limit = min(limit, MAX_PAGE_SIZE)
 
 
             # Calculate available IPs within the parent
             # Calculate available IPs within the parent
             ip_list = []
             ip_list = []

+ 8 - 9
netbox/ipam/api/serializers.py

@@ -9,7 +9,6 @@ from ipam.choices import *
 from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
 from ipam.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',
-            'prefix_count', 'vlan_count',
+            'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'prefix_count', 'vlan_count',
         ]
         ]
 
 
 
 
-class VLANGroupSerializer(OrganizationalModelSerializer):
+class VLANGroupSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
     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',
-            'created', 'last_updated', 'vlan_count',
+            'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'tags',
+            'custom_fields', 'created', 'last_updated', 'vlan_count',
         ]
         ]
         validators = []
         validators = []
 
 

+ 3 - 3
netbox/ipam/api/views.py

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

+ 3 - 0
netbox/ipam/filtersets.py

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

+ 3 - 3
netbox/ipam/forms/bulk_edit.py

@@ -73,7 +73,7 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
         ]
         ]
 
 
 
 
-class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class RIRBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     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

+ 4 - 8
netbox/ipam/forms/filtersets.py

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

+ 16 - 4
netbox/ipam/forms/models.py

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

+ 30 - 0
netbox/ipam/migrations/0051_extend_tag_support.py

@@ -0,0 +1,30 @@
+# Generated by Django 3.2.8 on 2021-10-21 14:50
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0062_clear_secrets_changelog'),
+        ('ipam', '0050_iprange'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='rir',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='role',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='vlangroup',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 6 - 6
netbox/ipam/models/ip.py

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

+ 1 - 1
netbox/ipam/models/vlans.py

@@ -21,7 +21,7 @@ __all__ = (
 )
 )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class VLANGroup(OrganizationalModel):
 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.

+ 8 - 2
netbox/ipam/tables/ip.py

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

+ 4 - 1
netbox/ipam/tables/vlans.py

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

+ 9 - 0
netbox/ipam/tests/test_views.py

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

Некоторые файлы не были показаны из-за большого количества измененных файлов