Explorar el Código

Merge pull request #21079 from netbox-community/feature

Release v4.5.0
Jeremy Stretch hace 1 mes
padre
commit
e73db97d46
Se han modificado 100 ficheros con 3030 adiciones y 875 borrados
  1. 2 2
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  2. 1 1
      .github/workflows/ci.yml
  3. 958 1
      contrib/openapi.json
  4. 1 1
      docs/administration/authentication/overview.md
  5. 10 0
      docs/configuration/graphql-api.md
  6. 0 16
      docs/configuration/remote-authentication.md
  7. 25 0
      docs/configuration/required-parameters.md
  8. 0 11
      docs/configuration/security.md
  9. 0 11
      docs/customization/custom-scripts.md
  10. 4 0
      docs/development/application-registry.md
  11. 1 1
      docs/development/getting-started.md
  12. 1 1
      docs/features/api-integration.md
  13. 7 0
      docs/features/configuration-rendering.md
  14. 10 0
      docs/features/resource-ownership.md
  15. 23 7
      docs/features/tenancy.md
  16. 0 3
      docs/features/user-preferences.md
  17. 22 5
      docs/installation/3-netbox.md
  18. 0 3
      docs/installation/4a-gunicorn.md
  19. 0 3
      docs/installation/6-ldap.md
  20. 1 1
      docs/installation/index.md
  21. 3 2
      docs/installation/upgrading.md
  22. 25 9
      docs/integrations/rest-api.md
  23. 15 0
      docs/models/dcim/cable.md
  24. 23 0
      docs/models/users/owner.md
  25. 9 0
      docs/models/users/ownergroup.md
  26. 7 0
      docs/models/virtualization/virtualmachine.md
  27. 20 1
      docs/plugins/development/filtersets.md
  28. 46 6
      docs/plugins/development/forms.md
  29. 16 0
      docs/plugins/development/graphql-api.md
  30. 4 4
      docs/plugins/development/index.md
  31. 3 4
      docs/plugins/development/migration-v4.md
  32. 40 0
      docs/plugins/development/models.md
  33. 11 8
      docs/plugins/development/navigation.md
  34. 8 0
      docs/plugins/development/rest-api.md
  35. 8 0
      docs/plugins/development/tables.md
  36. 148 0
      docs/plugins/development/ui-components.md
  37. 8 0
      docs/release-notes/index.md
  38. 150 0
      docs/release-notes/version-4.5.md
  39. 6 0
      mkdocs.yml
  40. 0 57
      netbox/account/tables.py
  41. 15 4
      netbox/account/views.py
  42. 20 43
      netbox/circuits/api/serializers_/circuits.py
  43. 9 9
      netbox/circuits/api/serializers_/providers.py
  44. 19 7
      netbox/circuits/filtersets.py
  45. 13 58
      netbox/circuits/forms/bulk_edit.py
  46. 18 19
      netbox/circuits/forms/bulk_import.py
  47. 17 17
      netbox/circuits/forms/filtersets.py
  48. 19 30
      netbox/circuits/forms/model_forms.py
  49. 6 5
      netbox/circuits/graphql/filter_mixins.py
  50. 26 30
      netbox/circuits/graphql/filters.py
  51. 6 11
      netbox/circuits/graphql/types.py
  52. 68 0
      netbox/circuits/migrations/0053_owner.py
  53. 39 0
      netbox/circuits/migrations/0054_cable_connector_positions.py
  54. 28 0
      netbox/circuits/migrations/0055_add_comments_to_organizationalmodel.py
  55. 17 0
      netbox/circuits/migrations/0056_gfk_indexes.py
  56. 3 0
      netbox/circuits/models/circuits.py
  57. 3 0
      netbox/circuits/search.py
  58. 11 16
      netbox/circuits/tables/circuits.py
  59. 9 18
      netbox/circuits/tables/providers.py
  60. 5 8
      netbox/circuits/tables/virtual_circuits.py
  61. 1 1
      netbox/circuits/tests/test_filtersets.py
  62. 0 5
      netbox/circuits/urls.py
  63. 0 81
      netbox/circuits/views.py
  64. 7 22
      netbox/core/api/serializers_/change_logging.py
  65. 4 4
      netbox/core/api/serializers_/data.py
  66. 2 3
      netbox/core/api/views.py
  67. 9 2
      netbox/core/filtersets.py
  68. 2 9
      netbox/core/forms/bulk_edit.py
  69. 3 3
      netbox/core/forms/bulk_import.py
  70. 5 4
      netbox/core/forms/filtersets.py
  71. 5 5
      netbox/core/forms/model_forms.py
  72. 11 0
      netbox/core/graphql/enums.py
  73. 3 15
      netbox/core/graphql/filter_mixins.py
  74. 13 12
      netbox/core/graphql/filters.py
  75. 2 3
      netbox/core/graphql/types.py
  76. 19 0
      netbox/core/migrations/0020_owner.py
  77. 0 3
      netbox/core/models/contenttypes.py
  78. 3 3
      netbox/core/tables/data.py
  79. 90 26
      netbox/core/tests/test_api.py
  80. 4 4
      netbox/core/tests/test_views.py
  81. 3 3
      netbox/core/views.py
  82. 52 0
      netbox/dcim/api/serializers_/base.py
  83. 17 16
      netbox/dcim/api/serializers_/cables.py
  84. 89 47
      netbox/dcim/api/serializers_/device_components.py
  85. 14 22
      netbox/dcim/api/serializers_/devices.py
  86. 48 20
      netbox/dcim/api/serializers_/devicetype_components.py
  87. 13 14
      netbox/dcim/api/serializers_/devicetypes.py
  88. 6 4
      netbox/dcim/api/serializers_/manufacturers.py
  89. 1 1
      netbox/dcim/api/serializers_/platforms.py
  90. 7 6
      netbox/dcim/api/serializers_/power.py
  91. 14 15
      netbox/dcim/api/serializers_/racks.py
  92. 5 5
      netbox/dcim/api/serializers_/roles.py
  93. 6 6
      netbox/dcim/api/serializers_/sites.py
  94. 4 4
      netbox/dcim/api/serializers_/virtualchassis.py
  95. 2 2
      netbox/dcim/api/views.py
  96. 2 2
      netbox/dcim/apps.py
  97. 390 0
      netbox/dcim/cable_profiles.py
  98. 71 0
      netbox/dcim/choices.py
  99. 13 2
      netbox/dcim/constants.py
  100. 123 68
      netbox/dcim/filtersets.py

+ 2 - 2
.github/ISSUE_TEMPLATE/02-bug_report.yaml

@@ -35,9 +35,9 @@ body:
       label: Python Version
       description: What version of Python are you currently running?
       options:
-        - "3.10"
-        - "3.11"
         - "3.12"
+        - "3.13"
+        - "3.14"
     validations:
       required: true
   - type: textarea

+ 1 - 1
.github/workflows/ci.yml

@@ -31,7 +31,7 @@ jobs:
       NETBOX_CONFIGURATION: netbox.configuration_testing
     strategy:
       matrix:
-        python-version: ['3.10', '3.11', '3.12']
+        python-version: ['3.12', '3.13', '3.14']
         node-version: ['20.x']
     services:
       redis:

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 958 - 1
contrib/openapi.json


+ 1 - 1
docs/administration/authentication/overview.md

@@ -2,7 +2,7 @@
 
 ## Local Authentication
 
-Local user accounts and groups can be created in NetBox under the "Authentication" section in the "Admin" menu. This section is available only to users with the "staff" permission enabled.
+Local user accounts and groups can be created in NetBox under the "Authentication" section in the "Admin" menu.
 
 At a minimum, each user account must have a username and password set. User accounts may also denote a first name, last name, and email address. [Permissions](../permissions.md) may also be assigned to individual users and/or groups as needed.
 

+ 10 - 0
docs/configuration/graphql-api.md

@@ -1,5 +1,15 @@
 # GraphQL API Parameters
 
+## GRAPHQL_DEFAULT_VERSION
+
+!!! note "This parameter was introduced in NetBox v4.5."
+
+Default: `1`
+
+Designates the default version of the GraphQL API served by `/graphql/`. To access a specific version, append the version number to the URL, e.g. `/graphql/v2/`.
+
+---
+
 ## GRAPHQL_ENABLED
 
 !!! tip "Dynamic Configuration Parameter"

+ 0 - 16
docs/configuration/remote-authentication.md

@@ -127,19 +127,3 @@ The list of groups that promote an remote User to Superuser on Login. If group i
 Default: `[]` (Empty list)
 
 The list of users that get promoted to Superuser on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
-
----
-
-## REMOTE_AUTH_STAFF_GROUPS
-
-Default: `[]` (Empty list)
-
-The list of groups that promote an remote User to Staff on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
-
----
-
-## REMOTE_AUTH_STAFF_USERS
-
-Default: `[]` (Empty list)
-
-The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )

+ 25 - 0
docs/configuration/required-parameters.md

@@ -23,6 +23,31 @@ ALLOWED_HOSTS = ['*']
 
 ---
 
+## API_TOKEN_PEPPERS
+
+!!! info "This parameter was introduced in NetBox v4.5."
+
+[Cryptographic peppers](https://en.wikipedia.org/wiki/Pepper_(cryptography)) are employed to generate hashes of sensitive values on the server. This parameter defines the peppers used to hash v2 API tokens in NetBox. You must define at least one pepper before creating a v2 API token. See the [API documentation](../integrations/rest-api.md#authentication) for further information about how peppers are used.
+
+```python
+API_TOKEN_PEPPERS = {
+    # DO NOT USE THIS EXAMPLE PEPPER IN PRODUCTION
+    1: 'kp7ht*76fiQAhUi5dHfASLlYUE_S^gI^(7J^K5M!LfoH@vl&b_',
+}
+```
+
+!!! warning "Peppers are sensitive"
+    Treat pepper values as extremely sensitive. Consider populating peppers from environment variables at initialization time rather than defining them in the configuration file, if feasible. 
+
+Peppers must be at least 50 characters in length and should comprise a random string with a diverse character set. Consider using the Python script at `$INSTALL_ROOT/netbox/generate_secret_key.py` to generate a pepper value.
+
+It is recommended to start with a pepper ID of `1`. Additional peppers can be introduced later as needed to begin rotating token hashes.
+
+!!! tip
+    Although NetBox will run without `API_TOKEN_PEPPERS` defined, the use of v2 API tokens will be unavailable.
+
+---
+
 ## DATABASE
 
 !!! warning "Legacy Configuration Parameter"

+ 0 - 11
docs/configuration/security.md

@@ -1,16 +1,5 @@
 # Security & Authentication Parameters
 
-## ALLOW_TOKEN_RETRIEVAL
-
-Default: `False`
-
-!!! note
-    The default value of this parameter changed from `True` to `False` in NetBox v4.3.0.
-
-If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.
-
----
-
 ## ALLOWED_URL_SCHEMES
 
 !!! tip "Dynamic Configuration Parameter"

+ 0 - 11
docs/customization/custom-scripts.md

@@ -131,17 +131,6 @@ self.log_info(f"Running as user {username} (IP: {ip_address})...")
 
 For a complete list of available request parameters, please see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/request-response/).
 
-## Reading Data from Files
-
-The Script class provides two convenience methods for reading data from files:
-
-* `load_yaml`
-* `load_json`
-
-These two methods will load data in YAML or JSON format, respectively, from files within the local path (i.e. `SCRIPTS_ROOT`).
-
-**Note:** These convenience methods are deprecated and will be removed in NetBox v4.4.  These only work if running scripts within the local path, they will not work if using a storage other than ScriptFileSystemStorage.
-
 ## Logging
 
 The Script object provides a set of convenient functions for recording messages at different severity levels:

+ 4 - 0
docs/development/application-registry.md

@@ -20,6 +20,10 @@ A dictionary mapping data backend types to their respective classes. These are u
 
 Stores registration made using `netbox.denormalized.register()`. For each model, a list of related models and their field mappings is maintained to facilitate automatic updates.
 
+### `filtersets`
+
+A dictionary mapping each model (identified by its app and label) to its filterset class, if one has been registered for it. Filtersets are registered using the `@register_filterset` decorator.
+
 ### `model_features`
 
 A dictionary of model features (e.g. custom fields, tags, etc.) mapped to the functions used to qualify a model as supporting each feature. Model features are registered using the `register_model_feature()` function in `netbox.utils`.

+ 1 - 1
docs/development/getting-started.md

@@ -7,7 +7,7 @@ Getting started with NetBox development is pretty straightforward, and should fe
 * A Linux system or compatible environment
 * A PostgreSQL server, which can be installed locally [per the documentation](../installation/1-postgresql.md)
 * A Redis server, which can also be [installed locally](../installation/2-redis.md)
-* Python 3.10 or later
+* Python 3.12 or later
 
 ### 1. Fork the Repo
 

+ 1 - 1
docs/features/api-integration.md

@@ -8,7 +8,7 @@ NetBox's REST API, powered by the [Django REST Framework](https://www.django-res
 
 ```no-highlight
 curl -s -X POST \
--H "Authorization: Token $TOKEN" \
+-H "Authorization: Bearer $TOKEN" \
 -H "Content-Type: application/json" \
 http://netbox/api/ipam/prefixes/ \
 --data '{"prefix": "192.0.2.0/24", "site": {"name": "Branch 12"}}'

+ 7 - 0
docs/features/configuration-rendering.md

@@ -90,3 +90,10 @@ http://netbox:8000/api/extras/config-templates/123/render/ \
   "bar": 123
 }'
 ```
+
+!!! note "Permissions"
+    Rendering configuration templates via the REST API requires appropriate permissions for the relevant object type:
+
+    * To render a device's configuration via `/api/dcim/devices/{id}/render-config/`, assign a permission for "DCIM > Device" with the `render_config` action.
+    * To render a virtual machine's configuration via `/api/virtualization/virtual-machines/{id}/render-config/`, assign a permission for "Virtualization > Virtual Machine" with the `render_config` action.
+    * To render a config template directly via `/api/extras/config-templates/{id}/render/`, assign a permission for "Extras > Config Template" with the `render` action.

+ 10 - 0
docs/features/resource-ownership.md

@@ -0,0 +1,10 @@
+# Resource Ownership
+
+!!! info "This feature was introduced in NetBox v4.5."
+
+Most objects in NetBox can be assigned an owner. An owner is a set of users and/or groups who are responsible for the administration of associated objects. For example, you might designate the operations team at a site as the owner for all prefixes and VLANs deployed at that site. The users and groups assigned to an owner are referred to as its members.
+
+!!! note
+    Ownership of an object should not be confused with the concept of [tenancy](./tenancy.md), which indicates the dedication of an object to a specific tenant. For instance, a tenant might represent a customer served by the object, whereas an owner typically represents a set of internal users responsible for the management of the object.
+
+Owners can be organized into groups for easier management.

+ 23 - 7
docs/features/tenancy.md

@@ -1,6 +1,6 @@
 # Tenancy
 
-Most core objects within NetBox's data model support _tenancy_. This is the association of an object with a particular tenant to convey ownership or dependency. For example, an enterprise might represent its internal business units as tenants, whereas a managed services provider might create a tenant in NetBox to represent each of its customers.
+Most core objects within NetBox's data model support _tenancy_. This is the association of an object with a particular tenant to convey assignment or dependency. For example, an enterprise might represent its internal business units as tenants, whereas a managed services provider might create a tenant in NetBox to represent each of its customers.
 
 ```mermaid
 flowchart TD
@@ -19,20 +19,36 @@ Tenants can be grouped by any logic that your use case demands, and groups can b
 
 Typically, the tenant model is used to represent a customer or internal organization, however it can be used for whatever purpose meets your needs.
 
-Most core objects within NetBox can be assigned to particular tenant, so this model provides a very convenient way to correlate ownership across object types. For example, each of your customers might have its own racks, devices, IP addresses, circuits and so on: These can all be easily tracked via tenant assignment.
+Most core objects within NetBox can be assigned to a particular tenant, so this model provides a very convenient way to correlate resource allocation across object types. For example, each of your customers might have its own racks, devices, IP addresses, circuits and so on: These can all be easily tracked via tenant assignment.
 
 The following objects can be assigned to tenants:
 
-* Sites
+* Circuits
+* Circuit groups
+* Virtual circuits
+* Cables
+* Devices
+* Virtual device contexts
+* Power feeds
 * Racks
 * Rack reservations
-* Devices
-* VRFs
+* Sites
+* Locations
+* ASNs
+* ASN ranges
+* Aggregates
 * Prefixes
+* IP ranges
 * IP addresses
 * VLANs
-* Circuits
+* VLAN groups
+* VRFs
+* Route targets
 * Clusters
 * Virtual machines
+* L2VPNs
+* Tunnels
+* Wireless LANs
+* Wireless links
 
-Tenant assignment is used to signify the ownership of an object in NetBox. As such, each object may only be owned by a single tenant. For example, if you have a firewall dedicated to a particular customer, you would assign it to the tenant which represents that customer. However, if the firewall serves multiple customers, it doesn't *belong* to any particular customer, so tenant assignment would not be appropriate.
+Tenancy represents the dedication of an object to a specific tenant. As such, each object may only be assigned to a single tenant. For example, if you have a firewall dedicated to a particular customer, you would assign it to the tenant which represents that customer. However, if the firewall serves multiple customers, it doesn't *belong* to any particular customer, so the assignment of a tenant would not be appropriate.

+ 0 - 3
docs/features/user-preferences.md

@@ -34,9 +34,6 @@ Sets the default number of rows displayed on paginated tables.
 ### Paginator placement
 Controls where pagination controls are rendered relative to a table.
 
-### HTMX navigation (experimental)
-Enables partial‑page navigation for supported views. Disable this preference if unexpected behavior is observed.
-
 ### Striped table rows
 Toggles alternating row backgrounds on tables.
 

+ 22 - 5
docs/installation/3-netbox.md

@@ -6,8 +6,8 @@ This section of the documentation discusses installing and configuring the NetBo
 
 Begin by installing all system packages required by NetBox and its dependencies.
 
-!!! warning "Python 3.10 or later required"
-    NetBox supports Python 3.10, 3.11, and 3.12.
+!!! warning "Python 3.12 or later required"
+    NetBox supports only Python 3.12 or later.
 
 ```no-highlight
 sudo apt install -y python3 python3-pip python3-venv python3-dev \
@@ -15,7 +15,7 @@ build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev \
 libssl-dev zlib1g-dev
 ```
 
-Before continuing, check that your installed Python version is at least 3.10:
+Before continuing, check that your installed Python version is at least 3.12:
 
 ```no-highlight
 python3 -V
@@ -120,6 +120,23 @@ If you are not yet sure what the domain name and/or IP address of the NetBox ins
 ALLOWED_HOSTS = ['*']
 ```
 
+### API_TOKEN_PEPPERS
+
+Define at least one random cryptographic pepper, identified by a numeric ID starting at 1. This will be used to generate SHA256 checksums for API tokens.
+
+```python
+API_TOKEN_PEPPERS = {
+    # DO NOT USE THIS EXAMPLE PEPPER IN PRODUCTION
+    1: 'kp7ht*76fiQAhUi5dHfASLlYUE_S^gI^(7J^K5M!LfoH@vl&b_',
+}
+```
+
+!!! tip
+    As with [`SECRET_KEY`](#secret_key) below, you can use the `generate_secret_key.py` script to generate a random pepper:
+    ```no-highlight
+    python3 ../generate_secret_key.py
+    ```
+
 ### DATABASES
 
 This parameter holds the PostgreSQL database configuration details. The default database must be defined; additional databases may be defined as needed e.g. by plugins.
@@ -235,10 +252,10 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
 sudo /opt/netbox/upgrade.sh
 ```
 
-Note that **Python 3.10 or later is required** for NetBox v4.0 and later releases. If the default Python installation on your server is set to a lesser version,  pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.)
+Note that **Python 3.12 or later is required** for NetBox v4.5 and later releases. If the default Python installation on your server is set to a lesser version,  pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.)
 
 ```no-highlight
-sudo PYTHON=/usr/bin/python3.10 /opt/netbox/upgrade.sh
+sudo PYTHON=/usr/bin/python3.12 /opt/netbox/upgrade.sh
 ```
 
 !!! note

+ 0 - 3
docs/installation/4a-gunicorn.md

@@ -60,6 +60,3 @@ You should see output similar to the following:
     If the NetBox service fails to start, issue the command `journalctl -eu netbox` to check for log messages that may indicate the problem.
 
 Once you've verified that the WSGI workers are up and running, move on to HTTP server setup.
-
-!!! note
-    There is a bug in the current stable release of gunicorn (v21.2.0) where automatic restarts of the worker processes can result in 502 errors under heavy load. (See [gunicorn bug #3038](https://github.com/benoitc/gunicorn/issues/3038) for more detail.) Users who encounter this issue may opt to downgrade to an earlier, unaffected release of gunicorn (`pip install gunicorn==20.1.0`). Note, however, that this earlier release does not officially support Python 3.11.

+ 0 - 3
docs/installation/6-ldap.md

@@ -121,7 +121,6 @@ AUTH_LDAP_MIRROR_GROUPS = True
 # Define special user types using groups. Exercise great caution when assigning superuser status.
 AUTH_LDAP_USER_FLAGS_BY_GROUP = {
     "is_active": "cn=active,ou=groups,dc=example,dc=com",
-    "is_staff": "cn=staff,ou=groups,dc=example,dc=com",
     "is_superuser": "cn=superuser,ou=groups,dc=example,dc=com"
 }
 
@@ -134,7 +133,6 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600
 ```
 
 * `is_active` - All users must be mapped to at least this group to enable authentication. Without this, users cannot log in.
-* `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions.
 * `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions.
 
 !!! warning
@@ -248,7 +246,6 @@ AUTH_LDAP_MIRROR_GROUPS = True
 # Define special user types using groups. Exercise great caution when assigning superuser status.
 AUTH_LDAP_USER_FLAGS_BY_GROUP = {
     "is_active": "cn=active,ou=groups,dc=example,dc=com",
-    "is_staff": "cn=staff,ou=groups,dc=example,dc=com",
     "is_superuser": "cn=superuser,ou=groups,dc=example,dc=com"
 }
 

+ 1 - 1
docs/installation/index.md

@@ -27,7 +27,7 @@ The following sections detail how to set up a new instance of NetBox:
 
 | Dependency | Supported Versions |
 |------------|--------------------|
-| Python     | 3.10, 3.11, 3.12   |
+| Python     | 3.12, 3.13, 3.14   |
 | PostgreSQL | 14+                |
 | Redis      | 4.0+               |
 

+ 3 - 2
docs/installation/upgrading.md

@@ -19,7 +19,7 @@ NetBox requires the following dependencies:
 
 | Dependency | Supported Versions |
 |------------|--------------------|
-| Python     | 3.10, 3.11, 3.12   |
+| Python     | 3.12, 3.13, 3.14   |
 | PostgreSQL | 14+                |
 | Redis      | 4.0+               |
 
@@ -27,6 +27,7 @@ NetBox requires the following dependencies:
 
 | NetBox Version | Python min | Python max | PostgreSQL min | Redis min |                                       Documentation                                       |
 |:--------------:|:----------:|:----------:|:--------------:|:---------:|:-----------------------------------------------------------------------------------------:|
+|      4.5       |    3.12    |    3.14    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.5.0/docs/installation/index.md) |
 |      4.4       |    3.10    |    3.12    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.4.0/docs/installation/index.md) |
 |      4.3       |    3.10    |    3.12    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) |
 |      4.2       |    3.10    |    3.12    |       13       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
@@ -130,7 +131,7 @@ sudo ./upgrade.sh
     If the default version of Python is not at least 3.10, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example:
 
     ```no-highlight
-    sudo PYTHON=/usr/bin/python3.10 ./upgrade.sh
+    sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh
     ```
 
 !!! note

+ 25 - 9
docs/integrations/rest-api.md

@@ -80,7 +80,7 @@ Likewise, the site, rack, and device objects are located under the "DCIM" applic
 
 The full hierarchy of available endpoints can be viewed by navigating to the API root in a web browser.
 
-Each model generally has two views associated with it: a list view and a detail view. The list view is used to retrieve a list of multiple objects and to create new objects. The detail view is used to retrieve, update, or delete an single existing object. All objects are referenced by their numeric primary key (`id`).
+Each model generally has two views associated with it: a list view and a detail view. The list view is used to retrieve a list of multiple objects and to create new objects. The detail view is used to retrieve, update, or delete a single existing object. All objects are referenced by their numeric primary key (`id`).
 
 * `/api/dcim/devices/` - List existing devices or create a new device
 * `/api/dcim/devices/123/` - Retrieve, update, or delete the device with ID 123
@@ -653,18 +653,22 @@ The NetBox REST API primarily employs token-based authentication. For convenienc
 
 ### Tokens
 
-A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile.
+A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically populate a randomly-generated token value.
 
-By default, all users can create and manage their own REST API tokens under the user control panel in the UI or via the REST API. This ability can be disabled by overriding the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter.
+!!! note "Tokens cannot be retrieved once created"
+    Once a token has been created, its plaintext value cannot be retrieved. For this reason, you must take care to securely record the token locally immediately upon its creation. If a token plaintext is lost, it cannot be recovered: A new token must be created.
 
-Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
+By default, all users can create and manage their own REST API tokens under the user control panel in the UI or via the REST API. This ability can be disabled by overriding the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter.
 
 Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
 
-!!! info "Restricting Token Retrieval"
-    The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter.
+#### v1 and v2 Tokens
+
+Beginning with NetBox v4.5, two versions of API token are supported, denoted as v1 and v2. Users are strongly encouraged to create only v2 tokens and to discontinue the use of v1 tokens. Support for v1 tokens will be removed in a future NetBox release.
 
-### Restricting Write Operations
+v2 API tokens offer much stronger security. The token plaintext given at creation time is hashed together with a configured [cryptographic pepper](../configuration/required-parameters.md#api_token_peppers) to generate a unique checksum. This checksum is irreversible; the token plaintext is never stored on the server and thus cannot be retrieved even with database-level access.
+
+#### Restricting Write Operations
 
 By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
 
@@ -681,10 +685,22 @@ It is possible to provision authentication tokens for other users via the REST A
 
 ### Authenticating to the API
 
-An authentication token is attached to a request by setting the `Authorization` header to the string `Token` followed by a space and the user's token:
+An authentication token is included with a request in its `Authorization` header. The format of the header value depends on the version of token in use. v2 tokens use the following form, concatenating the token's prefix (`nbt_`) and key with its plaintext value, separated by a period:
+
+```
+Authorization: Bearer nbt_<key>.<token>
+```
+
+Legacy v1 tokens use the prefix `Token` rather than `Bearer`, and include only the token plaintext. (v1 tokens do not have a key.)
+
+```
+Authorization: Token <token>
+```
+
+Below is an example REST API request utilizing a v2 token.
 
 ```
-$ curl -H "Authorization: Token $TOKEN" \
+$ curl -H "Authorization: Bearer nbt_4F9DAouzURLb.zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S" \
 -H "Accept: application/json; indent=4" \
 https://netbox/api/dcim/sites/
 {

+ 15 - 0
docs/models/dcim/cable.md

@@ -21,6 +21,21 @@ The cable's operational status. Choices include:
 * Planned
 * Decommissioning
 
+### Profile
+
+!!! note "This field was introduced in NetBox v4.5."
+
+The profile to which the cable conforms. The profile determines the mapping of termination between the two ends and enables logical tracing across complex connections, such as breakout cables. Supported profiles are listed below.
+
+* Straight (single position)
+* Straight (multi-position)
+* Shuffle (2x2 MPO8)
+* Shuffle (4x4 MPO8)
+
+A single-position cable is allowed only one termination point at each end. There is no limit to the number of terminations a multi-position cable may have. Each end of a cable must have the same number of terminations, unless connected to a pass-through port or to a circuit termination.
+
+The assignment of a cable profile is optional. If no profile is assigned, legacy tracing behavior will be preserved.
+
 ### Type
 
 The cable's physical medium or classification.

+ 23 - 0
docs/models/users/owner.md

@@ -0,0 +1,23 @@
+# Owner
+
+An owner is a set of users and/or groups who are responsible for the administration of certain resources within NetBox. The users and groups assigned to an owner are referred to as its members. Owner assignments are useful for indicating which parties are responsible for the administration of a particular object.
+
+Most objects within NetBox can be assigned an owner, although this is not required.
+
+## Fields
+
+### Name
+
+The owner's name.
+
+### Group
+
+The [group](./ownergroup.md) to which the owner is assigned. The assignment of an owner to a group is optional.
+
+### User Groups
+
+Groups of users that are members of the owner.
+
+### Users
+
+Individual users that are members of the owner.

+ 9 - 0
docs/models/users/ownergroup.md

@@ -0,0 +1,9 @@
+# Owner Groups
+
+Groups are used to correlate and organize [owners](./owner.md). The assignment of an owner to a group has no bearing on the relationship of owned objects to their owners.
+
+## Fields
+
+### Name
+
+The name of the group.

+ 7 - 0
docs/models/virtualization/virtualmachine.md

@@ -21,6 +21,13 @@ The VM's operational status.
 !!! tip
     Additional statuses may be defined by setting `VirtualMachine.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
 
+### Start on boot
+
+The start on boot setting from the hypervisor.
+
+!!! tip
+    Additional statuses may be defined by setting `VirtualMachine.start_on_boot` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
+
 ### Site & Cluster
 
 The [site](../dcim/site.md) and/or [cluster](./cluster.md) to which the VM is assigned.

+ 20 - 1
docs/plugins/development/filtersets.md

@@ -6,12 +6,17 @@ Filter sets define the mechanisms available for filtering or searching through a
 
 To support additional functionality standard to NetBox models, such as tag assignment and custom field support, the `NetBoxModelFilterSet` class is available for use by plugins. This should be used as the base filter set class for plugin models which inherit from `NetBoxModel`. Within this class, individual filters can be declared as directed by the `django-filters` documentation. An example is provided below.
 
+!!! info "New in NetBox v4.5: FilterSet Registration"
+    NetBox v4.5 introduced the `register_filterset()` utility function. This enables plugins to register their filtersets to receive advanced functionality, such as the automatic attachment of field-specific lookup modifiers on the filter form. Registration is optional: Unregistered filtersets will continue to work as before, but will not receive the enhanced functionality.
+
 ```python
 # filtersets.py
 import django_filters
 from netbox.filtersets import NetBoxModelFilterSet
+from utilities.filtersets import register_filterset
 from .models import MyModel
 
+@register_filterset
 class MyFilterSet(NetBoxModelFilterSet):
     status = django_filters.MultipleChoiceFilter(
         choices=(
@@ -27,6 +32,14 @@ class MyFilterSet(NetBoxModelFilterSet):
         fields = ('some', 'other', 'fields')
 ```
 
+In addition to the base NetBoxModelFilterSet class, the following filterset classes are also available for subclasses of standard base models.
+
+| Model Class           | FilterSet Class                                  |
+|-----------------------|--------------------------------------------------|
+| `PrimaryModel`        | `netbox.filtersets.PrimaryModelFilterSet`        |
+| `OrganizationalModel` | `netbox.filtersets.OrganizationalModelFilterSet` |
+| `NestedGroupModel`    | `netbox.filtersets.NestedGroupModelFilterSet`    |
+
 ### Declaring Filter Sets
 
 To utilize a filter set in a subclass of one of NetBox's generic views (such as `ObjectListView` or `BulkEditView`), define the `filterset` attribute on the view class:
@@ -42,7 +55,7 @@ class MyModelListView(ObjectListView):
     filterset = MyModelFilterSet
 ```
 
-To enable a filter set on a  REST API endpoint, set the `filterset_class` attribute on the API view:
+To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view:
 
 ```python
 # api/views.py
@@ -62,7 +75,9 @@ The `ObjectListView` has a field called Quick Search. For Quick Search to work t
 ```python
 from django.db.models import Q
 from netbox.filtersets import NetBoxModelFilterSet
+from utilities.filtersets import register_filterset
 
+@register_filterset
 class MyFilterSet(NetBoxModelFilterSet):
     ...
     def search(self, queryset, name, value):
@@ -90,7 +105,9 @@ This class filters `tags` using the `slug` field. For example:
 ```python
 from django_filters import FilterSet
 from extras.filters import TagFilter
+from utilities.filtersets import register_filterset
 
+@register_filterset
 class MyModelFilterSet(FilterSet):
     tag = TagFilter()
 ```
@@ -106,7 +123,9 @@ This class filters `tags` using the `id` field. For example:
 ```python
 from django_filters import FilterSet
 from extras.filters import TagIDFilter
+from utilities.filtersets import register_filterset
 
+@register_filterset
 class MyModelFilterSet(FilterSet):
     tag_id = TagIDFilter()
 ```

+ 46 - 6
docs/plugins/development/forms.md

@@ -2,7 +2,7 @@
 
 ## Form Classes
 
-NetBox provides several base form classes for use by plugins.
+NetBox provides several base form classes for use by plugins. Additional form classes are also available for other standard base model classes (PrimaryModel, OrganizationalModel, and NestedGroupModel).
 
 | Form Class                 | Purpose                              |
 |----------------------------|--------------------------------------|
@@ -19,7 +19,17 @@ This is the base form for creating and editing NetBox models. It extends Django'
 |-------------|---------------------------------------------------------------------------------------|
 | `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) |
 
-**Example**
+#### Subclasses
+
+The corresponding model-specific subclasses of `NetBoxModelForm` are documented below.
+
+| Model Class           | Form Class                |
+|-----------------------|---------------------------|
+| `PrimaryModel`        | `PrimaryModelForm`        |
+| `OrganizationalModel` | `OrganizationalModelForm` |
+| `NestedGroupModel`    | `NestedGroupModelForm`    |
+
+#### Example
 
 ```python
 from django.utils.translation import gettext_lazy as _
@@ -49,9 +59,19 @@ class MyModelForm(NetBoxModelForm):
 
 ### `NetBoxModelImportForm`
 
-This form facilitates the bulk import of new objects from CSV, JSON, or YAML data. As with model forms, you'll need to declare a `Meta` subclass specifying the associated `model` and `fields`. NetBox also provides several form fields suitable for import various types of CSV data, listed below.
+This form facilitates the bulk import of new objects from CSV, JSON, or YAML data. As with model forms, you'll need to declare a `Meta` subclass specifying the associated `model` and `fields`. NetBox also provides several form fields suitable for importing various types of CSV data, listed [below](#csv-import-fields).
+
+#### Subclasses
+
+The corresponding model-specific subclasses of `NetBoxModelImportForm` are documented below.
+
+| Model Class           | Form Class                      |
+|-----------------------|---------------------------------|
+| `PrimaryModel`        | `PrimaryModelImportForm`        |
+| `OrganizationalModel` | `OrganizationalModelImportForm` |
+| `NestedGroupModel`    | `NestedGroupModelImportForm`    |
 
-**Example**
+#### Example
 
 ```python
 from django.utils.translation import gettext_lazy as _
@@ -83,7 +103,17 @@ This form facilitates editing multiple objects in bulk. Unlike a model form, thi
 | `fieldsets`       | A tuple of `FieldSet` instances which control how form fields are rendered (optional)       |
 | `nullable_fields` | A tuple of fields which can be nullified (set to empty) using the bulk edit form (optional) |
 
-**Example**
+#### Subclasses
+
+The corresponding model-specific subclasses of `NetBoxModelBulkEditForm` are documented below.
+
+| Model Class           | Form Class                        |
+|-----------------------|-----------------------------------|
+| `PrimaryModel`        | `PrimaryModelBulkEditForm`        |
+| `OrganizationalModel` | `OrganizationalModelBulkEditForm` |
+| `NestedGroupModel`    | `NestedGroupModelBulkEditForm`    |
+
+#### Example
 
 ```python
 from django import forms
@@ -125,7 +155,17 @@ This form class is used to render a form expressly for filtering a list of objec
 | `model`     | The model of object being edited                                                      |
 | `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) |
 
-**Example**
+#### Subclasses
+
+The corresponding model-specific subclasses of `NetBoxModelFilterSetForm` are documented below.
+
+| Model Class           | Form Class                         |
+|-----------------------|------------------------------------|
+| `PrimaryModel`        | `PrimaryModelFilterSetForm`        |
+| `OrganizationalModel` | `OrganizationalModelFilterSetForm` |
+| `NestedGroupModel`    | `NestedGroupModelFilterSetForm`    |
+
+#### Example
 
 ```python
 from dcim.models import Site

+ 16 - 0
docs/plugins/development/graphql-api.md

@@ -46,3 +46,19 @@ NetBox provides two object type classes for use by plugins.
 ::: netbox.graphql.types.NetBoxObjectType
     options:
       members: false
+
+## GraphQL Filters
+
+NetBox provides a base filter class for use by plugins which employ subclasseses of `NetBoxModel`.
+
+::: netbox.graphql.filters.NetBoxModelFilter
+    options:
+      members: false
+
+Additionally, the following filter classes are available for subclasses of standard base models.
+
+| Model Class           | FilterSet Class                                    |
+|-----------------------|----------------------------------------------------|
+| `PrimaryModel`        | `netbox.graphql.filters.PrimaryModelFilter`        |
+| `OrganizationalModel` | `netbox.graphql.filters.OrganizationalModelFilter` |
+| `NestedGroupModel`    | `netbox.graphql.filters.NestedGroupModelFilter`    |

+ 4 - 4
docs/plugins/development/index.md

@@ -173,12 +173,12 @@ classifiers=[
     'Intended Audience :: Developers',
     'Natural Language :: English',
     "Programming Language :: Python :: 3 :: Only",
-    'Programming Language :: Python :: 3.10',
-    'Programming Language :: Python :: 3.11',
     'Programming Language :: Python :: 3.12',
+    'Programming Language :: Python :: 3.13',
+    'Programming Language :: Python :: 3.14',
 ]
 
-requires-python = ">=3.10.0"
+requires-python = ">=3.12.0"
 ```
 
 Many of these are self-explanatory, but for more information, see the [pyproject.toml documentation](https://packaging.python.org/en/latest/specifications/pyproject-toml/).
@@ -208,7 +208,7 @@ python3 -m venv ~/.virtualenvs/my_plugin
 You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.)
 
 ```shell
-echo /opt/netbox/netbox > $VENV/lib/python3.10/site-packages/netbox.pth
+echo /opt/netbox/netbox > $VENV/lib/python3.12/site-packages/netbox.pth
 ```
 
 ## Development Installation

+ 3 - 4
docs/plugins/development/migration-v4.md

@@ -325,14 +325,14 @@ class CircuitTypeType(OrganizationalObjectType):
 
 ### Change filters.py
 
-Strawberry currently doesn't directly support django-filter, so an explicit filters.py file will need to be created.  NetBox includes a new `autotype_decorator` used to automatically wrap FilterSets to reduce the required code to a minimum.
+Filter classes should inherit from `netbox.graphql.filters.BaseModelFilter`.
 
 ```python title="New"
 import strawberry
 import strawberry_django
 from circuits import filtersets, models
 
-from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
+from netbox.graphql.filters import BaseModelFilter
 
 __all__ = (
     'CircuitFilter',
@@ -340,8 +340,7 @@ __all__ = (
 
 
 @strawberry_django.filter(models.Circuit, lookups=True)
-@autotype_decorator(filtersets.CircuitFilterSet)
-class CircuitFilter(BaseFilterMixin):
+class CircuitFilter(BaseModelFilter):
     pass
 
 ```

+ 40 - 0
docs/plugins/development/models.md

@@ -67,6 +67,46 @@ class MyModel(ExportTemplatesMixin, TagsMixin, models.Model):
     ...
 ```
 
+### Additional Models
+
+In addition to the base NetBoxModel class, the following additional classes are provided for convenience.
+
+!!! info "These model classes were added to the plugins API in NetBox v4.5."
+
+#### PrimaryModel
+
+PrimaryModel is the go-to class for most object types. It extends NetBoxModel with `description` and `comments` fields, and it introduces support for ownership assignment.
+
+| Field         | Required | Unique | Description                                 |
+|---------------|----------|--------|---------------------------------------------|
+| `owner`       | No       | No     | The object's owner                          |
+| `description` | No       | No     | A human-friendly description for the object |
+| `comments`    | No       | No     | General comments                            |
+
+#### OrganizationalModel
+
+OrganizationalModel is used by object types whose function is primarily the organization of other objects.
+
+| Field         | Required | Unique | Description                                 |
+|---------------|----------|--------|---------------------------------------------|
+| `name`        | Yes      | Yes    | The name of the object                      |
+| `slug`        | Yes      | Yes    | A unique URL-friendly identifier            |
+| `owner`       | No       | No     | The object's owner                          |
+| `description` | No       | No     | A human-friendly description for the object |
+
+#### NestedGroupModel
+
+NestedGroupModel is used for objects which arrange into a recursive hierarchy (like regions and locations) via its self-referential `parent` foreign key.
+
+| Field         | Required | Unique | Description                                                     |
+|---------------|----------|--------|-----------------------------------------------------------------|
+| `name`        | Yes      | Yes    | The name of the object                                          |
+| `slug`        | Yes      | Yes    | A unique URL-friendly identifier                                |
+| `parent`      | No       | No     | The object (of the same type) under which this object is nested |
+| `owner`       | No       | No     | The object's owner                                              |
+| `description` | No       | No     | A human-friendly description for the object                     |
+| `comments`    | No       | No     | General comments                                                |
+
 ## Database Migrations
 
 Once you have completed defining the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command. (Ensure that your plugin has been installed and enabled first, otherwise it won't be found.)

+ 11 - 8
docs/plugins/development/navigation.md

@@ -64,14 +64,17 @@ item1 = PluginMenuItem(
 
 A `PluginMenuItem` has the following attributes:
 
-| Attribute       | Required | Description                                                                                              |
-|-----------------|----------|----------------------------------------------------------------------------------------------------------|
-| `link`          | Yes      | Name of the URL path to which this menu item links                                                       |
-| `link_text`     | Yes      | The text presented to the user                                                                           |
-| `permissions`   | -        | A list of permissions required to display this link                                                      |
-| `auth_required` | -        | Display only for authenticated users                                                                     |
-| `staff_only`    | -        | Display only for users who have `is_staff` set to true (any specified permissions will also be required) |
-| `buttons`       | -        | An iterable of PluginMenuButton instances to include                                                     |
+| Attribute       | Required | Description                                          |
+|-----------------|----------|------------------------------------------------------|
+| `link`          | Yes      | Name of the URL path to which this menu item links   |
+| `link_text`     | Yes      | The text presented to the user                       |
+| `permissions`   | -        | A list of permissions required to display this link  |
+| `auth_required` | -        | Display only for authenticated users                 |
+| `staff_only`    | -        | Display only for superusers                          |
+| `buttons`       | -        | An iterable of PluginMenuButton instances to include |
+
+!!! note "Changed in NetBox v4.5"
+    In releases prior to NetBox v4.5, `staff_only` restricted display of a menu item to only users with `is_staff` set to True. In NetBox v4.5, the `is_staff` flag was removed from the user model. Menu items with `staff_only` set to True are now displayed only for superusers.
 
 ## Menu Buttons
 

+ 8 - 0
docs/plugins/development/rest-api.md

@@ -27,6 +27,14 @@ Serializers are responsible for converting Python objects to JSON data suitable
 
 The default nested representation of an object is defined by the `brief_fields` attributes under the serializer's `Meta` class. (Older versions of NetBox required the definition of a separate nested serializer.)
 
+In addition to the base NetBoxModelSerializer class, the following serializer classes are also available for subclasses of standard base models.
+
+| Model Class           | Serializer Class                                       |
+|-----------------------|--------------------------------------------------------|
+| `PrimaryModel`        | `netbox.api.serializers.PrimaryModelSerializer`        |
+| `OrganizationalModel` | `netbox.api.serializers.OrganizationalModelSerializer` |
+| `NestedGroupModel`    | `netbox.api.serializers.NestedGroupModelSerializer`    |
+
 #### Example
 
 To create a serializer for a plugin model, subclass `NetBoxModelSerializer` in `api/serializers.py`. Specify the model class and the fields to include within the serializer's `Meta` class.

+ 8 - 0
docs/plugins/development/tables.md

@@ -36,6 +36,14 @@ class MyModelTable(NetBoxTable):
         default_columns = ('pk', 'name', ...)
 ```
 
+In addition to the base NetBoxTable class, the following table classes are also available for subclasses of standard base models.
+
+| Model Class           | Table Class                              |
+|-----------------------|------------------------------------------|
+| `PrimaryModel`        | `netbox.tables.PrimaryModelTable`        |
+| `OrganizationalModel` | `netbox.tables.OrganizationalModelTable` |
+| `NestedGroupModel`    | `netbox.tables.NestedGroupModelTable`    |
+
 ### Table Configuration
 
 The NetBoxTable class features dynamic configuration to allow users to change their column display and ordering preferences. To configure a table for a specific request, simply call its `configure()` method and pass the current HTTPRequest object. For example:

+ 148 - 0
docs/plugins/development/ui-components.md

@@ -0,0 +1,148 @@
+# UI Components
+
+!!! note "New in NetBox v4.5"
+    All UI components described here were introduced in NetBox v4.5. Be sure to set the minimum NetBox version to 4.5.0 for your plugin before incorporating any of these resources.
+
+!!! danger "Beta Feature"
+    UI components are considered a beta feature, and are still under active development. Please be aware that the API for resources on this page is subject to change in future releases.
+
+To simply the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
+
+## Page Layout
+
+A layout defines the general arrangement of content on a page into rows and columns. The layout is defined under the [view](./views.md) and declares a set of rows, each of which may have one or more columns. Below is an example layout.
+
+```
++-------+-------+-------+
+| Col 1 | Col 2 | Col 3 |
++-------+-------+-------+
+|         Col 4         |
++-----------+-----------+
+|   Col 5   |   Col 6   |
++-----------+-----------+
+```
+
+The above layout can be achieved with the following declaration under a view:
+
+```python
+from netbox.ui import layout
+from netbox.views import generic
+
+class MyView(generic.ObjectView):
+    layout = layout.Layout(
+        layout.Row(
+            layout.Column(),
+            layout.Column(),
+            layout.Column(),
+        ),
+        layout.Row(
+            layout.Column(),
+        ),
+        layout.Row(
+            layout.Column(),
+            layout.Column(),
+        ),
+    )
+```
+
+!!! note
+    Currently, layouts are supported only for subclasses of [`generic.ObjectView`](./views.md#netbox.views.generic.ObjectView).
+
+::: netbox.ui.layout.Layout
+
+::: netbox.ui.layout.SimpleLayout
+
+::: netbox.ui.layout.Row
+
+::: netbox.ui.layout.Column
+
+## Panels
+
+Within each column, related blocks of content are arranged into panels. Each panel has a title and may have a set of associated actions, but the content within is otherwise arbitrary.
+
+Plugins can define their own panels by inheriting from the base class `netbox.ui.panels.Panel`. Override the `get_context()` method to pass additional context to your custom panel template. An example is provided below.
+
+```python
+from django.utils.translation import gettext_lazy as _
+from netbox.ui.panels import Panel
+
+class RecentChangesPanel(Panel):
+    template_name = 'my_plugin/panels/recent_changes.html'
+    title = _('Recent Changes')
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'changes': get_changes()[:10],
+        }
+```
+
+NetBox also includes a set of panels suite for specific uses, such as display object details or embedding a table of related objects. These are listed below.
+
+::: netbox.ui.panels.Panel
+
+::: netbox.ui.panels.ObjectPanel
+
+::: netbox.ui.panels.ObjectAttributesPanel
+
+#### Object Attributes
+
+The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
+
+| Class                                | Description                                      |
+|--------------------------------------|--------------------------------------------------|
+| `netbox.ui.attrs.AddressAttr`        | A physical or mailing address.                   |
+| `netbox.ui.attrs.BooleanAttr`        | A boolean value                                  |
+| `netbox.ui.attrs.ColorAttr`          | A color expressed in RGB                         |
+| `netbox.ui.attrs.ChoiceAttr`         | A selection from a set of choices                |
+| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude)         |
+| `netbox.ui.attrs.ImageAttr`          | An attached image (displays the image)           |
+| `netbox.ui.attrs.NestedObjectAttr`   | A related nested object                          |
+| `netbox.ui.attrs.NumericAttr`        | An integer or float value                        |
+| `netbox.ui.attrs.RelatedObjectAttr`  | A related object                                 |
+| `netbox.ui.attrs.TemplatedAttr`      | Renders an attribute using a custom template     |
+| `netbox.ui.attrs.TextAttr`           | A string (text) value                            |
+| `netbox.ui.attrs.TimezoneAttr`       | A timezone with annotated offset                 |
+| `netbox.ui.attrs.UtilizationAttr`    | A numeric value expressed as a utilization graph |
+
+::: netbox.ui.panels.OrganizationalObjectPanel
+
+::: netbox.ui.panels.NestedGroupObjectPanel
+
+::: netbox.ui.panels.CommentsPanel
+
+::: netbox.ui.panels.JSONPanel
+
+::: netbox.ui.panels.RelatedObjectsPanel
+
+::: netbox.ui.panels.ObjectsTablePanel
+
+::: netbox.ui.panels.TemplatePanel
+
+::: netbox.ui.panels.PluginContentPanel
+
+## Panel Actions
+
+Each panel may have actions associated with it. These render as links or buttons within the panel header, opposite the panel's title. For example, a common use case is to include an "Add" action on a panel which displays a list of objects. Below is an example of this.
+
+```python
+from django.utils.translation import gettext_lazy as _
+from netbox.ui import actions, panels
+
+panels.ObjectsTablePanel(
+    model='dcim.Region',
+    title=_('Child Regions'),
+    filters={'parent_id': lambda ctx: ctx['object'].pk},
+    actions=[
+        actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
+    ],
+),
+```
+
+::: netbox.ui.actions.PanelAction
+
+::: netbox.ui.actions.LinkAction
+
+::: netbox.ui.actions.AddObject
+
+::: netbox.ui.actions.CopyContent

+ 8 - 0
docs/release-notes/index.md

@@ -10,6 +10,14 @@ Minor releases are published in April, August, and December of each calendar yea
 
 This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
 
+#### [Version 4.5](./version-4.5.md) (January 2026)
+
+* Lookup Modifiers in Filter Forms ([#7604](https://github.com/netbox-community/netbox/issues/7604))
+* Improved API Authentication Tokens ([#20210](https://github.com/netbox-community/netbox/issues/20210))
+* Object Ownership ([#20304](https://github.com/netbox-community/netbox/issues/20304))
+* Advanced Port Mappings ([#20564](https://github.com/netbox-community/netbox/issues/20564))
+* Cable Profiles ([#20788](https://github.com/netbox-community/netbox/issues/20788))
+
 #### [Version 4.4](./version-4.4.md) (September 2025)
 
 * Background Jobs for Bulk Operations ([#19589](https://github.com/netbox-community/netbox/issues/19589), [#19891](https://github.com/netbox-community/netbox/issues/19891))

+ 150 - 0
docs/release-notes/version-4.5.md

@@ -0,0 +1,150 @@
+## v4.5.0 (FUTURE)
+
+### Breaking Changes
+
+* Python 3.10 and 3.11 are no longer supported. NetBox now requires Python 3.12, 3.13, or 3.14.
+* GraphQL API queries which filter by object IDs or enums must now specify a filter lookup similar to other fields. For example, `id: 123` becomes `id: {exact: 123 }`.
+* Rendering a device or virtual machine configuration is now restricted to users with the `render_config` permission for the applicable object type.
+* Retrieval of API token plaintexts is no longer supported. The `ALLOW_TOKEN_RETRIEVAL` config parameter has been removed.
+* API tokens can no longer be reassigned from one user to another.
+* A config context assigned to a platform will now also apply to any children of that platform. (Although this is typically desired behavior, it may introduce unanticipated changes for existing deployments.)
+* The `/api/dcim/cable-terminations/` REST API endpoint is now read-only. Cable terminations must be set on cables directly via the `/api/dcim/cables/` endpoint.
+* The UI view dedicated to swapping A/Z circuit terminations has been removed.
+* The experimental HTMX navigation feature has been removed.
+* The obsolete boolean field `is_staff` has been removed from the `User` model.
+* Removal of deprecated behavior
+    * The `/api/extras/object-types/` REST API endpoint has been removed. (Use `/api/core/object-types/` instead.)
+    * Webhooks no longer specify a `model` in payload data. (Reference `object_type` instead, which includes the parent app label.)
+    * The obsolete module `core.models.contenttypes` has been removed (replaced in v4.4 by `core.models.object_types`).
+    * The `load_yaml()` and `load_json()` utility methods have been removed from the base class for custom scripts.
+
+### New Features
+
+#### Lookup Modifiers in Filter Forms ([#7604](https://github.com/netbox-community/netbox/issues/7604))
+
+Most object list filters within the UI have been extended to include optional lookup modifiers to support more complex queries. For instance, filters for numeric values now include a dropdown where a user can select "less than," "greater than," or "not" in addition to the default equivalency match. The specific modifiers available depend on the type of each filter. Plugins can register their own filtersets using the `register_filterset()` decorator to enable this new functionality.
+
+(Note that this feature does not introduce any new filters. Rather, it makes available in the UI filters which already exist.)
+
+#### Improved API Authentication Tokens ([#20210](https://github.com/netbox-community/netbox/issues/20210))
+
+This release introduces a new version of API token (v2) which implements several security improvements. HMAC hashing with a cryptographic pepper is used to authenticate these tokens, obviating the need to store plaintexts. The new tokens also employ a non-sensitive key which can be shared to identify tokens without divulging their plaintexts. We've also adopted the standard "bearer" HTTP header format, as shown below.
+
+```
+# v1 token header
+Authorization: Token <TOKEN>
+
+# v2 token header
+Authorization: Bearer nbt_<KEY>.<TOKEN>
+```
+
+Note that v2 token keys are prefixed with the fixed string `nbt_`, which can be used to aid in secret detection.
+
+Backward compatibility with legacy (v1) tokens is retained in this release. However, users are strongly encouraged to begin using only v2 tokens, as support for legacy tokens will be removed in NetBox v4.7.
+
+#### Object Ownership ([#20304](https://github.com/netbox-community/netbox/issues/20304))
+
+An optional `owner` foreign key field has been added to most models. This enables the assignment of objects to a new Owner model, which represents a set of users and/or groups. Through this relationship, we can now convey ownership of objects within NetBox natively, without needing to rely on the assignment of tags or custom fields.
+
+(Note that ownership differs significantly in function from tenancy. Ownership determines the parties responsible for the maintenance of an object, whereas as tenancy conveys an operational dependency.)
+
+#### Advanced Port Mappings ([#20564](https://github.com/netbox-community/netbox/issues/20564))
+
+The previous many-to-one mapping of front to rear ports has been expanded to support bidirectional mappings. The `rear_port` and `rear_port_position` fields on the FrontPort model have been replaced with an intermediary PortMapping model, which supports any number of assignments between front port/position pair and a rear port/position pair. This change unlocks the ability to model complex inline devices that swap individual fiber pairs between cables.
+
+#### Cable Profiles ([#20788](https://github.com/netbox-community/netbox/issues/20788))
+
+Cables can now be assigned profiles which determine how they are treated for path tracing. A profile indicates the number of discrete parallel channels or lanes carried by the cable among its endpoints. For example, a 1-to-4 breakout cable has four lanes, shared at one end via a common termination and split out at the other end to four separate terminations. Profiles, when assigned, enable NetBox to more accurately trace a specific connection within a cable, rather than the cable as a whole.
+
+The assignment of cable profiles is optional: Cable tracing will continue to operate as before for cables with no profile assigned.
+
+### Enhancements
+
+* [#16681](https://github.com/netbox-community/netbox/issues/16681) - Introduce a `render_config` permission, which is now required to render a device or virtual machine configuration
+* [#18658](https://github.com/netbox-community/netbox/issues/18658) - Add a `start_on_boot` choice field for virtual machines
+* [#19095](https://github.com/netbox-community/netbox/issues/19095) - Add support for Python 3.13 and 3.14
+* [#19338](https://github.com/netbox-community/netbox/issues/19338) - Enable filter lookups for object IDs and enums in GraphQL API queries
+* [#19523](https://github.com/netbox-community/netbox/issues/19523) - Cache the number of instances for device, module, and rack types, and enable filtering by these counts
+* [#20417](https://github.com/netbox-community/netbox/issues/20417) - Add an optional `color` field for device type power outlets
+* [#20476](https://github.com/netbox-community/netbox/issues/20476) - Once provisioned, the owner of an API token cannot be changed
+* [#20492](https://github.com/netbox-community/netbox/issues/20492) - Completely disabled the means to retrieve legacy API token plaintexts (removed the `ALLOW_TOKEN_RETRIEVAL` config parameter)
+* [#20639](https://github.com/netbox-community/netbox/issues/20639) - Apply config contexts to devices/VMs assigned any child platform of the parent platform
+* [#20834](https://github.com/netbox-community/netbox/issues/20834) - Add an `enabled` boolean field to API tokens
+* [#20917](https://github.com/netbox-community/netbox/issues/20917) - Include usage reference on API token views
+* [#20925](https://github.com/netbox-community/netbox/issues/20925) - Add optional `comments` field to all subclasses of `OrganizationalModel`
+* [#20929](https://github.com/netbox-community/netbox/issues/20929) - Require the `render_config` permission to view a rendered device/VM configuration in the UI
+* [#20936](https://github.com/netbox-community/netbox/issues/20936) - Introduce the `/api/authentication-check/` REST API endpoint for validating authentication tokens
+* [#20959](https://github.com/netbox-community/netbox/issues/20959) - Include a count of related module types for a manufacturer in the REST API
+
+### Plugins
+
+* [#13182](https://github.com/netbox-community/netbox/issues/13182) - Added `PrimaryModel`, `OrganizationalModel`, and `NestedGroupModel` to the plugins API, as well as their respective base classes for various resources
+
+### Other Changes
+
+* [#16137](https://github.com/netbox-community/netbox/issues/16137) - Remove the obsolete boolean field `is_staff` from the `User` model
+* [#17571](https://github.com/netbox-community/netbox/issues/17571) - Remove the experimental HTMX navigation feature
+* [#17936](https://github.com/netbox-community/netbox/issues/17936) - Introduce a dedicated `GFKSerializerField` for representing generic foreign keys in API serializers
+* [#19889](https://github.com/netbox-community/netbox/issues/19889) - Drop support for Python 3.10 and 3.11
+* [#19898](https://github.com/netbox-community/netbox/issues/19898) - Remove the obsolete REST API endpoint `/api/extras/object-types/`
+* [#20088](https://github.com/netbox-community/netbox/issues/20088) - Remove the non-deterministic `model` key from webhook payload data
+* [#20095](https://github.com/netbox-community/netbox/issues/20095) - Remove the obsolete module `core.models.contenttypes`
+* [#20096](https://github.com/netbox-community/netbox/issues/20096) - Remove the `load_yaml()` and `load_json()` utility methods from the `BaseScript` class
+* [#20204](https://github.com/netbox-community/netbox/issues/20204) - Started migrating object views from custom HTML templates to declarative layouts
+* [#20295](https://github.com/netbox-community/netbox/issues/20295) - Cable terminations may be modified via the REST API only by modifying the cable itself
+* [#20617](https://github.com/netbox-community/netbox/issues/20617) - Introduce `BaseModel` as the global base class for models
+* [#20683](https://github.com/netbox-community/netbox/issues/20683) - Remove the UI view dedicated to swapping A/Z circuit terminations
+* [#20926](https://github.com/netbox-community/netbox/issues/20926) - Standardize naming of GraphQL filters
+
+### REST API Changes
+
+* Most objects now include an optional `owner` foreign key field.
+* The `/api/dcim/cable-terminations` endpoint is now read-only.
+* Introduced the `/api/authentication-check/` endpoint to test REST API credentials
+* `circuits.CircuitGroup`
+    * Add optional `comments` field
+* `circuits.CircuitType`
+    * Add optional `comments` field
+* `circuits.VirtualCircuitType`
+    * Add optional `comments` field
+* `dcim.Cable`
+    * Add the optional `profile` choice field
+* `dcim.FrontPort`
+    * Removed the `rear_port` and `rear_port_position` fields
+    * Add the `positions` integer field
+    * Add the `rear_ports` list for port mappings
+* `dcim.InventoryItemRole`
+    * Add optional `comments` field
+* `dcim.Manufacturer`
+    * Add optional `comments` field
+    * Add read-only `moduletype_count` integer field
+* `dcim.ModuleType`
+    * Add read-only `module_count` integer field
+* `dcim.PowerOutletTemplate`
+    * Add optional `color` field
+* `dcim.RackRole`
+    * Add optional `comments` field
+* `dcim.RackType`
+    * Add read-only `rack_count` integer field
+* `dcim.RearPort`
+    * Add the `front_ports` list for port mappings
+* `ipam.ASNRange`
+    * Add optional `comments` field
+* `ipam.RIR`
+    * Add optional `comments` field
+* `ipam.Role`
+    * Add optional `comments` field
+* `ipam.VLANGroup`
+    * Add optional `comments` field
+* `tenancy.ContactRole`
+    * Add optional `comments` field
+* `users.Token`
+    * Add `enabled` boolean field
+* `virtualization.ClusterGroup`
+    * Add optional `comments` field
+* `virtualization.ClusterType`
+    * Add optional `comments` field
+* `virtualization.VirtualMachine`
+    * Add optional `start_on_boot` choice field
+* `vpn.TunnelGroup`
+    * Add optional `comments` field

+ 6 - 0
mkdocs.yml

@@ -77,6 +77,7 @@ nav:
         - Wireless: 'features/wireless.md'
         - Virtualization: 'features/virtualization.md'
         - VPN Tunnels: 'features/vpn-tunnels.md'
+        - Resource Ownership: 'features/resource-ownership.md'
         - Tenancy: 'features/tenancy.md'
         - Contacts: 'features/contacts.md'
         - Search: 'features/search.md'
@@ -142,6 +143,7 @@ nav:
             - Getting Started: 'plugins/development/index.md'
             - Models: 'plugins/development/models.md'
             - Views: 'plugins/development/views.md'
+            - UI Components: 'plugins/development/ui-components.md'
             - Navigation: 'plugins/development/navigation.md'
             - Templates: 'plugins/development/templates.md'
             - Tables: 'plugins/development/tables.md'
@@ -273,6 +275,9 @@ nav:
             - ContactRole: 'models/tenancy/contactrole.md'
             - Tenant: 'models/tenancy/tenant.md'
             - TenantGroup: 'models/tenancy/tenantgroup.md'
+        - Users:
+            - Owner: 'models/users/owner.md'
+            - OwnerGroup: 'models/users/ownergroup.md'
         - Virtualization:
             - Cluster: 'models/virtualization/cluster.md'
             - ClusterGroup: 'models/virtualization/clustergroup.md'
@@ -317,6 +322,7 @@ nav:
         - git Cheat Sheet: 'development/git-cheat-sheet.md'
     - Release Notes:
         - Summary: 'release-notes/index.md'
+        - Version 4.5: 'release-notes/version-4.5.md'
         - Version 4.4: 'release-notes/version-4.4.md'
         - Version 4.3: 'release-notes/version-4.3.md'
         - Version 4.2: 'release-notes/version-4.2.md'

+ 0 - 57
netbox/account/tables.py

@@ -1,57 +0,0 @@
-from django.utils.translation import gettext as _
-
-from account.models import UserToken
-from netbox.tables import NetBoxTable, columns
-
-__all__ = (
-    'UserTokenTable',
-)
-
-
-TOKEN = """<samp><span id="token_{{ record.pk }}">{{ record }}</span></samp>"""
-
-ALLOWED_IPS = """{{ value|join:", " }}"""
-
-COPY_BUTTON = """
-{% if settings.ALLOW_TOKEN_RETRIEVAL %}
-  {% copy_content record.pk prefix="token_" color="success" %}
-{% endif %}
-"""
-
-
-class UserTokenTable(NetBoxTable):
-    """
-    Table for users to manager their own API tokens under account views.
-    """
-    key = columns.TemplateColumn(
-        verbose_name=_('Key'),
-        template_code=TOKEN,
-    )
-    write_enabled = columns.BooleanColumn(
-        verbose_name=_('Write Enabled')
-    )
-    created = columns.DateTimeColumn(
-        timespec='minutes',
-        verbose_name=_('Created'),
-    )
-    expires = columns.DateTimeColumn(
-        timespec='minutes',
-        verbose_name=_('Expires'),
-    )
-    last_used = columns.DateTimeColumn(
-        verbose_name=_('Last Used'),
-    )
-    allowed_ips = columns.TemplateColumn(
-        verbose_name=_('Allowed IPs'),
-        template_code=ALLOWED_IPS
-    )
-    actions = columns.ActionsColumn(
-        actions=('edit', 'delete'),
-        extra_buttons=COPY_BUTTON
-    )
-
-    class Meta(NetBoxTable.Meta):
-        model = UserToken
-        fields = (
-            'pk', 'id', 'key', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
-        )

+ 15 - 4
netbox/account/views.py

@@ -25,9 +25,12 @@ from extras.models import Bookmark
 from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable
 from netbox.authentication import get_auth_backend_display, get_saml_idps
 from netbox.config import get_config
+from netbox.ui import layout
 from netbox.views import generic
-from users import forms, tables
+from users import forms
 from users.models import UserConfig
+from users.tables import TokenTable
+from users.ui.panels import TokenExamplePanel, TokenPanel
 from utilities.request import safe_for_redirect
 from utilities.string import remove_linebreaks
 from utilities.views import register_model_view
@@ -328,7 +331,8 @@ class UserTokenListView(LoginRequiredMixin, View):
 
     def get(self, request):
         tokens = UserToken.objects.filter(user=request.user)
-        table = tables.UserTokenTable(tokens)
+        table = TokenTable(tokens)
+        table.columns.hide('user')
         table.configure(request)
 
         return render(request, 'account/token_list.html', {
@@ -340,14 +344,21 @@ class UserTokenListView(LoginRequiredMixin, View):
 
 @register_model_view(UserToken)
 class UserTokenView(LoginRequiredMixin, View):
+    layout = layout.SimpleLayout(
+        left_panels=[
+            TokenPanel(),
+        ],
+        right_panels=[
+            TokenExamplePanel(),
+        ],
+    )
 
     def get(self, request, pk):
         token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
-        key = token.key if settings.ALLOW_TOKEN_RETRIEVAL else None
 
         return render(request, 'account/token.html', {
             'object': token,
-            'key': key,
+            'layout': self.layout,
         })
 
 

+ 20 - 43
netbox/circuits/api/serializers_/circuits.py

@@ -1,5 +1,4 @@
 from django.contrib.contenttypes.models import ContentType
-from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
@@ -11,10 +10,12 @@ from circuits.models import (
 from dcim.api.serializers_.device_components import InterfaceSerializer
 from dcim.api.serializers_.cables import CabledObjectSerializer
 from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
-from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
+from netbox.api.gfk_fields import GFKSerializerField
+from netbox.api.serializers import (
+    NetBoxModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer,
+)
 from netbox.choices import DistanceUnitChoices
 from tenancy.api.serializers_.tenants import TenantSerializer
-from utilities.api import get_serializer_for_model
 from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer
 
 __all__ = (
@@ -29,7 +30,7 @@ __all__ = (
 )
 
 
-class CircuitTypeSerializer(NetBoxModelSerializer):
+class CircuitTypeSerializer(OrganizationalModelSerializer):
 
     # Related object counts
     circuit_count = RelatedObjectCountField('circuits')
@@ -37,8 +38,8 @@ class CircuitTypeSerializer(NetBoxModelSerializer):
     class Meta:
         model = CircuitType
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
-            'created', 'last_updated', 'circuit_count',
+            'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated', 'circuit_count',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
 
@@ -53,7 +54,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
         default=None
     )
     termination_id = serializers.IntegerField(allow_null=True, required=False, default=None)
-    termination = serializers.SerializerMethodField(read_only=True)
+    termination = GFKSerializerField(read_only=True)
 
     class Meta:
         model = CircuitTermination
@@ -62,24 +63,16 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
             'upstream_speed', 'xconnect_id', 'description',
         ]
 
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_termination(self, obj):
-        if obj.termination_id is None:
-            return None
-        serializer = get_serializer_for_model(obj.termination)
-        context = {'request': self.context['request']}
-        return serializer(obj.termination, nested=True, context=context).data
-
 
-class CircuitGroupSerializer(NetBoxModelSerializer):
+class CircuitGroupSerializer(OrganizationalModelSerializer):
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
     circuit_count = RelatedObjectCountField('assignments')
 
     class Meta:
         model = CircuitGroup
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tenant',
-            'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count'
+            'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tenant', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated', 'circuit_count'
         ]
         brief_fields = ('id', 'url', 'display', 'name')
 
@@ -99,7 +92,7 @@ class CircuitGroupAssignmentSerializer_(NetBoxModelSerializer):
         brief_fields = ('id', 'url', 'display', 'group', 'priority')
 
 
-class CircuitSerializer(NetBoxModelSerializer):
+class CircuitSerializer(PrimaryModelSerializer):
     provider = ProviderSerializer(nested=True)
     provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
     status = ChoiceField(choices=CircuitStatusChoices, required=False)
@@ -115,7 +108,7 @@ class CircuitSerializer(NetBoxModelSerializer):
         fields = [
             'id', 'url', 'display_url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant',
             'install_date', 'termination_date', 'commit_rate', 'description', 'distance', 'distance_unit',
-            'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'termination_a', 'termination_z', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
             'assignments',
         ]
         brief_fields = ('id', 'url', 'display', 'provider', 'cid', 'description')
@@ -132,7 +125,7 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
         default=None
     )
     termination_id = serializers.IntegerField(allow_null=True, required=False, default=None)
-    termination = serializers.SerializerMethodField(read_only=True)
+    termination = GFKSerializerField(read_only=True)
 
     class Meta:
         model = CircuitTermination
@@ -144,20 +137,12 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
         ]
         brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')
 
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_termination(self, obj):
-        if obj.termination_id is None:
-            return None
-        serializer = get_serializer_for_model(obj.termination)
-        context = {'request': self.context['request']}
-        return serializer(obj.termination, nested=True, context=context).data
-
 
 class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
     member_type = ContentTypeField(
         queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS)
     )
-    member = serializers.SerializerMethodField(read_only=True)
+    member = GFKSerializerField(read_only=True)
 
     class Meta:
         model = CircuitGroupAssignment
@@ -167,16 +152,8 @@ class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
         ]
         brief_fields = ('id', 'url', 'display', 'group', 'member_type', 'member_id', 'member', 'priority')
 
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_member(self, obj):
-        if obj.member_id is None:
-            return None
-        serializer = get_serializer_for_model(obj.member)
-        context = {'request': self.context['request']}
-        return serializer(obj.member, nested=True, context=context).data
-
 
-class VirtualCircuitTypeSerializer(NetBoxModelSerializer):
+class VirtualCircuitTypeSerializer(OrganizationalModelSerializer):
 
     # Related object counts
     virtual_circuit_count = RelatedObjectCountField('virtual_circuits')
@@ -184,13 +161,13 @@ class VirtualCircuitTypeSerializer(NetBoxModelSerializer):
     class Meta:
         model = VirtualCircuitType
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
-            'created', 'last_updated', 'virtual_circuit_count',
+            'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated', 'virtual_circuit_count',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'virtual_circuit_count')
 
 
-class VirtualCircuitSerializer(NetBoxModelSerializer):
+class VirtualCircuitSerializer(PrimaryModelSerializer):
     provider_network = ProviderNetworkSerializer(nested=True)
     provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
     type = VirtualCircuitTypeSerializer(nested=True)
@@ -201,7 +178,7 @@ class VirtualCircuitSerializer(NetBoxModelSerializer):
         model = VirtualCircuit
         fields = [
             'id', 'url', 'display_url', 'display', 'cid', 'provider_network', 'provider_account', 'type', 'status',
-            'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'provider_network', 'cid', 'description')
 

+ 9 - 9
netbox/circuits/api/serializers_/providers.py

@@ -4,7 +4,7 @@ from circuits.models import Provider, ProviderAccount, ProviderNetwork
 from ipam.api.serializers_.asns import ASNSerializer
 from ipam.models import ASN
 from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
-from netbox.api.serializers import NetBoxModelSerializer
+from netbox.api.serializers import PrimaryModelSerializer
 from .nested import NestedProviderAccountSerializer
 
 __all__ = (
@@ -14,7 +14,7 @@ __all__ = (
 )
 
 
-class ProviderSerializer(NetBoxModelSerializer):
+class ProviderSerializer(PrimaryModelSerializer):
     accounts = SerializedPKRelatedField(
         queryset=ProviderAccount.objects.all(),
         serializer=NestedProviderAccountSerializer,
@@ -35,32 +35,32 @@ class ProviderSerializer(NetBoxModelSerializer):
     class Meta:
         model = Provider
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'slug', 'accounts', 'description', 'comments',
+            'id', 'url', 'display_url', 'display', 'name', 'slug', 'accounts', 'description', 'owner', 'comments',
             'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
 
 
-class ProviderAccountSerializer(NetBoxModelSerializer):
+class ProviderAccountSerializer(PrimaryModelSerializer):
     provider = ProviderSerializer(nested=True)
     name = serializers.CharField(allow_blank=True, max_length=100, required=False, default='')
 
     class Meta:
         model = ProviderAccount
         fields = [
-            'id', 'url', 'display_url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags',
-            'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display_url', 'display', 'provider', 'name', 'account', 'description', 'owner', 'comments',
+            'tags', 'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'account', 'description')
 
 
-class ProviderNetworkSerializer(NetBoxModelSerializer):
+class ProviderNetworkSerializer(PrimaryModelSerializer):
     provider = ProviderSerializer(nested=True)
 
     class Meta:
         model = ProviderNetwork
         fields = [
-            'id', 'url', 'display_url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
-            'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display_url', 'display', 'provider', 'name', 'service_id', 'description', 'owner', 'comments',
+            'tags', 'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')

+ 19 - 7
netbox/circuits/filtersets.py

@@ -6,11 +6,12 @@ from django.utils.translation import gettext as _
 from dcim.filtersets import CabledObjectFilterSet
 from dcim.models import Interface, Location, Region, Site, SiteGroup
 from ipam.models import ASN
-from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
+from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
 from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
 from utilities.filters import (
     ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
 )
+from utilities.filtersets import register_filterset
 from .choices import *
 from .models import *
 
@@ -29,7 +30,8 @@ __all__ = (
 )
 
 
-class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
+@register_filterset
+class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         field_name='circuits__terminations___region',
@@ -93,7 +95,8 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
         )
 
 
-class ProviderAccountFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
+@register_filterset
+class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
     provider_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Provider.objects.all(),
         label=_('Provider (ID)'),
@@ -120,7 +123,8 @@ class ProviderAccountFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
         ).distinct()
 
 
-class ProviderNetworkFilterSet(NetBoxModelFilterSet):
+@register_filterset
+class ProviderNetworkFilterSet(PrimaryModelFilterSet):
     provider_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Provider.objects.all(),
         label=_('Provider (ID)'),
@@ -147,6 +151,7 @@ class ProviderNetworkFilterSet(NetBoxModelFilterSet):
         ).distinct()
 
 
+@register_filterset
 class CircuitTypeFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
@@ -154,7 +159,8 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
         fields = ('id', 'name', 'slug', 'color', 'description')
 
 
-class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
+@register_filterset
+class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     provider_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Provider.objects.all(),
         label=_('Provider (ID)'),
@@ -265,6 +271,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
         ).distinct()
 
 
+@register_filterset
 class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
     q = django_filters.CharFilter(
         method='search',
@@ -346,7 +353,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
         model = CircuitTermination
         fields = (
             'id', 'termination_id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description',
-            'mark_connected', 'pp_info', 'cable_end',
+            'mark_connected', 'pp_info', 'cable_end', 'cable_connector',
         )
 
     def search(self, queryset, name, value):
@@ -360,6 +367,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
         ).distinct()
 
 
+@register_filterset
 class CircuitGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
 
     class Meta:
@@ -367,6 +375,7 @@ class CircuitGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
         fields = ('id', 'name', 'slug', 'description')
 
 
+@register_filterset
 class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
@@ -466,6 +475,7 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
         )
 
 
+@register_filterset
 class VirtualCircuitTypeFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
@@ -473,7 +483,8 @@ class VirtualCircuitTypeFilterSet(OrganizationalModelFilterSet):
         fields = ('id', 'name', 'slug', 'color', 'description')
 
 
-class VirtualCircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+@register_filterset
+class VirtualCircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     provider_id = django_filters.ModelMultipleChoiceFilter(
         field_name='provider_network__provider',
         queryset=Provider.objects.all(),
@@ -529,6 +540,7 @@ class VirtualCircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         ).distinct()
 
 
+@register_filterset
 class VirtualCircuitTerminationFilterSet(NetBoxModelFilterSet):
     q = django_filters.CharFilter(
         method='search',

+ 13 - 58
netbox/circuits/forms/bulk_edit.py

@@ -11,11 +11,11 @@ from circuits.models import *
 from dcim.models import Site
 from ipam.models import ASN
 from netbox.choices import DistanceUnitChoices
-from netbox.forms import NetBoxModelBulkEditForm
+from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm
 from tenancy.models import Tenant
 from utilities.forms import add_blank_choice, get_field_value
 from utilities.forms.fields import (
-    ColorField, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
+    ColorField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
 )
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, HTMXSelect, NumberWithOptions
@@ -36,18 +36,12 @@ __all__ = (
 )
 
 
-class ProviderBulkEditForm(NetBoxModelBulkEditForm):
+class ProviderBulkEditForm(PrimaryModelBulkEditForm):
     asns = DynamicModelMultipleChoiceField(
         queryset=ASN.objects.all(),
         label=_('ASNs'),
         required=False
     )
-    description = forms.CharField(
-        label=_('Description'),
-        max_length=200,
-        required=False
-    )
-    comments = CommentField()
 
     model = Provider
     fieldsets = (
@@ -58,18 +52,12 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
     )
 
 
-class ProviderAccountBulkEditForm(NetBoxModelBulkEditForm):
+class ProviderAccountBulkEditForm(PrimaryModelBulkEditForm):
     provider = DynamicModelChoiceField(
         label=_('Provider'),
         queryset=Provider.objects.all(),
         required=False
     )
-    description = forms.CharField(
-        label=_('Description'),
-        max_length=200,
-        required=False
-    )
-    comments = CommentField()
 
     model = ProviderAccount
     fieldsets = (
@@ -80,7 +68,7 @@ class ProviderAccountBulkEditForm(NetBoxModelBulkEditForm):
     )
 
 
-class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
+class ProviderNetworkBulkEditForm(PrimaryModelBulkEditForm):
     provider = DynamicModelChoiceField(
         label=_('Provider'),
         queryset=Provider.objects.all(),
@@ -91,12 +79,6 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
         required=False,
         label=_('Service ID')
     )
-    description = forms.CharField(
-        label=_('Description'),
-        max_length=200,
-        required=False
-    )
-    comments = CommentField()
 
     model = ProviderNetwork
     fieldsets = (
@@ -107,25 +89,20 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
     )
 
 
-class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
+class CircuitTypeBulkEditForm(OrganizationalModelBulkEditForm):
     color = ColorField(
         label=_('Color'),
         required=False
     )
-    description = forms.CharField(
-        label=_('Description'),
-        max_length=200,
-        required=False
-    )
 
     model = CircuitType
     fieldsets = (
         FieldSet('color', 'description'),
     )
-    nullable_fields = ('color', 'description')
+    nullable_fields = ('color', 'description', 'comments')
 
 
-class CircuitBulkEditForm(NetBoxModelBulkEditForm):
+class CircuitBulkEditForm(PrimaryModelBulkEditForm):
     type = DynamicModelChoiceField(
         label=_('Type'),
         queryset=CircuitType.objects.all(),
@@ -183,12 +160,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
         required=False,
         initial=''
     )
-    description = forms.CharField(
-        label=_('Description'),
-        max_length=100,
-        required=False
-    )
-    comments = CommentField()
 
     model = Circuit
     fieldsets = (
@@ -261,12 +232,7 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
                 pass
 
 
-class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm):
-    description = forms.CharField(
-        label=_('Description'),
-        max_length=200,
-        required=False
-    )
+class CircuitGroupBulkEditForm(OrganizationalModelBulkEditForm):
     tenant = DynamicModelChoiceField(
         label=_('Tenant'),
         queryset=Tenant.objects.all(),
@@ -275,7 +241,7 @@ class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm):
 
     model = CircuitGroup
     nullable_fields = (
-        'description', 'tenant',
+        'description', 'tenant', 'comments',
     )
 
 
@@ -298,25 +264,20 @@ class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
     nullable_fields = ('priority',)
 
 
-class VirtualCircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
+class VirtualCircuitTypeBulkEditForm(OrganizationalModelBulkEditForm):
     color = ColorField(
         label=_('Color'),
         required=False
     )
-    description = forms.CharField(
-        label=_('Description'),
-        max_length=200,
-        required=False
-    )
 
     model = VirtualCircuitType
     fieldsets = (
         FieldSet('color', 'description'),
     )
-    nullable_fields = ('color', 'description')
+    nullable_fields = ('color', 'description', 'comments')
 
 
-class VirtualCircuitBulkEditForm(NetBoxModelBulkEditForm):
+class VirtualCircuitBulkEditForm(PrimaryModelBulkEditForm):
     provider_network = DynamicModelChoiceField(
         label=_('Provider network'),
         queryset=ProviderNetwork.objects.all(),
@@ -343,12 +304,6 @@ class VirtualCircuitBulkEditForm(NetBoxModelBulkEditForm):
         queryset=Tenant.objects.all(),
         required=False
     )
-    description = forms.CharField(
-        label=_('Description'),
-        max_length=100,
-        required=False
-    )
-    comments = CommentField()
 
     model = VirtualCircuit
     fieldsets = (

+ 18 - 19
netbox/circuits/forms/bulk_import.py

@@ -7,7 +7,7 @@ from circuits.constants import *
 from circuits.models import *
 from dcim.models import Interface
 from netbox.choices import DistanceUnitChoices
-from netbox.forms import NetBoxModelImportForm
+from netbox.forms import NetBoxModelImportForm, OrganizationalModelImportForm, PrimaryModelImportForm
 from tenancy.models import Tenant
 from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
 
@@ -28,17 +28,17 @@ __all__ = (
 )
 
 
-class ProviderImportForm(NetBoxModelImportForm):
+class ProviderImportForm(PrimaryModelImportForm):
     slug = SlugField()
 
     class Meta:
         model = Provider
         fields = (
-            'name', 'slug', 'description', 'comments', 'tags',
+            'name', 'slug', 'description', 'owner', 'comments', 'tags',
         )
 
 
-class ProviderAccountImportForm(NetBoxModelImportForm):
+class ProviderAccountImportForm(PrimaryModelImportForm):
     provider = CSVModelChoiceField(
         label=_('Provider'),
         queryset=Provider.objects.all(),
@@ -49,11 +49,11 @@ class ProviderAccountImportForm(NetBoxModelImportForm):
     class Meta:
         model = ProviderAccount
         fields = (
-            'provider', 'name', 'account', 'description', 'comments', 'tags',
+            'provider', 'name', 'account', 'description', 'owner', 'comments', 'tags',
         )
 
 
-class ProviderNetworkImportForm(NetBoxModelImportForm):
+class ProviderNetworkImportForm(PrimaryModelImportForm):
     provider = CSVModelChoiceField(
         label=_('Provider'),
         queryset=Provider.objects.all(),
@@ -64,19 +64,19 @@ class ProviderNetworkImportForm(NetBoxModelImportForm):
     class Meta:
         model = ProviderNetwork
         fields = [
-            'provider', 'name', 'service_id', 'description', 'comments', 'tags'
+            'provider', 'name', 'service_id', 'description', 'owner', 'comments', 'tags'
         ]
 
 
-class CircuitTypeImportForm(NetBoxModelImportForm):
+class CircuitTypeImportForm(OrganizationalModelImportForm):
     slug = SlugField()
 
     class Meta:
         model = CircuitType
-        fields = ('name', 'slug', 'color', 'description', 'tags')
+        fields = ('name', 'slug', 'color', 'description', 'owner', 'comments', 'tags')
 
 
-class CircuitImportForm(NetBoxModelImportForm):
+class CircuitImportForm(PrimaryModelImportForm):
     provider = CSVModelChoiceField(
         label=_('Provider'),
         queryset=Provider.objects.all(),
@@ -119,7 +119,7 @@ class CircuitImportForm(NetBoxModelImportForm):
         model = Circuit
         fields = [
             'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', 'termination_date',
-            'commit_rate', 'distance', 'distance_unit', 'description', 'comments', 'tags'
+            'commit_rate', 'distance', 'distance_unit', 'description', 'owner', 'comments', 'tags'
         ]
 
 
@@ -165,7 +165,7 @@ class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTermination
         }
 
 
-class CircuitGroupImportForm(NetBoxModelImportForm):
+class CircuitGroupImportForm(OrganizationalModelImportForm):
     tenant = CSVModelChoiceField(
         label=_('Tenant'),
         queryset=Tenant.objects.all(),
@@ -176,7 +176,7 @@ class CircuitGroupImportForm(NetBoxModelImportForm):
 
     class Meta:
         model = CircuitGroup
-        fields = ('name', 'slug', 'description', 'tenant', 'tags')
+        fields = ('name', 'slug', 'description', 'tenant', 'owner', 'comments', 'tags')
 
 
 class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
@@ -195,15 +195,14 @@ class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
         fields = ('member_type', 'member_id', 'group', 'priority')
 
 
-class VirtualCircuitTypeImportForm(NetBoxModelImportForm):
-    slug = SlugField()
+class VirtualCircuitTypeImportForm(OrganizationalModelImportForm):
 
     class Meta:
         model = VirtualCircuitType
-        fields = ('name', 'slug', 'color', 'description', 'tags')
+        fields = ('name', 'slug', 'color', 'description', 'owner', 'comments', 'tags')
 
 
-class VirtualCircuitImportForm(NetBoxModelImportForm):
+class VirtualCircuitImportForm(PrimaryModelImportForm):
     provider_network = CSVModelChoiceField(
         label=_('Provider network'),
         queryset=ProviderNetwork.objects.all(),
@@ -239,8 +238,8 @@ class VirtualCircuitImportForm(NetBoxModelImportForm):
     class Meta:
         model = VirtualCircuit
         fields = [
-            'cid', 'provider_network', 'provider_account', 'type', 'status', 'tenant', 'description', 'comments',
-            'tags',
+            'cid', 'provider_network', 'provider_account', 'type', 'status', 'tenant', 'description', 'owner',
+            'comments', 'tags',
         ]
 
 

+ 17 - 17
netbox/circuits/forms/filtersets.py

@@ -9,7 +9,7 @@ from circuits.models import *
 from dcim.models import Location, Region, Site, SiteGroup
 from ipam.models import ASN
 from netbox.choices import DistanceUnitChoices
-from netbox.forms import NetBoxModelFilterSetForm
+from netbox.forms import NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, PrimaryModelFilterSetForm
 from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
 from utilities.forms import add_blank_choice
 from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
@@ -31,10 +31,10 @@ __all__ = (
 )
 
 
-class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
+class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
     model = Provider
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
         FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
         FieldSet('asn_id', name=_('ASN')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
@@ -66,10 +66,10 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
 
 
-class ProviderAccountFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
+class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
     model = ProviderAccount
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
         FieldSet('provider_id', 'account', name=_('Attributes')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
@@ -85,10 +85,10 @@ class ProviderAccountFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm
     tag = TagFilterField(model)
 
 
-class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
+class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
     model = ProviderNetwork
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
         FieldSet('provider_id', 'service_id', name=_('Attributes')),
     )
     provider_id = DynamicModelMultipleChoiceField(
@@ -104,10 +104,10 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
 
 
-class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
+class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
     model = CircuitType
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
         FieldSet('color', name=_('Attributes')),
     )
     tag = TagFilterField(model)
@@ -118,10 +118,10 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
     )
 
 
-class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
+class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
     model = Circuit
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
         FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
         FieldSet(
             'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
@@ -271,10 +271,10 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
 
 
-class CircuitGroupFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+class CircuitGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
     model = CircuitGroup
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
     )
     tag = TagFilterField(model)
@@ -309,10 +309,10 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
 
 
-class VirtualCircuitTypeFilterForm(NetBoxModelFilterSetForm):
+class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
     model = VirtualCircuitType
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
         FieldSet('color', name=_('Attributes')),
     )
     tag = TagFilterField(model)
@@ -323,10 +323,10 @@ class VirtualCircuitTypeFilterForm(NetBoxModelFilterSetForm):
     )
 
 
-class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
+class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
     model = VirtualCircuit
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
         FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
         FieldSet('type_id', 'status', name=_('Attributes')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),

+ 19 - 30
netbox/circuits/forms/model_forms.py

@@ -10,11 +10,11 @@ from circuits.constants import *
 from circuits.models import *
 from dcim.models import Interface, Site
 from ipam.models import ASN
-from netbox.forms import NetBoxModelForm
+from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 from tenancy.forms import TenancyForm
 from utilities.forms import get_field_value
 from utilities.forms.fields import (
-    CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
+    ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
 )
 from utilities.forms.mixins import DistanceValidationMixin
 from utilities.forms.rendering import FieldSet, InlineFields
@@ -36,14 +36,13 @@ __all__ = (
 )
 
 
-class ProviderForm(NetBoxModelForm):
+class ProviderForm(PrimaryModelForm):
     slug = SlugField()
     asns = DynamicModelMultipleChoiceField(
         queryset=ASN.objects.all(),
         label=_('ASNs'),
         required=False
     )
-    comments = CommentField()
 
     fieldsets = (
         FieldSet('name', 'slug', 'asns', 'description', 'tags'),
@@ -52,34 +51,32 @@ class ProviderForm(NetBoxModelForm):
     class Meta:
         model = Provider
         fields = [
-            'name', 'slug', 'asns', 'description', 'comments', 'tags',
+            'name', 'slug', 'asns', 'description', 'owner', 'comments', 'tags',
         ]
 
 
-class ProviderAccountForm(NetBoxModelForm):
+class ProviderAccountForm(PrimaryModelForm):
     provider = DynamicModelChoiceField(
         label=_('Provider'),
         queryset=Provider.objects.all(),
         selector=True,
         quick_add=True
     )
-    comments = CommentField()
 
     class Meta:
         model = ProviderAccount
         fields = [
-            'provider', 'name', 'account', 'description', 'comments', 'tags',
+            'provider', 'name', 'account', 'description', 'owner', 'comments', 'tags',
         ]
 
 
-class ProviderNetworkForm(NetBoxModelForm):
+class ProviderNetworkForm(PrimaryModelForm):
     provider = DynamicModelChoiceField(
         label=_('Provider'),
         queryset=Provider.objects.all(),
         selector=True,
         quick_add=True
     )
-    comments = CommentField()
 
     fieldsets = (
         FieldSet('provider', 'name', 'service_id', 'description', 'tags'),
@@ -88,25 +85,23 @@ class ProviderNetworkForm(NetBoxModelForm):
     class Meta:
         model = ProviderNetwork
         fields = [
-            'provider', 'name', 'service_id', 'description', 'comments', 'tags',
+            'provider', 'name', 'service_id', 'description', 'owner', 'comments', 'tags',
         ]
 
 
-class CircuitTypeForm(NetBoxModelForm):
-    slug = SlugField()
-
+class CircuitTypeForm(OrganizationalModelForm):
     fieldsets = (
-        FieldSet('name', 'slug', 'color', 'description', 'tags'),
+        FieldSet('name', 'slug', 'color', 'description', 'owner', 'tags'),
     )
 
     class Meta:
         model = CircuitType
         fields = [
-            'name', 'slug', 'color', 'description', 'tags',
+            'name', 'slug', 'color', 'description', 'comments', 'tags',
         ]
 
 
-class CircuitForm(DistanceValidationMixin, TenancyForm, NetBoxModelForm):
+class CircuitForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm):
     provider = DynamicModelChoiceField(
         label=_('Provider'),
         queryset=Provider.objects.all(),
@@ -125,7 +120,6 @@ class CircuitForm(DistanceValidationMixin, TenancyForm, NetBoxModelForm):
         queryset=CircuitType.objects.all(),
         quick_add=True
     )
-    comments = CommentField()
 
     fieldsets = (
         FieldSet(
@@ -147,7 +141,7 @@ class CircuitForm(DistanceValidationMixin, TenancyForm, NetBoxModelForm):
         model = Circuit
         fields = [
             'cid', 'type', 'provider', 'provider_account', 'status', 'install_date', 'termination_date', 'commit_rate',
-            'distance', 'distance_unit', 'description', 'tenant_group', 'tenant', 'comments', 'tags',
+            'distance', 'distance_unit', 'description', 'tenant_group', 'tenant', 'owner', 'comments', 'tags',
         ]
         widgets = {
             'install_date': DatePicker(),
@@ -233,9 +227,7 @@ class CircuitTerminationForm(NetBoxModelForm):
         self.instance.termination = self.cleaned_data.get('termination')
 
 
-class CircuitGroupForm(TenancyForm, NetBoxModelForm):
-    slug = SlugField()
-
+class CircuitGroupForm(TenancyForm, OrganizationalModelForm):
     fieldsets = (
         FieldSet('name', 'slug', 'description', 'tags', name=_('Circuit Group')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
@@ -244,7 +236,7 @@ class CircuitGroupForm(TenancyForm, NetBoxModelForm):
     class Meta:
         model = CircuitGroup
         fields = [
-            'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
+            'name', 'slug', 'description', 'tenant_group', 'tenant', 'owner', 'comments', 'tags',
         ]
 
 
@@ -307,9 +299,7 @@ class CircuitGroupAssignmentForm(NetBoxModelForm):
         self.instance.member = self.cleaned_data.get('member')
 
 
-class VirtualCircuitTypeForm(NetBoxModelForm):
-    slug = SlugField()
-
+class VirtualCircuitTypeForm(OrganizationalModelForm):
     fieldsets = (
         FieldSet('name', 'slug', 'color', 'description', 'tags'),
     )
@@ -317,11 +307,11 @@ class VirtualCircuitTypeForm(NetBoxModelForm):
     class Meta:
         model = VirtualCircuitType
         fields = [
-            'name', 'slug', 'color', 'description', 'tags',
+            'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
         ]
 
 
-class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
+class VirtualCircuitForm(TenancyForm, PrimaryModelForm):
     provider_network = DynamicModelChoiceField(
         label=_('Provider network'),
         queryset=ProviderNetwork.objects.all(),
@@ -336,7 +326,6 @@ class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
         queryset=VirtualCircuitType.objects.all(),
         quick_add=True
     )
-    comments = CommentField()
 
     fieldsets = (
         FieldSet(
@@ -350,7 +339,7 @@ class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
         model = VirtualCircuit
         fields = [
             'cid', 'provider_network', 'provider_account', 'type', 'status', 'description', 'tenant_group', 'tenant',
-            'comments', 'tags',
+            'owner', 'comments', 'tags',
         ]
 
 

+ 6 - 5
netbox/circuits/graphql/filter_mixins.py

@@ -3,17 +3,18 @@ from typing import Annotated, TYPE_CHECKING
 
 import strawberry
 import strawberry_django
-
-from netbox.graphql.filter_mixins import OrganizationalModelFilterMixin
+from strawberry_django import BaseFilterLookup
 
 if TYPE_CHECKING:
     from netbox.graphql.enums import ColorEnum
 
 __all__ = (
-    'BaseCircuitTypeFilterMixin',
+    'CircuitTypeFilterMixin',
 )
 
 
 @dataclass
-class BaseCircuitTypeFilterMixin(OrganizationalModelFilterMixin):
-    color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
+class CircuitTypeFilterMixin:
+    color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )

+ 26 - 30
netbox/circuits/graphql/filters.py

@@ -4,20 +4,15 @@ from typing import Annotated, TYPE_CHECKING
 import strawberry
 import strawberry_django
 from strawberry.scalars import ID
-from strawberry_django import FilterLookup, DateFilterLookup
+from strawberry_django import BaseFilterLookup, FilterLookup, DateFilterLookup
 
 from circuits import models
-from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
+from circuits.graphql.filter_mixins import CircuitTypeFilterMixin
 from dcim.graphql.filter_mixins import CabledObjectModelFilterMixin
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
-from netbox.graphql.filter_mixins import (
-    DistanceFilterMixin,
-    ImageAttachmentFilterMixin,
-    OrganizationalModelFilterMixin,
-    PrimaryModelFilterMixin,
-)
+from netbox.graphql.filter_mixins import DistanceFilterMixin, ImageAttachmentFilterMixin
+from netbox.graphql.filters import ChangeLoggedModelFilter, OrganizationalModelFilter, PrimaryModelFilter
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
-from .filter_mixins import BaseCircuitTypeFilterMixin
 
 if TYPE_CHECKING:
     from core.graphql.filters import ContentTypeFilter
@@ -43,16 +38,17 @@ __all__ = (
 
 @strawberry_django.filter_type(models.CircuitTermination, lookups=True)
 class CircuitTerminationFilter(
-    BaseObjectTypeFilterMixin,
     CustomFieldsFilterMixin,
     TagsFilterMixin,
-    ChangeLogFilterMixin,
+    ChangeLoggedModelFilter,
     CabledObjectModelFilterMixin,
 ):
     circuit: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
-    term_side: Annotated['CircuitTerminationSideEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
+    term_side: (
+        BaseFilterLookup[Annotated['CircuitTerminationSideEnum', strawberry.lazy('circuits.graphql.enums')]] | None
+    ) = (
         strawberry_django.filter_field()
     )
     termination_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
@@ -93,7 +89,7 @@ class CircuitFilter(
     ImageAttachmentFilterMixin,
     DistanceFilterMixin,
     TenancyFilterMixin,
-    PrimaryModelFilterMixin
+    PrimaryModelFilter
 ):
     cid: FilterLookup[str] | None = strawberry_django.filter_field()
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -108,7 +104,7 @@ class CircuitFilter(
         strawberry_django.filter_field()
     )
     type_id: ID | None = strawberry_django.filter_field()
-    status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
+    status: BaseFilterLookup[Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
     install_date: DateFilterLookup[date] | None = strawberry_django.filter_field()
@@ -122,19 +118,17 @@ class CircuitFilter(
 
 
 @strawberry_django.filter_type(models.CircuitType, lookups=True)
-class CircuitTypeFilter(BaseCircuitTypeFilterMixin):
+class CircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter):
     pass
 
 
 @strawberry_django.filter_type(models.CircuitGroup, lookups=True)
-class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
+class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilter):
     pass
 
 
 @strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True)
-class CircuitGroupAssignmentFilter(
-    BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
-):
+class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     member_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -143,13 +137,13 @@ class CircuitGroupAssignmentFilter(
         strawberry_django.filter_field()
     )
     group_id: ID | None = strawberry_django.filter_field()
-    priority: Annotated['CircuitPriorityEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
+    priority: BaseFilterLookup[Annotated['CircuitPriorityEnum', strawberry.lazy('circuits.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
 
 
 @strawberry_django.filter_type(models.Provider, lookups=True)
-class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
+class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     slug: FilterLookup[str] | None = strawberry_django.filter_field()
     asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@@ -159,7 +153,7 @@ class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
 
 
 @strawberry_django.filter_type(models.ProviderAccount, lookups=True)
-class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
+class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -169,7 +163,7 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
 
 
 @strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
-class ProviderNetworkFilter(PrimaryModelFilterMixin):
+class ProviderNetworkFilter(PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -179,12 +173,12 @@ class ProviderNetworkFilter(PrimaryModelFilterMixin):
 
 
 @strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
-class VirtualCircuitTypeFilter(BaseCircuitTypeFilterMixin):
+class VirtualCircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter):
     pass
 
 
 @strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
-class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
     cid: FilterLookup[str] | None = strawberry_django.filter_field()
     provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -198,7 +192,7 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
         strawberry_django.filter_field()
     )
     type_id: ID | None = strawberry_django.filter_field()
-    status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
+    status: BaseFilterLookup[Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
     group_assignments: Annotated['CircuitGroupAssignmentFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -207,14 +201,16 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
 
 
 @strawberry_django.filter_type(models.VirtualCircuitTermination, lookups=True)
-class VirtualCircuitTerminationFilter(
-    BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
-):
+class VirtualCircuitTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     virtual_circuit: Annotated['VirtualCircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
     virtual_circuit_id: ID | None = strawberry_django.filter_field()
-    role: Annotated['VirtualCircuitTerminationRoleEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
+    role: (
+        BaseFilterLookup[
+            Annotated['VirtualCircuitTerminationRoleEnum', strawberry.lazy('circuits.graphql.enums')]
+        ] | None
+    ) = (
         strawberry_django.filter_field()
     )
     interface: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (

+ 6 - 11
netbox/circuits/graphql/types.py

@@ -6,7 +6,7 @@ import strawberry_django
 from circuits import models
 from dcim.graphql.mixins import CabledObjectMixin
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
-from netbox.graphql.types import BaseObjectType, NetBoxObjectType, ObjectType, OrganizationalObjectType
+from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType
 from tenancy.graphql.types import TenantType
 from .filters import *
 
@@ -35,8 +35,7 @@ __all__ = (
     filters=ProviderFilter,
     pagination=True
 )
-class ProviderType(NetBoxObjectType, ContactsMixin):
-
+class ProviderType(ContactsMixin, PrimaryObjectType):
     networks: List[Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')]]
     circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
     asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]]
@@ -49,9 +48,8 @@ class ProviderType(NetBoxObjectType, ContactsMixin):
     filters=ProviderAccountFilter,
     pagination=True
 )
-class ProviderAccountType(ContactsMixin, NetBoxObjectType):
+class ProviderAccountType(ContactsMixin, PrimaryObjectType):
     provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
-
     circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
 
 
@@ -61,9 +59,8 @@ class ProviderAccountType(ContactsMixin, NetBoxObjectType):
     filters=ProviderNetworkFilter,
     pagination=True
 )
-class ProviderNetworkType(NetBoxObjectType):
+class ProviderNetworkType(PrimaryObjectType):
     provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
-
     circuit_terminations: List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]
 
 
@@ -105,14 +102,13 @@ class CircuitTypeType(OrganizationalObjectType):
     filters=CircuitFilter,
     pagination=True
 )
-class CircuitType(NetBoxObjectType, ContactsMixin):
+class CircuitType(PrimaryObjectType, ContactsMixin):
     provider: ProviderType
     provider_account: ProviderAccountType | None
     termination_a: CircuitTerminationType | None
     termination_z: CircuitTerminationType | None
     type: CircuitTypeType
     tenant: TenantType | None
-
     terminations: List[CircuitTerminationType]
 
 
@@ -178,12 +174,11 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
     filters=VirtualCircuitFilter,
     pagination=True
 )
-class VirtualCircuitType(NetBoxObjectType):
+class VirtualCircuitType(PrimaryObjectType):
     provider_network: ProviderNetworkType = strawberry_django.field(select_related=["provider_network"])
     provider_account: ProviderAccountType | None
     type: Annotated["VirtualCircuitTypeType", strawberry.lazy('circuits.graphql.types')] = strawberry_django.field(
         select_related=["type"]
     )
     tenant: TenantType | None
-
     terminations: List[VirtualCircuitTerminationType]

+ 68 - 0
netbox/circuits/migrations/0053_owner.py

@@ -0,0 +1,68 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('circuits', '0052_extend_circuit_abs_distance_upper_limit'),
+        ('users', '0015_owner'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuit',
+            name='owner',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+            ),
+        ),
+        migrations.AddField(
+            model_name='circuitgroup',
+            name='owner',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+            ),
+        ),
+        migrations.AddField(
+            model_name='circuittype',
+            name='owner',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+            ),
+        ),
+        migrations.AddField(
+            model_name='provider',
+            name='owner',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+            ),
+        ),
+        migrations.AddField(
+            model_name='provideraccount',
+            name='owner',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+            ),
+        ),
+        migrations.AddField(
+            model_name='providernetwork',
+            name='owner',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+            ),
+        ),
+        migrations.AddField(
+            model_name='virtualcircuit',
+            name='owner',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+            ),
+        ),
+        migrations.AddField(
+            model_name='virtualcircuittype',
+            name='owner',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+            ),
+        ),
+    ]

+ 39 - 0
netbox/circuits/migrations/0054_cable_connector_positions.py

@@ -0,0 +1,39 @@
+import django.contrib.postgres.fields
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('circuits', '0053_owner'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuittermination',
+            name='cable_connector',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(256)
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='circuittermination',
+            name='cable_positions',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.PositiveSmallIntegerField(
+                    validators=[
+                        django.core.validators.MinValueValidator(1),
+                        django.core.validators.MaxValueValidator(1024),
+                    ]
+                ),
+                blank=True,
+                null=True,
+                size=None,
+            ),
+        ),
+    ]

+ 28 - 0
netbox/circuits/migrations/0055_add_comments_to_organizationalmodel.py

@@ -0,0 +1,28 @@
+# Generated by Django 5.2.8 on 2025-12-08 17:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0054_cable_connector_positions'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuitgroup',
+            name='comments',
+            field=models.TextField(blank=True),
+        ),
+        migrations.AddField(
+            model_name='circuittype',
+            name='comments',
+            field=models.TextField(blank=True),
+        ),
+        migrations.AddField(
+            model_name='virtualcircuittype',
+            name='comments',
+            field=models.TextField(blank=True),
+        ),
+    ]

+ 17 - 0
netbox/circuits/migrations/0056_gfk_indexes.py

@@ -0,0 +1,17 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('circuits', '0055_add_comments_to_organizationalmodel'),
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('dcim', '0224_add_comments_to_organizationalmodel'),
+        ('extras', '0134_owner'),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name='circuittermination',
+            index=models.Index(fields=['termination_type', 'termination_id'], name='circuits_ci_termina_505dda_idx'),
+        ),
+    ]

+ 3 - 0
netbox/circuits/models/circuits.py

@@ -335,6 +335,9 @@ class CircuitTermination(
                 name='%(app_label)s_%(class)s_unique_circuit_term_side'
             ),
         )
+        indexes = (
+            models.Index(fields=('termination_type', 'termination_id')),
+        )
         verbose_name = _('circuit termination')
         verbose_name_plural = _('circuit terminations')
 

+ 3 - 0
netbox/circuits/search.py

@@ -20,6 +20,7 @@ class CircuitGroupIndex(SearchIndex):
         ('name', 100),
         ('slug', 110),
         ('description', 500),
+        ('comments', 5000),
     )
     display_attrs = ('description',)
 
@@ -44,6 +45,7 @@ class CircuitTypeIndex(SearchIndex):
         ('name', 100),
         ('slug', 110),
         ('description', 500),
+        ('comments', 5000),
     )
     display_attrs = ('description',)
 
@@ -109,5 +111,6 @@ class VirtualCircuitTypeIndex(SearchIndex):
         ('name', 100),
         ('slug', 110),
         ('description', 500),
+        ('comments', 5000),
     )
     display_attrs = ('description',)

+ 11 - 16
netbox/circuits/tables/circuits.py

@@ -1,11 +1,9 @@
-from django.utils.translation import gettext_lazy as _
 import django_tables2 as tables
+from django.utils.translation import gettext_lazy as _
 
 from circuits.models import *
+from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
 from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
-
-from netbox.tables import NetBoxTable, columns
-
 from .columns import CommitRateColumn
 
 __all__ = (
@@ -24,7 +22,7 @@ CIRCUITTERMINATION_LINK = """
 """
 
 
-class CircuitTypeTable(NetBoxTable):
+class CircuitTypeTable(OrganizationalModelTable):
     name = tables.Column(
         linkify=True,
         verbose_name=_('Name'),
@@ -39,16 +37,16 @@ class CircuitTypeTable(NetBoxTable):
         verbose_name=_('Circuits')
     )
 
-    class Meta(NetBoxTable.Meta):
+    class Meta(OrganizationalModelTable.Meta):
         model = CircuitType
         fields = (
-            'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'tags', 'created', 'last_updated',
-            'actions',
+            'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'comments', 'tags', 'created',
+            'last_updated', 'actions',
         )
         default_columns = ('pk', 'name', 'circuit_count', 'color', 'description')
 
 
-class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
+class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
     cid = tables.Column(
         linkify=True,
         verbose_name=_('Circuit ID')
@@ -79,9 +77,6 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         verbose_name=_('Commit Rate')
     )
     distance = columns.DistanceColumn()
-    comments = columns.MarkdownColumn(
-        verbose_name=_('Comments')
-    )
     tags = columns.TagColumn(
         url_name='circuits:circuit_list'
     )
@@ -90,7 +85,7 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         linkify_item=True
     )
 
-    class Meta(NetBoxTable.Meta):
+    class Meta(PrimaryModelTable.Meta):
         model = Circuit
         fields = (
             'pk', 'id', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'tenant_group',
@@ -163,7 +158,7 @@ class CircuitTerminationTable(NetBoxTable):
         )
 
 
-class CircuitGroupTable(NetBoxTable):
+class CircuitGroupTable(OrganizationalModelTable):
     name = tables.Column(
         verbose_name=_('Name'),
         linkify=True
@@ -177,10 +172,10 @@ class CircuitGroupTable(NetBoxTable):
         url_name='circuits:circuitgroup_list'
     )
 
-    class Meta(NetBoxTable.Meta):
+    class Meta(OrganizationalModelTable.Meta):
         model = CircuitGroup
         fields = (
-            'pk', 'name', 'description', 'circuit_group_assignment_count', 'tags',
+            'pk', 'name', 'description', 'circuit_group_assignment_count', 'comments', 'tags',
             'created', 'last_updated', 'actions',
         )
         default_columns = ('pk', 'name', 'description', 'circuit_group_assignment_count')

+ 9 - 18
netbox/circuits/tables/providers.py

@@ -1,10 +1,10 @@
 import django_tables2 as tables
 from django.utils.translation import gettext_lazy as _
-from circuits.models import *
 from django_tables2.utils import Accessor
-from tenancy.tables import ContactsColumnMixin
 
-from netbox.tables import NetBoxTable, columns
+from circuits.models import *
+from netbox.tables import PrimaryModelTable, columns
+from tenancy.tables import ContactsColumnMixin
 
 __all__ = (
     'ProviderTable',
@@ -13,7 +13,7 @@ __all__ = (
 )
 
 
-class ProviderTable(ContactsColumnMixin, NetBoxTable):
+class ProviderTable(ContactsColumnMixin, PrimaryModelTable):
     name = tables.Column(
         verbose_name=_('Name'),
         linkify=True
@@ -42,14 +42,11 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
         url_params={'provider_id': 'pk'},
         verbose_name=_('Circuits')
     )
-    comments = columns.MarkdownColumn(
-        verbose_name=_('Comments'),
-    )
     tags = columns.TagColumn(
         url_name='circuits:provider_list'
     )
 
-    class Meta(NetBoxTable.Meta):
+    class Meta(PrimaryModelTable.Meta):
         model = Provider
         fields = (
             'pk', 'id', 'name', 'accounts', 'account_count', 'asns', 'asn_count', 'circuit_count', 'description',
@@ -58,7 +55,7 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
         default_columns = ('pk', 'name', 'account_count', 'circuit_count')
 
 
-class ProviderAccountTable(ContactsColumnMixin, NetBoxTable):
+class ProviderAccountTable(ContactsColumnMixin, PrimaryModelTable):
     account = tables.Column(
         linkify=True,
         verbose_name=_('Account'),
@@ -76,14 +73,11 @@ class ProviderAccountTable(ContactsColumnMixin, NetBoxTable):
         url_params={'provider_account_id': 'pk'},
         verbose_name=_('Circuits')
     )
-    comments = columns.MarkdownColumn(
-        verbose_name=_('Comments'),
-    )
     tags = columns.TagColumn(
         url_name='circuits:provideraccount_list'
     )
 
-    class Meta(NetBoxTable.Meta):
+    class Meta(PrimaryModelTable.Meta):
         model = ProviderAccount
         fields = (
             'pk', 'id', 'account', 'name', 'provider', 'circuit_count', 'comments', 'contacts', 'tags', 'created',
@@ -92,7 +86,7 @@ class ProviderAccountTable(ContactsColumnMixin, NetBoxTable):
         default_columns = ('pk', 'account', 'name', 'provider', 'circuit_count')
 
 
-class ProviderNetworkTable(NetBoxTable):
+class ProviderNetworkTable(PrimaryModelTable):
     name = tables.Column(
         verbose_name=_('Name'),
         linkify=True
@@ -101,14 +95,11 @@ class ProviderNetworkTable(NetBoxTable):
         verbose_name=_('Provider'),
         linkify=True
     )
-    comments = columns.MarkdownColumn(
-        verbose_name=_('Comments'),
-    )
     tags = columns.TagColumn(
         url_name='circuits:providernetwork_list'
     )
 
-    class Meta(NetBoxTable.Meta):
+    class Meta(PrimaryModelTable.Meta):
         model = ProviderNetwork
         fields = (
             'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags',

+ 5 - 8
netbox/circuits/tables/virtual_circuits.py

@@ -2,7 +2,7 @@ import django_tables2 as tables
 from django.utils.translation import gettext_lazy as _
 
 from circuits.models import *
-from netbox.tables import NetBoxTable, columns
+from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
 from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
 
 __all__ = (
@@ -12,7 +12,7 @@ __all__ = (
 )
 
 
-class VirtualCircuitTypeTable(NetBoxTable):
+class VirtualCircuitTypeTable(OrganizationalModelTable):
     name = tables.Column(
         linkify=True,
         verbose_name=_('Name'),
@@ -27,7 +27,7 @@ class VirtualCircuitTypeTable(NetBoxTable):
         verbose_name=_('Circuits')
     )
 
-    class Meta(NetBoxTable.Meta):
+    class Meta(OrganizationalModelTable.Meta):
         model = VirtualCircuitType
         fields = (
             'pk', 'id', 'name', 'virtual_circuit_count', 'color', 'description', 'slug', 'tags', 'created',
@@ -36,7 +36,7 @@ class VirtualCircuitTypeTable(NetBoxTable):
         default_columns = ('pk', 'name', 'virtual_circuit_count', 'color', 'description')
 
 
-class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
+class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
     cid = tables.Column(
         linkify=True,
         verbose_name=_('Circuit ID')
@@ -63,14 +63,11 @@ class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
         url_params={'virtual_circuit_id': 'pk'},
         verbose_name=_('Terminations')
     )
-    comments = columns.MarkdownColumn(
-        verbose_name=_('Comments')
-    )
     tags = columns.TagColumn(
         url_name='circuits:virtualcircuit_list'
     )
 
-    class Meta(NetBoxTable.Meta):
+    class Meta(PrimaryModelTable.Meta):
         model = VirtualCircuit
         fields = (
             'pk', 'id', 'cid', 'provider', 'provider_account', 'provider_network', 'type', 'status', 'tenant',

+ 1 - 1
netbox/circuits/tests/test_filtersets.py

@@ -433,7 +433,7 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
 class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CircuitTermination.objects.all()
     filterset = CircuitTerminationFilterSet
-    ignore_fields = ('cable',)
+    ignore_fields = ('cable', 'cable_positions')
 
     @classmethod
     def setUpTestData(cls):

+ 0 - 5
netbox/circuits/urls.py

@@ -18,11 +18,6 @@ urlpatterns = [
     path('circuit-types/<int:pk>/', include(get_model_urls('circuits', 'circuittype'))),
 
     path('circuits/', include(get_model_urls('circuits', 'circuit', detail=False))),
-    path(
-        'circuits/<int:pk>/terminations/swap/',
-        views.CircuitSwapTerminations.as_view(),
-        name='circuit_terminations_swap'
-    ),
     path('circuits/<int:pk>/', include(get_model_urls('circuits', 'circuit'))),
 
     path('circuit-terminations/', include(get_model_urls('circuits', 'circuittermination', detail=False))),

+ 0 - 81
netbox/circuits/views.py

@@ -1,13 +1,8 @@
-from django.contrib import messages
-from django.db import router, transaction
-from django.shortcuts import get_object_or_404, redirect, render
-from django.utils.translation import gettext_lazy as _
 
 from dcim.views import PathTraceView
 from ipam.models import ASN
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
 from netbox.views import generic
-from utilities.forms import ConfirmationForm
 from utilities.query import count_related
 from utilities.views import GetRelatedModelsMixin, register_model_view
 from . import filtersets, forms, tables
@@ -378,82 +373,6 @@ class CircuitBulkDeleteView(generic.BulkDeleteView):
     table = tables.CircuitTable
 
 
-class CircuitSwapTerminations(generic.ObjectEditView):
-    """
-    Swap the A and Z terminations of a circuit.
-    """
-    queryset = Circuit.objects.all()
-
-    def get(self, request, pk):
-        circuit = get_object_or_404(self.queryset, pk=pk)
-        form = ConfirmationForm()
-
-        # Circuit must have at least one termination to swap
-        if not circuit.termination_a and not circuit.termination_z:
-            messages.error(request, _(
-                "No terminations have been defined for circuit {circuit}."
-            ).format(circuit=circuit))
-            return redirect('circuits:circuit', pk=circuit.pk)
-
-        return render(request, 'circuits/circuit_terminations_swap.html', {
-            'circuit': circuit,
-            'termination_a': circuit.termination_a,
-            'termination_z': circuit.termination_z,
-            'form': form,
-            'panel_class': 'light',
-            'button_class': 'primary',
-            'return_url': circuit.get_absolute_url(),
-        })
-
-    def post(self, request, pk):
-        circuit = get_object_or_404(self.queryset, pk=pk)
-        form = ConfirmationForm(request.POST)
-
-        if form.is_valid():
-
-            termination_a = CircuitTermination.objects.filter(pk=circuit.termination_a_id).first()
-            termination_z = CircuitTermination.objects.filter(pk=circuit.termination_z_id).first()
-
-            if termination_a and termination_z:
-                # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
-                with transaction.atomic(using=router.db_for_write(CircuitTermination)):
-                    termination_a.term_side = '_'
-                    termination_a.save()
-                    termination_z.term_side = 'A'
-                    termination_z.save()
-                    termination_a.term_side = 'Z'
-                    termination_a.save()
-                    circuit.refresh_from_db()
-                    circuit.termination_a = termination_z
-                    circuit.termination_z = termination_a
-                    circuit.save()
-            elif termination_a:
-                termination_a.term_side = 'Z'
-                termination_a.save()
-                circuit.refresh_from_db()
-                circuit.termination_a = None
-                circuit.save()
-            else:
-                termination_z.term_side = 'A'
-                termination_z.save()
-                circuit.refresh_from_db()
-                circuit.termination_z = None
-                circuit.save()
-
-            messages.success(request, _("Swapped terminations for circuit {circuit}.").format(circuit=circuit))
-            return redirect('circuits:circuit', pk=circuit.pk)
-
-        return render(request, 'circuits/circuit_terminations_swap.html', {
-            'circuit': circuit,
-            'termination_a': circuit.termination_a,
-            'termination_z': circuit.termination_z,
-            'form': form,
-            'panel_class': 'default',
-            'button_class': 'primary',
-            'return_url': circuit.get_absolute_url(),
-        })
-
-
 #
 # Circuit terminations
 #

+ 7 - 22
netbox/core/api/serializers_/change_logging.py

@@ -1,13 +1,11 @@
-from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from core.choices import *
 from core.models import ObjectChange
-from netbox.api.exceptions import SerializerNotFound
 from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.serializers import BaseModelSerializer
 from users.api.serializers_.users import UserSerializer
-from utilities.api import get_serializer_for_model
 
 __all__ = (
     'ObjectChangeSerializer',
@@ -26,7 +24,10 @@ class ObjectChangeSerializer(BaseModelSerializer):
     changed_object_type = ContentTypeField(
         read_only=True
     )
-    changed_object = serializers.SerializerMethodField(
+    changed_object = GFKSerializerField(
+        read_only=True
+    )
+    object_repr = serializers.CharField(
         read_only=True
     )
     prechange_data = serializers.JSONField(
@@ -44,22 +45,6 @@ class ObjectChangeSerializer(BaseModelSerializer):
         model = ObjectChange
         fields = [
             'id', 'url', 'display_url', 'display', 'time', 'user', 'user_name', 'request_id', 'action',
-            'changed_object_type', 'changed_object_id', 'changed_object', 'message', 'prechange_data',
-            'postchange_data',
+            'changed_object_type', 'changed_object_id', 'changed_object', 'object_repr', 'message',
+            'prechange_data', 'postchange_data',
         ]
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_changed_object(self, obj):
-        """
-        Serialize a nested representation of the changed object.
-        """
-        if obj.changed_object is None:
-            return None
-
-        try:
-            serializer = get_serializer_for_model(obj.changed_object)
-        except SerializerNotFound:
-            return obj.object_repr
-        data = serializer(obj.changed_object, nested=True, context={'request': self.context['request']}).data
-
-        return data

+ 4 - 4
netbox/core/api/serializers_/data.py

@@ -1,7 +1,7 @@
 from core.choices import *
 from core.models import DataFile, DataSource
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
-from netbox.api.serializers import NetBoxModelSerializer
+from netbox.api.serializers import NetBoxModelSerializer, PrimaryModelSerializer
 from netbox.utils import get_data_backend_choices
 
 __all__ = (
@@ -10,7 +10,7 @@ __all__ = (
 )
 
 
-class DataSourceSerializer(NetBoxModelSerializer):
+class DataSourceSerializer(PrimaryModelSerializer):
     type = ChoiceField(
         choices=get_data_backend_choices()
     )
@@ -26,8 +26,8 @@ class DataSourceSerializer(NetBoxModelSerializer):
         model = DataSource
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description',
-            'sync_interval', 'parameters', 'ignore_rules', 'comments', 'custom_fields', 'created', 'last_updated',
-            'last_synced', 'file_count',
+            'sync_interval', 'parameters', 'ignore_rules', 'owner', 'comments', 'custom_fields', 'created',
+            'last_updated', 'last_synced', 'file_count',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 

+ 2 - 3
netbox/core/api/views.py

@@ -9,7 +9,6 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
 from rest_framework import viewsets
 from rest_framework.decorators import action
 from rest_framework.exceptions import PermissionDenied
-from rest_framework.permissions import IsAdminUser
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 from rest_framework.viewsets import ReadOnlyModelViewSet
@@ -24,7 +23,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import LimitOffsetListPagination
 from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
-
+from utilities.api import IsSuperuser
 from . import serializers
 
 
@@ -100,7 +99,7 @@ class BaseRQViewSet(viewsets.ViewSet):
     """
     Base class for RQ view sets. Provides a list() method. Subclasses must implement get_data().
     """
-    permission_classes = [IsAdminUser]
+    permission_classes = [IsSuperuser]
     serializer_class = None
 
     def get_data(self):

+ 9 - 2
netbox/core/filtersets.py

@@ -3,10 +3,11 @@ from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.utils.translation import gettext as _
 
-from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
+from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, PrimaryModelFilterSet
 from netbox.utils import get_data_backend_choices
 from users.models import User
 from utilities.filters import ContentTypeFilter
+from utilities.filtersets import register_filterset
 from .choices import *
 from .models import *
 
@@ -20,7 +21,8 @@ __all__ = (
 )
 
 
-class DataSourceFilterSet(NetBoxModelFilterSet):
+@register_filterset
+class DataSourceFilterSet(PrimaryModelFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=get_data_backend_choices,
         null_value=None
@@ -48,6 +50,7 @@ class DataSourceFilterSet(NetBoxModelFilterSet):
         )
 
 
+@register_filterset
 class DataFileFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
         method='search'
@@ -75,6 +78,7 @@ class DataFileFilterSet(ChangeLoggedModelFilterSet):
         )
 
 
+@register_filterset
 class JobFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
         method='search',
@@ -139,6 +143,7 @@ class JobFilterSet(BaseFilterSet):
         )
 
 
+@register_filterset
 class ObjectTypeFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
         method='search',
@@ -164,6 +169,7 @@ class ObjectTypeFilterSet(BaseFilterSet):
         return queryset.filter(features__icontains=value)
 
 
+@register_filterset
 class ObjectChangeFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
         method='search',
@@ -203,6 +209,7 @@ class ObjectChangeFilterSet(BaseFilterSet):
         )
 
 
+@register_filterset
 class ConfigRevisionFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
         method='search',

+ 2 - 9
netbox/core/forms/bulk_edit.py

@@ -3,9 +3,8 @@ from django.utils.translation import gettext_lazy as _
 
 from core.choices import JobIntervalChoices
 from core.models import *
-from netbox.forms import NetBoxModelBulkEditForm
+from netbox.forms import PrimaryModelBulkEditForm
 from netbox.utils import get_data_backend_choices
-from utilities.forms.fields import CommentField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 
@@ -14,7 +13,7 @@ __all__ = (
 )
 
 
-class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
+class DataSourceBulkEditForm(PrimaryModelBulkEditForm):
     type = forms.ChoiceField(
         label=_('Type'),
         choices=get_data_backend_choices,
@@ -25,17 +24,11 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
         widget=BulkEditNullBooleanSelect(),
         label=_('Enabled')
     )
-    description = forms.CharField(
-        label=_('Description'),
-        max_length=200,
-        required=False
-    )
     sync_interval = forms.ChoiceField(
         choices=JobIntervalChoices,
         required=False,
         label=_('Sync interval')
     )
-    comments = CommentField()
     parameters = forms.JSONField(
         label=_('Parameters'),
         required=False

+ 3 - 3
netbox/core/forms/bulk_import.py

@@ -1,16 +1,16 @@
 from core.models import *
-from netbox.forms import NetBoxModelImportForm
+from netbox.forms import PrimaryModelImportForm
 
 __all__ = (
     'DataSourceImportForm',
 )
 
 
-class DataSourceImportForm(NetBoxModelImportForm):
+class DataSourceImportForm(PrimaryModelImportForm):
 
     class Meta:
         model = DataSource
         fields = (
             'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules',
-            'comments',
+            'owner', 'comments',
         )

+ 5 - 4
netbox/core/forms/filtersets.py

@@ -3,13 +3,13 @@ from django.utils.translation import gettext_lazy as _
 
 from core.choices import *
 from core.models import *
-from netbox.forms import NetBoxModelFilterSetForm
+from netbox.forms import NetBoxModelFilterSetForm, PrimaryModelFilterSetForm
 from netbox.forms.mixins import SavedFiltersMixin
 from netbox.utils import get_data_backend_choices
 from users.models import User
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 from utilities.forms.fields import (
-    ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField,
+    ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
 )
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import DateTimePicker
@@ -23,10 +23,10 @@ __all__ = (
 )
 
 
-class DataSourceFilterForm(NetBoxModelFilterSetForm):
+class DataSourceFilterForm(PrimaryModelFilterSetForm):
     model = DataSource
     fieldsets = (
-        FieldSet('q', 'filter_id'),
+        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
         FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')),
     )
     type = forms.MultipleChoiceField(
@@ -51,6 +51,7 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm):
         choices=JobIntervalChoices,
         required=False
     )
+    tag = TagFilterField(model)
 
 
 class DataFileFilterForm(NetBoxModelFilterSetForm):

+ 5 - 5
netbox/core/forms/model_forms.py

@@ -9,11 +9,11 @@ from django.utils.translation import gettext_lazy as _
 from core.forms.mixins import SyncedDataMixin
 from core.models import *
 from netbox.config import get_config, PARAMS
-from netbox.forms import NetBoxModelForm
+from netbox.forms import NetBoxModelForm, PrimaryModelForm
 from netbox.registry import registry
 from netbox.utils import get_data_backend_choices
 from utilities.forms import get_field_value
-from utilities.forms.fields import CommentField, JSONField
+from utilities.forms.fields import JSONField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import HTMXSelect
 
@@ -26,17 +26,17 @@ __all__ = (
 EMPTY_VALUES = ('', None, [], ())
 
 
-class DataSourceForm(NetBoxModelForm):
+class DataSourceForm(PrimaryModelForm):
     type = forms.ChoiceField(
         choices=get_data_backend_choices,
         widget=HTMXSelect()
     )
-    comments = CommentField()
 
     class Meta:
         model = DataSource
         fields = [
-            'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'ignore_rules', 'comments', 'tags',
+            'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'ignore_rules', 'owner',
+            'comments', 'tags',
         ]
         widgets = {
             'ignore_rules': forms.Textarea(

+ 11 - 0
netbox/core/graphql/enums.py

@@ -0,0 +1,11 @@
+import strawberry
+
+from core.choices import *
+
+__all__ = (
+    'DataSourceStatusEnum',
+    'ObjectChangeActionEnum',
+)
+
+DataSourceStatusEnum = strawberry.enum(DataSourceStatusChoices.as_enum(prefix='status'))
+ObjectChangeActionEnum = strawberry.enum(ObjectChangeActionChoices.as_enum(prefix='action'))

+ 3 - 15
netbox/core/graphql/filter_mixins.py

@@ -4,31 +4,19 @@ from typing import Annotated, TYPE_CHECKING
 
 import strawberry
 import strawberry_django
-from strawberry import ID
 from strawberry_django import DatetimeFilterLookup
 
 if TYPE_CHECKING:
     from .filters import *
 
 __all__ = (
-    'BaseFilterMixin',
-    'BaseObjectTypeFilterMixin',
-    'ChangeLogFilterMixin',
+    'ChangeLoggingMixin',
 )
 
 
-# @strawberry.input
-class BaseFilterMixin: ...
-
-
-@dataclass
-class BaseObjectTypeFilterMixin(BaseFilterMixin):
-    id: ID | None = strawberry.UNSET
-
-
 @dataclass
-class ChangeLogFilterMixin(BaseFilterMixin):
-    id: ID | None = strawberry.UNSET
+class ChangeLoggingMixin:
+    # TODO: "changelog" is not a valid field name; needs to be updated for ObjectChange
     changelog: Annotated['ObjectChangeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )

+ 13 - 12
netbox/core/graphql/filters.py

@@ -5,11 +5,11 @@ import strawberry
 import strawberry_django
 from django.contrib.contenttypes.models import ContentType as DjangoContentType
 from strawberry.scalars import ID
-from strawberry_django import DatetimeFilterLookup, FilterLookup
+from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup
 
 from core import models
-from core.graphql.filter_mixins import BaseFilterMixin
-from netbox.graphql.filter_mixins import PrimaryModelFilterMixin
+from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter
+from .enums import *
 
 if TYPE_CHECKING:
     from netbox.graphql.filter_lookups import IntegerLookup, JSONFilter
@@ -24,8 +24,7 @@ __all__ = (
 
 
 @strawberry_django.filter_type(models.DataFile, lookups=True)
-class DataFileFilter(BaseFilterMixin):
-    id: ID | None = strawberry_django.filter_field()
+class DataFileFilter(BaseModelFilter):
     created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
     last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
     source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = (
@@ -40,11 +39,13 @@ class DataFileFilter(BaseFilterMixin):
 
 
 @strawberry_django.filter_type(models.DataSource, lookups=True)
-class DataSourceFilter(PrimaryModelFilterMixin):
+class DataSourceFilter(PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     type: FilterLookup[str] | None = strawberry_django.filter_field()
     source_url: FilterLookup[str] | None = strawberry_django.filter_field()
-    status: FilterLookup[str] | None = strawberry_django.filter_field()
+    status: (
+        BaseFilterLookup[Annotated['DataSourceStatusEnum', strawberry.lazy('core.graphql.enums')]] | None
+    ) = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     ignore_rules: FilterLookup[str] | None = strawberry_django.filter_field()
     parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -57,13 +58,14 @@ class DataSourceFilter(PrimaryModelFilterMixin):
 
 
 @strawberry_django.filter_type(models.ObjectChange, lookups=True)
-class ObjectChangeFilter(BaseFilterMixin):
-    id: ID | None = strawberry_django.filter_field()
+class ObjectChangeFilter(BaseModelFilter):
     time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user_name: FilterLookup[str] | None = strawberry_django.filter_field()
     request_id: FilterLookup[str] | None = strawberry_django.filter_field()
-    action: FilterLookup[str] | None = strawberry_django.filter_field()
+    action: (
+        BaseFilterLookup[Annotated['ObjectChangeActionEnum', strawberry.lazy('core.graphql.enums')]] | None
+    ) = strawberry_django.filter_field()
     changed_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -83,7 +85,6 @@ class ObjectChangeFilter(BaseFilterMixin):
 
 
 @strawberry_django.filter_type(DjangoContentType, lookups=True)
-class ContentTypeFilter(BaseFilterMixin):
-    id: ID | None = strawberry_django.filter_field()
+class ContentTypeFilter(BaseModelFilter):
     app_label: FilterLookup[str] | None = strawberry_django.filter_field()
     model: FilterLookup[str] | None = strawberry_django.filter_field()

+ 2 - 3
netbox/core/graphql/types.py

@@ -5,7 +5,7 @@ import strawberry_django
 from django.contrib.contenttypes.models import ContentType as DjangoContentType
 
 from core import models
-from netbox.graphql.types import BaseObjectType, NetBoxObjectType
+from netbox.graphql.types import BaseObjectType, PrimaryObjectType
 from .filters import *
 
 __all__ = (
@@ -32,8 +32,7 @@ class DataFileType(BaseObjectType):
     filters=DataSourceFilter,
     pagination=True
 )
-class DataSourceType(NetBoxObjectType):
-
+class DataSourceType(PrimaryObjectType):
     datafiles: List[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]]
 
 

+ 19 - 0
netbox/core/migrations/0020_owner.py

@@ -0,0 +1,19 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('core', '0019_configrevision_active'),
+        ('users', '0015_owner'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='datasource',
+            name='owner',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+            ),
+        ),
+    ]

+ 0 - 3
netbox/core/models/contenttypes.py

@@ -1,3 +0,0 @@
-# TODO: Remove this module in NetBox v4.5
-# Provided for backward compatibility
-from .object_types import *

+ 3 - 3
netbox/core/tables/data.py

@@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _
 import django_tables2 as tables
 
 from core.models import *
-from netbox.tables import NetBoxTable, columns
+from netbox.tables import NetBoxTable, PrimaryModelTable, columns
 from .columns import BackendTypeColumn
 from .template_code import DATA_SOURCE_SYNC_BUTTON
 
@@ -12,7 +12,7 @@ __all__ = (
 )
 
 
-class DataSourceTable(NetBoxTable):
+class DataSourceTable(PrimaryModelTable):
     name = tables.Column(
         verbose_name=_('Name'),
         linkify=True,
@@ -42,7 +42,7 @@ class DataSourceTable(NetBoxTable):
         extra_buttons=DATA_SOURCE_SYNC_BUTTON,
     )
 
-    class Meta(NetBoxTable.Meta):
+    class Meta(PrimaryModelTable.Meta):
         model = DataSource
         fields = (
             'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'sync_interval', 'comments',

+ 90 - 26
netbox/core/tests/test_api.py

@@ -8,6 +8,7 @@ from rq.job import Job as RQ_Job, JobStatus
 from rq.registry import FailedJobRegistry, StartedJobRegistry
 
 from rest_framework import status
+from users.constants import TOKEN_PREFIX
 from users.models import Token, User
 from utilities.testing import APITestCase, APIViewTestCases, TestCase
 from utilities.testing.utils import disable_logging
@@ -107,14 +108,14 @@ class ObjectTypeTest(APITestCase):
     def test_list_objects(self):
         object_type_count = ObjectType.objects.count()
 
-        response = self.client.get(reverse('extras-api:objecttype-list'), **self.header)
+        response = self.client.get(reverse('core-api:objecttype-list'), **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data['count'], object_type_count)
 
     def test_get_object(self):
         object_type = ObjectType.objects.first()
 
-        url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk})
+        url = reverse('core-api:objecttype-detail', kwargs={'pk': object_type.pk})
         self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
 
 
@@ -134,12 +135,9 @@ class BackgroundTaskTestCase(TestCase):
         Create a user and token for API calls.
         """
         # Create the test user and assign permissions
-        self.user = User.objects.create_user(username='testuser')
-        self.user.is_staff = True
-        self.user.is_active = True
-        self.user.save()
+        self.user = User.objects.create_user(username='testuser', is_active=True)
         self.token = Token.objects.create(user=self.user)
-        self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.key}'}
+        self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'}
 
         # Clear all queues prior to running each test
         get_queue('default').connection.flushall()
@@ -150,13 +148,11 @@ class BackgroundTaskTestCase(TestCase):
         url = reverse('core-api:rqqueue-list')
 
         # Attempt to load view without permission
-        self.user.is_staff = False
-        self.user.save()
         response = self.client.get(url, **self.header)
         self.assertEqual(response.status_code, 403)
 
         # Load view with permission
-        self.user.is_staff = True
+        self.user.is_superuser = True
         self.user.save()
         response = self.client.get(url, **self.header)
         self.assertEqual(response.status_code, 200)
@@ -165,7 +161,16 @@ class BackgroundTaskTestCase(TestCase):
         self.assertIn('low', str(response.content))
 
     def test_background_queue(self):
-        response = self.client.get(reverse('core-api:rqqueue-detail', args=['default']), **self.header)
+        url = reverse('core-api:rqqueue-detail', args=['default'])
+
+        # Attempt to load view without permission
+        response = self.client.get(url, **self.header)
+        self.assertEqual(response.status_code, 403)
+
+        # Load view with permission
+        self.user.is_superuser = True
+        self.user.save()
+        response = self.client.get(url, **self.header)
         self.assertEqual(response.status_code, 200)
         self.assertIn('default', str(response.content))
         self.assertIn('oldest_job_timestamp', str(response.content))
@@ -174,8 +179,16 @@ class BackgroundTaskTestCase(TestCase):
     def test_background_task_list(self):
         queue = get_queue('default')
         queue.enqueue(self.dummy_job_default)
+        url = reverse('core-api:rqtask-list')
 
-        response = self.client.get(reverse('core-api:rqtask-list'), **self.header)
+        # Attempt to load view without permission
+        response = self.client.get(url, **self.header)
+        self.assertEqual(response.status_code, 403)
+
+        # Load view with permission
+        self.user.is_superuser = True
+        self.user.save()
+        response = self.client.get(url, **self.header)
         self.assertEqual(response.status_code, 200)
         self.assertIn('origin', str(response.content))
         self.assertIn('core.tests.test_api.BackgroundTaskTestCase.dummy_job_default()', str(response.content))
@@ -183,8 +196,16 @@ class BackgroundTaskTestCase(TestCase):
     def test_background_task(self):
         queue = get_queue('default')
         job = queue.enqueue(self.dummy_job_default)
+        url = reverse('core-api:rqtask-detail', args=[job.id])
+
+        # Attempt to load view without permission
+        response = self.client.get(url, **self.header)
+        self.assertEqual(response.status_code, 403)
 
-        response = self.client.get(reverse('core-api:rqtask-detail', args=[job.id]), **self.header)
+        # Load view with permission
+        self.user.is_superuser = True
+        self.user.save()
+        response = self.client.get(url, **self.header)
         self.assertEqual(response.status_code, 200)
         self.assertIn(str(job.id), str(response.content))
         self.assertIn('origin', str(response.content))
@@ -194,45 +215,65 @@ class BackgroundTaskTestCase(TestCase):
     def test_background_task_delete(self):
         queue = get_queue('default')
         job = queue.enqueue(self.dummy_job_default)
+        url = reverse('core-api:rqtask-delete', args=[job.id])
 
-        response = self.client.post(reverse('core-api:rqtask-delete', args=[job.id]), **self.header)
+        # Attempt to load view without permission
+        response = self.client.get(url, **self.header)
+        self.assertEqual(response.status_code, 403)
+
+        # Load view with permission
+        self.user.is_superuser = True
+        self.user.save()
+        response = self.client.post(url, **self.header)
         self.assertEqual(response.status_code, 200)
         self.assertFalse(RQ_Job.exists(job.id, connection=queue.connection))
         queue = get_queue('default')
         self.assertNotIn(job.id, queue.job_ids)
 
     def test_background_task_requeue(self):
-        queue = get_queue('default')
-
         # Enqueue & run a job that will fail
+        queue = get_queue('default')
         job = queue.enqueue(self.dummy_job_failing)
         worker = get_worker('default')
         with disable_logging():
             worker.work(burst=True)
         self.assertTrue(job.is_failed)
+        url = reverse('core-api:rqtask-requeue', args=[job.id])
+
+        # Attempt to requeue the job without permission
+        response = self.client.post(url, **self.header)
+        self.assertEqual(response.status_code, 403)
 
         # Re-enqueue the failed job and check that its status has been reset
-        response = self.client.post(reverse('core-api:rqtask-requeue', args=[job.id]), **self.header)
+        self.user.is_superuser = True
+        self.user.save()
+        response = self.client.post(url, **self.header)
         self.assertEqual(response.status_code, 200)
         job = RQ_Job.fetch(job.id, queue.connection)
         self.assertFalse(job.is_failed)
 
     def test_background_task_enqueue(self):
-        queue = get_queue('default')
-
         # Enqueue some jobs that each depends on its predecessor
+        queue = get_queue('default')
         job = previous_job = None
         for _ in range(0, 3):
             job = queue.enqueue(self.dummy_job_default, depends_on=previous_job)
             previous_job = job
+        url = reverse('core-api:rqtask-enqueue', args=[job.id])
 
         # Check that the last job to be enqueued has a status of deferred
         self.assertIsNotNone(job)
         self.assertEqual(job.get_status(), JobStatus.DEFERRED)
         self.assertIsNone(job.enqueued_at)
 
+        # Attempt to force-enqueue the job without permission
+        response = self.client.post(url, **self.header)
+        self.assertEqual(response.status_code, 403)
+
         # Force-enqueue the deferred job
-        response = self.client.post(reverse('core-api:rqtask-enqueue', args=[job.id]), **self.header)
+        self.user.is_superuser = True
+        self.user.save()
+        response = self.client.post(url, **self.header)
         self.assertEqual(response.status_code, 200)
 
         # Check that job's status is updated correctly
@@ -242,19 +283,27 @@ class BackgroundTaskTestCase(TestCase):
 
     def test_background_task_stop(self):
         queue = get_queue('default')
-
         worker = get_worker('default')
         job = queue.enqueue(self.dummy_job_default)
         worker.prepare_job_execution(job)
-
+        url = reverse('core-api:rqtask-stop', args=[job.id])
         self.assertEqual(job.get_status(), JobStatus.STARTED)
-        response = self.client.post(reverse('core-api:rqtask-stop', args=[job.id]), **self.header)
+
+        # Attempt to stop the task without permission
+        response = self.client.post(url, **self.header)
+        self.assertEqual(response.status_code, 403)
+
+        # Stop the task
+        self.user.is_superuser = True
+        self.user.save()
+        response = self.client.post(url, **self.header)
         self.assertEqual(response.status_code, 200)
         with disable_logging():
             worker.monitor_work_horse(job, queue)  # Sets the job as Failed and removes from Started
         started_job_registry = StartedJobRegistry(queue.name, connection=queue.connection)
         self.assertEqual(len(started_job_registry), 0)
 
+        # Verify that the task was cancelled
         canceled_job_registry = FailedJobRegistry(queue.name, connection=queue.connection)
         self.assertEqual(len(canceled_job_registry), 1)
         self.assertIn(job.id, canceled_job_registry)
@@ -262,19 +311,34 @@ class BackgroundTaskTestCase(TestCase):
     def test_worker_list(self):
         worker1 = get_worker('default', name=uuid.uuid4().hex)
         worker1.register_birth()
-
         worker2 = get_worker('high')
         worker2.register_birth()
+        url = reverse('core-api:rqworker-list')
+
+        # Attempt to fetch the worker list without permission
+        response = self.client.get(url, **self.header)
+        self.assertEqual(response.status_code, 403)
 
-        response = self.client.get(reverse('core-api:rqworker-list'), **self.header)
+        # Fetch the worker list
+        self.user.is_superuser = True
+        self.user.save()
+        response = self.client.get(url, **self.header)
         self.assertEqual(response.status_code, 200)
         self.assertIn(str(worker1.name), str(response.content))
 
     def test_worker(self):
         worker1 = get_worker('default', name=uuid.uuid4().hex)
         worker1.register_birth()
+        url = reverse('core-api:rqworker-detail', args=[worker1.name])
 
-        response = self.client.get(reverse('core-api:rqworker-detail', args=[worker1.name]), **self.header)
+        # Attempt to fetch a worker without permission
+        response = self.client.get(url, **self.header)
+        self.assertEqual(response.status_code, 403)
+
+        # Fetch the worker
+        self.user.is_superuser = True
+        self.user.save()
+        response = self.client.get(url, **self.header)
         self.assertEqual(response.status_code, 200)
         self.assertIn(str(worker1.name), str(response.content))
         self.assertIn('birth_date', str(response.content))

+ 4 - 4
netbox/core/tests/test_views.py

@@ -158,7 +158,7 @@ class BackgroundTaskTestCase(TestCase):
 
     def setUp(self):
         super().setUp()
-        self.user.is_staff = True
+        self.user.is_superuser = True
         self.user.is_active = True
         self.user.save()
 
@@ -171,13 +171,13 @@ class BackgroundTaskTestCase(TestCase):
         url = reverse('core:background_queue_list')
 
         # Attempt to load view without permission
-        self.user.is_staff = False
+        self.user.is_superuser = False
         self.user.save()
         response = self.client.get(url)
         self.assertEqual(response.status_code, 403)
 
         # Load view with permission
-        self.user.is_staff = True
+        self.user.is_superuser = True
         self.user.save()
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200)
@@ -356,7 +356,7 @@ class SystemTestCase(TestCase):
     def setUp(self):
         super().setUp()
 
-        self.user.is_staff = True
+        self.user.is_superuser = True
         self.user.save()
 
     def test_system_view_default(self):

+ 3 - 3
netbox/core/views.py

@@ -373,7 +373,7 @@ class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
 class BaseRQView(UserPassesTestMixin, View):
 
     def test_func(self):
-        return self.request.user.is_staff
+        return self.request.user.is_superuser
 
 
 class BackgroundQueueListView(TableMixin, BaseRQView):
@@ -556,7 +556,7 @@ class WorkerView(BaseRQView):
 class SystemView(UserPassesTestMixin, View):
 
     def test_func(self):
-        return self.request.user.is_staff
+        return self.request.user.is_superuser
 
     def get(self, request):
 
@@ -639,7 +639,7 @@ class BasePluginView(UserPassesTestMixin, View):
     CACHE_KEY_CATALOG_ERROR = 'plugins-catalog-error'
 
     def test_func(self):
-        return self.request.user.is_staff
+        return self.request.user.is_superuser
 
     def get_cached_plugins(self, request):
         catalog_plugins = {}

+ 52 - 0
netbox/dcim/api/serializers_/base.py

@@ -2,10 +2,12 @@ from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
+from dcim.models import FrontPort, FrontPortTemplate, PortMapping, PortTemplateMapping, RearPort, RearPortTemplate
 from utilities.api import get_serializer_for_model
 
 __all__ = (
     'ConnectedEndpointsSerializer',
+    'PortSerializer',
 )
 
 
@@ -35,3 +37,53 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
     @extend_schema_field(serializers.BooleanField)
     def get_connected_endpoints_reachable(self, obj):
         return obj._path and obj._path.is_complete and obj._path.is_active
+
+
+class PortSerializer(serializers.ModelSerializer):
+    """
+    Base serializer for front & rear port and port templates.
+    """
+    @property
+    def _mapper(self):
+        """
+        Return the model and ForeignKey field name used to track port mappings for this model.
+        """
+        if self.Meta.model is FrontPort:
+            return PortMapping, 'front_port'
+        if self.Meta.model is RearPort:
+            return PortMapping, 'rear_port'
+        if self.Meta.model is FrontPortTemplate:
+            return PortTemplateMapping, 'front_port'
+        if self.Meta.model is RearPortTemplate:
+            return PortTemplateMapping, 'rear_port'
+        raise ValueError(f"Could not determine mapping details for {self.__class__}")
+
+    def create(self, validated_data):
+        mappings = validated_data.pop('mappings', [])
+        instance = super().create(validated_data)
+
+        # Create port mappings
+        mapping_model, fk_name = self._mapper
+        for attrs in mappings:
+            mapping_model.objects.create(**{
+                fk_name: instance,
+                **attrs,
+            })
+
+        return instance
+
+    def update(self, instance, validated_data):
+        mappings = validated_data.pop('mappings', None)
+        instance = super().update(instance, validated_data)
+
+        if mappings is not None:
+            # Update port mappings
+            mapping_model, fk_name = self._mapper
+            mapping_model.objects.filter(**{fk_name: instance}).delete()
+            for attrs in mappings:
+                mapping_model.objects.create(**{
+                    fk_name: instance,
+                    **attrs,
+                })
+
+        return instance

+ 17 - 16
netbox/dcim/api/serializers_/cables.py

@@ -1,13 +1,14 @@
-from django.contrib.contenttypes.models import ContentType
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from dcim.choices import *
-from dcim.constants import *
 from dcim.models import Cable, CablePath, CableTermination
 from netbox.api.fields import ChoiceField, ContentTypeField
-from netbox.api.serializers import BaseModelSerializer, GenericObjectSerializer, NetBoxModelSerializer
+from netbox.api.gfk_fields import GFKSerializerField
+from netbox.api.serializers import (
+    BaseModelSerializer, GenericObjectSerializer, NetBoxModelSerializer, PrimaryModelSerializer,
+)
 from tenancy.api.serializers_.tenants import TenantSerializer
 from utilities.api import get_serializer_for_model
 
@@ -20,19 +21,20 @@ __all__ = (
 )
 
 
-class CableSerializer(NetBoxModelSerializer):
+class CableSerializer(PrimaryModelSerializer):
     a_terminations = GenericObjectSerializer(many=True, required=False)
     b_terminations = GenericObjectSerializer(many=True, required=False)
     status = ChoiceField(choices=LinkStatusChoices, required=False)
+    profile = ChoiceField(choices=CableProfileChoices, required=False)
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
     length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True)
 
     class Meta:
         model = Cable
         fields = [
-            'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant',
-            'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created',
-            'last_updated',
+            'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'profile',
+            'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'label', 'description')
 
@@ -51,22 +53,21 @@ class TracedCableSerializer(BaseModelSerializer):
 
 class CableTerminationSerializer(NetBoxModelSerializer):
     termination_type = ContentTypeField(
-        queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
+        read_only=True,
     )
-    termination = serializers.SerializerMethodField(read_only=True)
+    termination = GFKSerializerField(read_only=True)
 
     class Meta:
         model = CableTermination
         fields = [
             'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id',
-            'termination', 'created', 'last_updated',
+            'termination', 'connector', 'positions', 'created', 'last_updated',
         ]
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_termination(self, obj):
-        serializer = get_serializer_for_model(obj.termination)
-        context = {'request': self.context['request']}
-        return serializer(obj.termination, nested=True, context=context).data
+        read_only_fields = fields
+        brief_fields = (
+            'id', 'url', 'display', 'cable', 'cable_end', 'connector', 'positions', 'termination_type',
+            'termination_id',
+        )
 
 
 class CablePathSerializer(serializers.ModelSerializer):

+ 89 - 47
netbox/dcim/api/serializers_/device_components.py

@@ -1,26 +1,26 @@
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
-from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from dcim.choices import *
 from dcim.constants import *
 from dcim.models import (
-    ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
-    RearPort, VirtualDeviceContext,
+    ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PortMapping,
+    PowerOutlet, PowerPort, RearPort, VirtualDeviceContext,
 )
 from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
 from ipam.api.serializers_.vrfs import VRFSerializer
 from ipam.models import VLAN
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
-from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
-from utilities.api import get_serializer_for_model
+from netbox.api.gfk_fields import GFKSerializerField
+from netbox.api.serializers import NetBoxModelSerializer
+from users.api.serializers_.mixins import OwnerMixin
 from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
 from wireless.api.serializers_.nested import NestedWirelessLinkSerializer
 from wireless.api.serializers_.wirelesslans import WirelessLANSerializer
 from wireless.choices import *
 from wireless.models import WirelessLAN
-from .base import ConnectedEndpointsSerializer
+from .base import ConnectedEndpointsSerializer, PortSerializer
 from .cables import CabledObjectSerializer
 from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer
 from .manufacturers import ManufacturerSerializer
@@ -41,7 +41,12 @@ __all__ = (
 )
 
 
-class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
+class ConsoleServerPortSerializer(
+    OwnerMixin,
+    NetBoxModelSerializer,
+    CabledObjectSerializer,
+    ConnectedEndpointsSerializer
+):
     device = DeviceSerializer(nested=True)
     module = ModuleSerializer(
         nested=True,
@@ -65,13 +70,18 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer,
         fields = [
             'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
             'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
-            'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
+            'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created',
             'last_updated', '_occupied',
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
-class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
+class ConsolePortSerializer(
+    OwnerMixin,
+    NetBoxModelSerializer,
+    CabledObjectSerializer,
+    ConnectedEndpointsSerializer
+):
     device = DeviceSerializer(nested=True)
     module = ModuleSerializer(
         nested=True,
@@ -95,13 +105,18 @@ class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
         fields = [
             'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
             'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
-            'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
+            'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created',
             'last_updated', '_occupied',
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
-class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
+class PowerPortSerializer(
+    OwnerMixin,
+    NetBoxModelSerializer,
+    CabledObjectSerializer,
+    ConnectedEndpointsSerializer
+):
     device = DeviceSerializer(nested=True)
     module = ModuleSerializer(
         nested=True,
@@ -121,13 +136,18 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
         fields = [
             'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw',
             'allocated_draw', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
-            'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
-            'created', 'last_updated', '_occupied',
+            'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags',
+            'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
-class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
+class PowerOutletSerializer(
+    OwnerMixin,
+    NetBoxModelSerializer,
+    CabledObjectSerializer,
+    ConnectedEndpointsSerializer
+):
     device = DeviceSerializer(nested=True)
     module = ModuleSerializer(
         nested=True,
@@ -160,12 +180,17 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
             'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'status', 'color',
             'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
             'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
-            'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
+            'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
-class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
+class InterfaceSerializer(
+    OwnerMixin,
+    NetBoxModelSerializer,
+    CabledObjectSerializer,
+    ConnectedEndpointsSerializer
+):
     device = DeviceSerializer(nested=True)
     vdcs = SerializedPKRelatedField(
         queryset=VirtualDeviceContext.objects.all(),
@@ -228,8 +253,8 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
             'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
             'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end',
             'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination',
-            'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
-            'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
+            'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags',
+            'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
@@ -296,7 +321,20 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
         return super().validate(data)
 
 
-class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
+class RearPortMappingSerializer(serializers.ModelSerializer):
+    position = serializers.IntegerField(
+        source='rear_port_position'
+    )
+    front_port = serializers.PrimaryKeyRelatedField(
+        queryset=FrontPort.objects.all(),
+    )
+
+    class Meta:
+        model = PortMapping
+        fields = ('position', 'front_port', 'front_port_position')
+
+
+class RearPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
     device = DeviceSerializer(nested=True)
     module = ModuleSerializer(
         nested=True,
@@ -305,28 +343,36 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
         allow_null=True
     )
     type = ChoiceField(choices=PortTypeChoices)
+    front_ports = RearPortMappingSerializer(
+        source='mappings',
+        many=True,
+        required=False,
+    )
 
     class Meta:
         model = RearPort
         fields = [
             'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
-            'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags',
-            'custom_fields', 'created', 'last_updated', '_occupied',
+            'front_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
+            'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
-class FrontPortRearPortSerializer(WritableNestedSerializer):
-    """
-    NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device)
-    """
+class FrontPortMappingSerializer(serializers.ModelSerializer):
+    position = serializers.IntegerField(
+        source='front_port_position'
+    )
+    rear_port = serializers.PrimaryKeyRelatedField(
+        queryset=RearPort.objects.all(),
+    )
 
     class Meta:
-        model = RearPort
-        fields = ['id', 'url', 'display_url', 'display', 'name', 'label', 'description']
+        model = PortMapping
+        fields = ('position', 'rear_port', 'rear_port_position')
 
 
-class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
+class FrontPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
     device = DeviceSerializer(nested=True)
     module = ModuleSerializer(
         nested=True,
@@ -335,19 +381,23 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
         allow_null=True
     )
     type = ChoiceField(choices=PortTypeChoices)
-    rear_port = FrontPortRearPortSerializer()
+    rear_ports = FrontPortMappingSerializer(
+        source='mappings',
+        many=True,
+        required=False,
+    )
 
     class Meta:
         model = FrontPort
         fields = [
-            'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
-            'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
-            'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
+            'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
+            'rear_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
+            'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
-class ModuleBaySerializer(NetBoxModelSerializer):
+class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
     device = DeviceSerializer(nested=True)
     module = ModuleSerializer(
         nested=True,
@@ -367,12 +417,12 @@ class ModuleBaySerializer(NetBoxModelSerializer):
         model = ModuleBay
         fields = [
             'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'installed_module', 'label', 'position',
-            'description', 'tags', 'custom_fields', 'created', 'last_updated',
+            'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
 
 
-class DeviceBaySerializer(NetBoxModelSerializer):
+class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
     device = DeviceSerializer(nested=True)
     installed_device = DeviceSerializer(nested=True, required=False, allow_null=True)
 
@@ -380,12 +430,12 @@ class DeviceBaySerializer(NetBoxModelSerializer):
         model = DeviceBay
         fields = [
             'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'description', 'installed_device',
-            'tags', 'custom_fields', 'created', 'last_updated',
+            'owner', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
 
 
-class InventoryItemSerializer(NetBoxModelSerializer):
+class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):
     device = DeviceSerializer(nested=True)
     parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
     role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True)
@@ -395,7 +445,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
         required=False,
         allow_null=True
     )
-    component = serializers.SerializerMethodField(read_only=True, allow_null=True)
+    component = GFKSerializerField(read_only=True)
     _depth = serializers.IntegerField(source='level', read_only=True)
     status = ChoiceField(choices=InventoryItemStatusChoices, required=False)
 
@@ -404,14 +454,6 @@ class InventoryItemSerializer(NetBoxModelSerializer):
         fields = [
             'id', 'url', 'display_url', 'display', 'device', 'parent', 'name', 'label', 'status', 'role',
             'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'component_type',
-            'component_id', 'component', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
+            'component_id', 'component', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_component(self, obj):
-        if obj.component is None:
-            return None
-        serializer = get_serializer_for_model(obj.component)
-        context = {'request': self.context['request']}
-        return serializer(obj.component, nested=True, context=context).data

+ 14 - 22
netbox/dcim/api/serializers_/devices.py

@@ -11,15 +11,15 @@ from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceCont
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from ipam.api.serializers_.ip import IPAddressSerializer
 from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
-from netbox.api.serializers import NetBoxModelSerializer
+from netbox.api.gfk_fields import GFKSerializerField
+from netbox.api.serializers import PrimaryModelSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
-from utilities.api import get_serializer_for_model
 from virtualization.api.serializers_.clusters import ClusterSerializer
 from .devicetypes import *
+from .nested import NestedDeviceBaySerializer, NestedDeviceSerializer, NestedModuleBaySerializer
 from .platforms import PlatformSerializer
 from .racks import RackSerializer
 from .roles import DeviceRoleSerializer
-from .nested import NestedDeviceBaySerializer, NestedDeviceSerializer, NestedModuleBaySerializer
 from .sites import LocationSerializer, SiteSerializer
 from .virtualchassis import VirtualChassisSerializer
 
@@ -32,7 +32,7 @@ __all__ = (
 )
 
 
-class DeviceSerializer(NetBoxModelSerializer):
+class DeviceSerializer(PrimaryModelSerializer):
     device_type = DeviceTypeSerializer(nested=True)
     role = DeviceRoleSerializer(nested=True)
     tenant = TenantSerializer(
@@ -84,8 +84,8 @@ class DeviceSerializer(NetBoxModelSerializer):
             'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
             'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
             'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
-            'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
-            'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
+            'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template', 'local_context_data',
+            'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
             'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
             'device_bay_count', 'module_bay_count', 'inventory_item_count',
         ]
@@ -111,7 +111,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
             'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
             'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
             'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
-            'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
+            'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template', 'config_context',
             'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
             'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
             'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
@@ -122,7 +122,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
         return obj.get_config_context()
 
 
-class VirtualDeviceContextSerializer(NetBoxModelSerializer):
+class VirtualDeviceContextSerializer(PrimaryModelSerializer):
     device = DeviceSerializer(nested=True)
     identifier = serializers.IntegerField(allow_null=True, max_value=32767, min_value=0, required=False, default=None)
     tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None)
@@ -138,13 +138,13 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
         model = VirtualDeviceContext
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip',
-            'primary_ip4', 'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields',
+            'primary_ip4', 'primary_ip6', 'status', 'description', 'owner', 'comments', 'tags', 'custom_fields',
             'created', 'last_updated', 'interface_count',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'identifier', 'device', 'description')
 
 
-class ModuleSerializer(NetBoxModelSerializer):
+class ModuleSerializer(PrimaryModelSerializer):
     device = DeviceSerializer(nested=True)
     module_bay = NestedModuleBaySerializer()
     module_type = ModuleTypeSerializer(nested=True)
@@ -154,31 +154,23 @@ class ModuleSerializer(NetBoxModelSerializer):
         model = Module
         fields = [
             'id', 'url', 'display_url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial',
-            'asset_tag', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'asset_tag', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
 
 
-class MACAddressSerializer(NetBoxModelSerializer):
+class MACAddressSerializer(PrimaryModelSerializer):
     assigned_object_type = ContentTypeField(
         queryset=ContentType.objects.filter(MACADDRESS_ASSIGNMENT_MODELS),
         required=False,
         allow_null=True
     )
-    assigned_object = serializers.SerializerMethodField(read_only=True)
+    assigned_object = GFKSerializerField(read_only=True)
 
     class Meta:
         model = MACAddress
         fields = [
             'id', 'url', 'display_url', 'display', 'mac_address', 'assigned_object_type', 'assigned_object_id',
-            'assigned_object', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'assigned_object', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'mac_address', 'description')
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_assigned_object(self, obj):
-        if obj.assigned_object is None:
-            return None
-        serializer = get_serializer_for_model(obj.assigned_object)
-        context = {'request': self.context['request']}
-        return serializer(obj.assigned_object, nested=True, context=context).data

+ 48 - 20
netbox/dcim/api/serializers_/devicetype_components.py

@@ -1,17 +1,18 @@
 from django.contrib.contenttypes.models import ContentType
-from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from dcim.choices import *
 from dcim.constants import *
 from dcim.models import (
     ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
-    InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
+    InventoryItemTemplate, ModuleBayTemplate, PortTemplateMapping, PowerOutletTemplate, PowerPortTemplate,
+    RearPortTemplate,
 )
 from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
-from utilities.api import get_serializer_for_model
 from wireless.choices import *
+from .base import PortSerializer
 from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
 from .manufacturers import ManufacturerSerializer
 from .nested import NestedInterfaceTemplateSerializer
@@ -155,7 +156,7 @@ class PowerOutletTemplateSerializer(ComponentTemplateSerializer):
         model = PowerOutletTemplate
         fields = [
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type',
-            'power_port', 'feed_leg', 'description', 'created', 'last_updated',
+            'color', 'power_port', 'feed_leg', 'description', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
@@ -206,7 +207,20 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
-class RearPortTemplateSerializer(ComponentTemplateSerializer):
+class RearPortTemplateMappingSerializer(serializers.ModelSerializer):
+    position = serializers.IntegerField(
+        source='rear_port_position'
+    )
+    front_port = serializers.PrimaryKeyRelatedField(
+        queryset=FrontPortTemplate.objects.all(),
+    )
+
+    class Meta:
+        model = PortTemplateMapping
+        fields = ('position', 'front_port', 'front_port_position')
+
+
+class RearPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
     device_type = DeviceTypeSerializer(
         required=False,
         nested=True,
@@ -220,17 +234,35 @@ class RearPortTemplateSerializer(ComponentTemplateSerializer):
         default=None
     )
     type = ChoiceField(choices=PortTypeChoices)
+    front_ports = RearPortTemplateMappingSerializer(
+        source='mappings',
+        many=True,
+        required=False,
+    )
 
     class Meta:
         model = RearPortTemplate
         fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color',
-            'positions', 'description', 'created', 'last_updated',
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
+            'front_ports', 'description', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
-class FrontPortTemplateSerializer(ComponentTemplateSerializer):
+class FrontPortTemplateMappingSerializer(serializers.ModelSerializer):
+    position = serializers.IntegerField(
+        source='front_port_position'
+    )
+    rear_port = serializers.PrimaryKeyRelatedField(
+        queryset=RearPortTemplate.objects.all(),
+    )
+
+    class Meta:
+        model = PortTemplateMapping
+        fields = ('position', 'rear_port', 'rear_port_position')
+
+
+class FrontPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
     device_type = DeviceTypeSerializer(
         nested=True,
         required=False,
@@ -244,13 +276,17 @@ class FrontPortTemplateSerializer(ComponentTemplateSerializer):
         default=None
     )
     type = ChoiceField(choices=PortTypeChoices)
-    rear_port = RearPortTemplateSerializer(nested=True)
+    rear_ports = FrontPortTemplateMappingSerializer(
+        source='mappings',
+        many=True,
+        required=False,
+    )
 
     class Meta:
         model = FrontPortTemplate
         fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color',
-            'rear_port', 'rear_port_position', 'description', 'created', 'last_updated',
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
+            'rear_ports', 'description', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
@@ -313,7 +349,7 @@ class InventoryItemTemplateSerializer(ComponentTemplateSerializer):
         required=False,
         allow_null=True
     )
-    component = serializers.SerializerMethodField(read_only=True, allow_null=True)
+    component = GFKSerializerField(read_only=True)
     _depth = serializers.IntegerField(source='level', read_only=True)
 
     class Meta:
@@ -324,11 +360,3 @@ class InventoryItemTemplateSerializer(ComponentTemplateSerializer):
             '_depth',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description', '_depth')
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_component(self, obj):
-        if obj.component is None:
-            return None
-        serializer = get_serializer_for_model(obj.component)
-        context = {'request': self.context['request']}
-        return serializer(obj.component, nested=True, context=context).data

+ 13 - 14
netbox/dcim/api/serializers_/devicetypes.py

@@ -5,8 +5,8 @@ from rest_framework import serializers
 
 from dcim.choices import *
 from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
-from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField
-from netbox.api.serializers import NetBoxModelSerializer
+from netbox.api.fields import AttributesField, ChoiceField
+from netbox.api.serializers import PrimaryModelSerializer
 from netbox.choices import *
 from .manufacturers import ManufacturerSerializer
 from .platforms import PlatformSerializer
@@ -18,7 +18,7 @@ __all__ = (
 )
 
 
-class DeviceTypeSerializer(NetBoxModelSerializer):
+class DeviceTypeSerializer(PrimaryModelSerializer):
     manufacturer = ManufacturerSerializer(nested=True)
     default_platform = PlatformSerializer(nested=True, required=False, allow_null=True)
     u_height = serializers.DecimalField(
@@ -45,16 +45,14 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
     device_bay_template_count = serializers.IntegerField(read_only=True)
     module_bay_template_count = serializers.IntegerField(read_only=True)
     inventory_item_template_count = serializers.IntegerField(read_only=True)
-
-    # Related object counts
-    device_count = RelatedObjectCountField('instances')
+    device_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = DeviceType
         fields = [
             'id', 'url', 'display_url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number',
             'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
-            'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields',
+            'weight_unit', 'front_image', 'rear_image', 'description', 'owner', 'comments', 'tags', 'custom_fields',
             'created', 'last_updated', 'device_count', 'console_port_template_count',
             'console_server_port_template_count', 'power_port_template_count', 'power_outlet_template_count',
             'interface_template_count', 'front_port_template_count', 'rear_port_template_count',
@@ -63,18 +61,18 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
         brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
 
 
-class ModuleTypeProfileSerializer(NetBoxModelSerializer):
+class ModuleTypeProfileSerializer(PrimaryModelSerializer):
 
     class Meta:
         model = ModuleTypeProfile
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated',
+            'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
-class ModuleTypeSerializer(NetBoxModelSerializer):
+class ModuleTypeSerializer(PrimaryModelSerializer):
     profile = ModuleTypeProfileSerializer(
         nested=True,
         required=False,
@@ -100,12 +98,13 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
         required=False,
         allow_null=True
     )
+    module_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = ModuleType
         fields = [
             'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
-            'weight', 'weight_unit', 'description', 'attributes', 'comments', 'tags', 'custom_fields', 'created',
-            'last_updated',
+            'weight', 'weight_unit', 'description', 'attributes', 'owner', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated', 'module_count',
         ]
-        brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description')
+        brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description', 'module_count')

+ 6 - 4
netbox/dcim/api/serializers_/manufacturers.py

@@ -1,23 +1,25 @@
 from dcim.models import Manufacturer
 from netbox.api.fields import RelatedObjectCountField
-from netbox.api.serializers import NetBoxModelSerializer
+from netbox.api.serializers import OrganizationalModelSerializer
 
 __all__ = (
     'ManufacturerSerializer',
 )
 
 
-class ManufacturerSerializer(NetBoxModelSerializer):
+class ManufacturerSerializer(OrganizationalModelSerializer):
 
     # Related object counts
     devicetype_count = RelatedObjectCountField('device_types')
+    moduletype_count = RelatedObjectCountField('module_types')
     inventoryitem_count = RelatedObjectCountField('inventory_items')
     platform_count = RelatedObjectCountField('platforms')
 
     class Meta:
         model = Manufacturer
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields',
-            'created', 'last_updated', 'devicetype_count', 'inventoryitem_count', 'platform_count',
+            'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
+            'platform_count',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')

+ 1 - 1
netbox/dcim/api/serializers_/platforms.py

@@ -24,7 +24,7 @@ class PlatformSerializer(NestedGroupModelSerializer):
         model = Platform
         fields = [
             'id', 'url', 'display_url', 'display', 'parent', 'name', 'slug', 'manufacturer', 'config_template',
-            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
+            'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
             'virtualmachine_count', '_depth',
         ]
         brief_fields = (

+ 7 - 6
netbox/dcim/api/serializers_/power.py

@@ -1,7 +1,7 @@
 from dcim.choices import *
 from dcim.models import PowerFeed, PowerPanel
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
-from netbox.api.serializers import NetBoxModelSerializer
+from netbox.api.serializers import PrimaryModelSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
 from .base import ConnectedEndpointsSerializer
 from .cables import CabledObjectSerializer
@@ -14,7 +14,7 @@ __all__ = (
 )
 
 
-class PowerPanelSerializer(NetBoxModelSerializer):
+class PowerPanelSerializer(PrimaryModelSerializer):
     site = SiteSerializer(nested=True)
     location = LocationSerializer(
         nested=True,
@@ -29,13 +29,13 @@ class PowerPanelSerializer(NetBoxModelSerializer):
     class Meta:
         model = PowerPanel
         fields = [
-            'id', 'url', 'display_url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags',
-            'custom_fields', 'powerfeed_count', 'created', 'last_updated',
+            'id', 'url', 'display_url', 'display', 'site', 'location', 'name', 'description', 'owner', 'comments',
+            'tags', 'custom_fields', 'powerfeed_count', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description', 'powerfeed_count')
 
 
-class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
+class PowerFeedSerializer(PrimaryModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
     power_panel = PowerPanelSerializer(nested=True)
     rack = RackSerializer(
         nested=True,
@@ -71,6 +71,7 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
             'id', 'url', 'display_url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply',
             'phase', 'voltage', 'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers',
             'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
-            'description', 'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
+            'description', 'tenant', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            '_occupied',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description', 'cable', '_occupied')

+ 14 - 15
netbox/dcim/api/serializers_/racks.py

@@ -5,7 +5,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.models import Rack, RackReservation, RackRole, RackType
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
-from netbox.api.serializers import NetBoxModelSerializer
+from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
 from netbox.choices import *
 from netbox.config import ConfigItem
 from tenancy.api.serializers_.tenants import TenantSerializer
@@ -22,7 +22,7 @@ __all__ = (
 )
 
 
-class RackRoleSerializer(NetBoxModelSerializer):
+class RackRoleSerializer(OrganizationalModelSerializer):
 
     # Related object counts
     rack_count = RelatedObjectCountField('racks')
@@ -30,13 +30,13 @@ class RackRoleSerializer(NetBoxModelSerializer):
     class Meta:
         model = RackRole
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
-            'created', 'last_updated', 'rack_count',
+            'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated', 'rack_count',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
 
 
-class RackBaseSerializer(NetBoxModelSerializer):
+class RackBaseSerializer(PrimaryModelSerializer):
     form_factor = ChoiceField(
         choices=RackFormFactorChoices,
         allow_blank=True,
@@ -62,19 +62,18 @@ class RackBaseSerializer(NetBoxModelSerializer):
 
 
 class RackTypeSerializer(RackBaseSerializer):
-    manufacturer = ManufacturerSerializer(
-        nested=True
-    )
+    manufacturer = ManufacturerSerializer(nested=True)
+    rack_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = RackType
         fields = [
             'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor',
             'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
-            'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags',
-            'custom_fields', 'created', 'last_updated',
+            'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'owner', 'comments',
+            'tags', 'custom_fields', 'created', 'last_updated', 'rack_count',
         ]
-        brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description')
+        brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'rack_count')
 
 
 class RackSerializer(RackBaseSerializer):
@@ -130,13 +129,13 @@ class RackSerializer(RackBaseSerializer):
             'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status',
             'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight',
             'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit',
-            'mounting_depth', 'airflow', 'description', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated', 'device_count', 'powerfeed_count',
+            'mounting_depth', 'airflow', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created',
+            'last_updated', 'device_count', 'powerfeed_count',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
 
 
-class RackReservationSerializer(NetBoxModelSerializer):
+class RackReservationSerializer(PrimaryModelSerializer):
     rack = RackSerializer(
         nested=True,
     )
@@ -157,7 +156,7 @@ class RackReservationSerializer(NetBoxModelSerializer):
         model = RackReservation
         fields = [
             'id', 'url', 'display_url', 'display', 'rack', 'units', 'status', 'created', 'last_updated', 'user',
-            'tenant', 'description', 'comments', 'tags', 'custom_fields',
+            'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields',
         ]
         brief_fields = ('id', 'url', 'display', 'status', 'user', 'description', 'units')
 

+ 5 - 5
netbox/dcim/api/serializers_/roles.py

@@ -3,7 +3,7 @@ from rest_framework import serializers
 from dcim.models import DeviceRole, InventoryItemRole
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from netbox.api.fields import RelatedObjectCountField
-from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
+from netbox.api.serializers import NestedGroupModelSerializer, OrganizationalModelSerializer
 from .nested import NestedDeviceRoleSerializer
 
 __all__ = (
@@ -23,14 +23,14 @@ class DeviceRoleSerializer(NestedGroupModelSerializer):
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'parent',
             'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
-            'comments', '_depth',
+            'owner', 'comments', '_depth',
         ]
         brief_fields = (
             'id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count', '_depth'
         )
 
 
-class InventoryItemRoleSerializer(NetBoxModelSerializer):
+class InventoryItemRoleSerializer(OrganizationalModelSerializer):
 
     # Related object counts
     inventoryitem_count = RelatedObjectCountField('inventory_items')
@@ -38,7 +38,7 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer):
     class Meta:
         model = InventoryItemRole
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
-            'created', 'last_updated', 'inventoryitem_count',
+            'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated', 'inventoryitem_count',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count')

+ 6 - 6
netbox/dcim/api/serializers_/sites.py

@@ -6,7 +6,7 @@ from dcim.models import Location, Region, Site, SiteGroup
 from ipam.api.serializers_.asns import ASNSerializer
 from ipam.models import ASN
 from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField
-from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
+from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
 from .nested import NestedLocationSerializer, NestedRegionSerializer, NestedSiteGroupSerializer
 
@@ -27,7 +27,7 @@ class RegionSerializer(NestedGroupModelSerializer):
         model = Region
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
-            'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth',
+            'created', 'last_updated', 'site_count', 'prefix_count', 'owner', 'comments', '_depth',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
 
@@ -41,12 +41,12 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
         model = SiteGroup
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
-            'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth',
+            'created', 'last_updated', 'site_count', 'prefix_count', 'owner', 'comments', '_depth',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
 
 
-class SiteSerializer(NetBoxModelSerializer):
+class SiteSerializer(PrimaryModelSerializer):
     status = ChoiceField(choices=SiteStatusChoices, required=False)
     region = RegionSerializer(nested=True, required=False, allow_null=True)
     group = SiteGroupSerializer(nested=True, required=False, allow_null=True)
@@ -72,7 +72,7 @@ class SiteSerializer(NetBoxModelSerializer):
         model = Site
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility',
-            'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude',
+            'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'owner',
             'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count',
             'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
         ]
@@ -93,6 +93,6 @@ class LocationSerializer(NestedGroupModelSerializer):
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility',
             'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count',
-            'prefix_count', 'comments', '_depth',
+            'prefix_count', 'owner', 'comments', '_depth',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')

+ 4 - 4
netbox/dcim/api/serializers_/virtualchassis.py

@@ -1,7 +1,7 @@
 from rest_framework import serializers
 
 from dcim.models import VirtualChassis
-from netbox.api.serializers import NetBoxModelSerializer
+from netbox.api.serializers import PrimaryModelSerializer
 from .nested import NestedDeviceSerializer
 
 __all__ = (
@@ -9,7 +9,7 @@ __all__ = (
 )
 
 
-class VirtualChassisSerializer(NetBoxModelSerializer):
+class VirtualChassisSerializer(PrimaryModelSerializer):
     master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
     members = NestedDeviceSerializer(many=True, read_only=True)
 
@@ -19,7 +19,7 @@ class VirtualChassisSerializer(NetBoxModelSerializer):
     class Meta:
         model = VirtualChassis
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags',
-            'custom_fields', 'created', 'last_updated', 'member_count', 'members',
+            'id', 'url', 'display_url', 'display', 'name', 'domain', 'master', 'description', 'owner', 'comments',
+            'tags', 'custom_fields', 'created', 'last_updated', 'member_count', 'members',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count')

+ 2 - 2
netbox/dcim/api/views.py

@@ -16,7 +16,7 @@ from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import StripCountAnnotationsPaginator
-from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
+from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin, NetBoxReadOnlyModelViewSet
 from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
 from utilities.api import get_serializer_for_model
 from utilities.query_functions import CollateAsChar
@@ -563,7 +563,7 @@ class CableViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.CableFilterSet
 
 
-class CableTerminationViewSet(NetBoxModelViewSet):
+class CableTerminationViewSet(NetBoxReadOnlyModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = CableTermination.objects.all()
     serializer_class = serializers.CableTerminationSerializer

+ 2 - 2
netbox/dcim/apps.py

@@ -11,7 +11,7 @@ class DCIMConfig(AppConfig):
         from netbox.models.features import register_models
         from utilities.counters import connect_counters
         from . import signals, search  # noqa: F401
-        from .models import CableTermination, Device, DeviceType, VirtualChassis
+        from .models import CableTermination, Device, DeviceType, ModuleType, RackType, VirtualChassis
 
         # Register models
         register_models(*self.get_models())
@@ -31,4 +31,4 @@ class DCIMConfig(AppConfig):
         })
 
         # Register counters
-        connect_counters(Device, DeviceType, VirtualChassis)
+        connect_counters(Device, DeviceType, ModuleType, RackType, VirtualChassis)

+ 390 - 0
netbox/dcim/cable_profiles.py

@@ -0,0 +1,390 @@
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext_lazy as _
+
+from dcim.choices import CableEndChoices
+from dcim.models import CableTermination
+
+
+class BaseCableProfile:
+    """Base class for representing a cable profile."""
+
+    # Mappings of connectors to the number of positions presented by each, at either end of the cable. For example, a
+    # 12-strand MPO fiber cable would have one connector at either end with six positions (six bidirectional fiber
+    # pairs).
+    a_connectors = {}
+    b_connectors = {}
+
+    # Defined a mapping of A/B connector & position pairings. If not defined, all positions are presumed to be
+    # symmetrical (i.e. 1:1 on side A maps to 1:1 on side B). If defined, it must be constructed as a dictionary of
+    # two-item tuples, e.g. {(1, 1): (1, 1)}.
+    _mapping = None
+
+    def clean(self, cable):
+        # Enforce maximum terminations limits
+        a_terminations_count = len(cable.a_terminations)
+        b_terminations_count = len(cable.b_terminations)
+        max_a_terminations = len(self.a_connectors)
+        max_b_terminations = len(self.b_connectors)
+        if a_terminations_count > max_a_terminations:
+            raise ValidationError({
+                'a_terminations': _(
+                    'A side of cable has {count} terminations but only {max} are permitted for profile {profile}'
+                ).format(
+                    count=a_terminations_count,
+                    profile=cable.get_profile_display(),
+                    max=max_a_terminations,
+                )
+            })
+        if b_terminations_count > max_b_terminations:
+            raise ValidationError({
+                'b_terminations': _(
+                    'B side of cable has {count} terminations but only {max} are permitted for profile {profile}'
+                ).format(
+                    count=b_terminations_count,
+                    profile=cable.get_profile_display(),
+                    max=max_b_terminations,
+                )
+            })
+
+    def get_mapped_position(self, side, connector, position):
+        """
+        Return the mapped far-end connector & position for a given cable end the local connector & position.
+        """
+        # By default, assume all positions are symmetrical.
+        if self._mapping:
+            return self._mapping.get((connector, position))
+        return connector, position
+
+    def get_peer_termination(self, termination, position):
+        """
+        Given a terminating object, return the peer terminating object (if any) on the opposite end of the cable.
+        """
+        try:
+            connector, position = self.get_mapped_position(
+                termination.cable_end,
+                termination.cable_connector,
+                position
+            )
+        except TypeError:
+            raise ValueError(
+                f"Could not map connector {termination.cable_connector} position {position} on side "
+                f"{termination.cable_end}"
+            )
+        try:
+            ct = CableTermination.objects.get(
+                cable=termination.cable,
+                cable_end=termination.opposite_cable_end,
+                connector=connector,
+                positions__contains=[position],
+            )
+            return ct.termination, position
+        except CableTermination.DoesNotExist:
+            return None, None
+
+    @staticmethod
+    def get_position_list(n):
+        """Return a list of integers from 1 to n, inclusive."""
+        return list(range(1, n + 1))
+
+
+# Profile naming:
+#  - Single: One connector per side, with one or more positions
+#  - Trunk: Two or more connectors per side, with one or more positions per connector
+#  - Breakout: One or more connectors on the A side which map to a greater number of B side connectors
+#  - Shuffle: A cable with nonlinear position mappings between sides
+
+class Single1C1PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 1,
+    }
+    b_connectors = a_connectors
+
+
+class Single1C2PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 2,
+    }
+    b_connectors = a_connectors
+
+
+class Single1C4PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+    }
+    b_connectors = a_connectors
+
+
+class Single1C6PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 6,
+    }
+    b_connectors = a_connectors
+
+
+class Single1C8PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 8,
+    }
+    b_connectors = a_connectors
+
+
+class Single1C12PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 12,
+    }
+    b_connectors = a_connectors
+
+
+class Single1C16PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 16,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk2C1PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 1,
+        2: 1,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk2C2PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 2,
+        2: 2,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk2C4PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+        2: 4,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk2C6PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 6,
+        2: 6,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk2C8PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 8,
+        2: 8,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk2C12PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 12,
+        2: 12,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk4C1PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 1,
+        2: 1,
+        3: 1,
+        4: 1,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk4C2PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 2,
+        2: 2,
+        3: 2,
+        4: 2,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk4C4PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+        2: 4,
+        3: 4,
+        4: 4,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk4C6PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 6,
+        2: 6,
+        3: 6,
+        4: 6,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk4C8PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 8,
+        2: 8,
+        3: 8,
+        4: 8,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk8C4PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+        2: 4,
+        3: 4,
+        4: 4,
+        5: 4,
+        6: 4,
+        7: 4,
+        8: 4,
+    }
+    b_connectors = a_connectors
+
+
+class Breakout1C4Px4C1PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+    }
+    b_connectors = {
+        1: 1,
+        2: 1,
+        3: 1,
+        4: 1,
+    }
+    _mapping = {
+        (1, 1): (1, 1),
+        (1, 2): (2, 1),
+        (1, 3): (3, 1),
+        (1, 4): (4, 1),
+        (2, 1): (1, 2),
+        (3, 1): (1, 3),
+        (4, 1): (1, 4),
+    }
+
+
+class Breakout1C6Px6C1PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 6,
+    }
+    b_connectors = {
+        1: 1,
+        2: 1,
+        3: 1,
+        4: 1,
+        5: 1,
+        6: 1,
+    }
+    _mapping = {
+        (1, 1): (1, 1),
+        (1, 2): (2, 1),
+        (1, 3): (3, 1),
+        (1, 4): (4, 1),
+        (1, 5): (5, 1),
+        (1, 6): (6, 1),
+        (2, 1): (1, 2),
+        (3, 1): (1, 3),
+        (4, 1): (1, 4),
+        (5, 1): (1, 5),
+        (6, 1): (1, 6),
+    }
+
+
+class Trunk2C4PShuffleCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+        2: 4,
+    }
+    b_connectors = a_connectors
+    _mapping = {
+        (1, 1): (1, 1),
+        (1, 2): (1, 2),
+        (1, 3): (2, 1),
+        (1, 4): (2, 2),
+        (2, 1): (1, 3),
+        (2, 2): (1, 4),
+        (2, 3): (2, 3),
+        (2, 4): (2, 4),
+    }
+
+
+class Trunk4C4PShuffleCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+        2: 4,
+        3: 4,
+        4: 4,
+    }
+    b_connectors = a_connectors
+    _mapping = {
+        (1, 1): (1, 1),
+        (1, 2): (2, 1),
+        (1, 3): (3, 1),
+        (1, 4): (4, 1),
+        (2, 1): (1, 2),
+        (2, 2): (2, 2),
+        (2, 3): (3, 2),
+        (2, 4): (4, 2),
+        (3, 1): (1, 3),
+        (3, 2): (2, 3),
+        (3, 3): (3, 3),
+        (3, 4): (4, 3),
+        (4, 1): (1, 4),
+        (4, 2): (2, 4),
+        (4, 3): (3, 4),
+        (4, 4): (4, 4),
+    }
+
+
+class Breakout2C4Px8C1PShuffleCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+        2: 4,
+    }
+    b_connectors = {
+        1: 1,
+        2: 1,
+        3: 1,
+        4: 1,
+        5: 1,
+        6: 1,
+        7: 1,
+        8: 1,
+    }
+    _a_mapping = {
+        (1, 1): (1, 1),
+        (1, 2): (2, 1),
+        (1, 3): (5, 1),
+        (1, 4): (6, 1),
+        (2, 1): (3, 1),
+        (2, 2): (4, 1),
+        (2, 3): (7, 1),
+        (2, 4): (8, 1),
+    }
+    _b_mapping = {
+        (1, 1): (1, 1),
+        (2, 1): (1, 2),
+        (3, 1): (2, 1),
+        (4, 1): (2, 2),
+        (5, 1): (1, 3),
+        (6, 1): (1, 4),
+        (7, 1): (2, 3),
+        (8, 1): (2, 4),
+    }
+
+    def get_mapped_position(self, side, connector, position):
+        if side.upper() == CableEndChoices.SIDE_A:
+            return self._a_mapping.get((connector, position))
+        return self._b_mapping.get((connector, position))

+ 71 - 0
netbox/dcim/choices.py

@@ -1721,6 +1721,77 @@ class PortTypeChoices(ChoiceSet):
 # Cables/links
 #
 
+class CableProfileChoices(ChoiceSet):
+    # Singles
+    SINGLE_1C1P = 'single-1c1p'
+    SINGLE_1C2P = 'single-1c2p'
+    SINGLE_1C4P = 'single-1c4p'
+    SINGLE_1C6P = 'single-1c6p'
+    SINGLE_1C8P = 'single-1c8p'
+    SINGLE_1C12P = 'single-1c12p'
+    SINGLE_1C16P = 'single-1c16p'
+    # Trunks
+    TRUNK_2C1P = 'trunk-2c1p'
+    TRUNK_2C2P = 'trunk-2c2p'
+    TRUNK_2C4P = 'trunk-2c4p'
+    TRUNK_2C4P_SHUFFLE = 'trunk-2c4p-shuffle'
+    TRUNK_2C6P = 'trunk-2c6p'
+    TRUNK_2C8P = 'trunk-2c8p'
+    TRUNK_2C12P = 'trunk-2c12p'
+    TRUNK_4C1P = 'trunk-4c1p'
+    TRUNK_4C2P = 'trunk-4c2p'
+    TRUNK_4C4P = 'trunk-4c4p'
+    TRUNK_4C4P_SHUFFLE = 'trunk-4c4p-shuffle'
+    TRUNK_4C6P = 'trunk-4c6p'
+    TRUNK_4C8P = 'trunk-4c8p'
+    TRUNK_8C4P = 'trunk-8c4p'
+    # Breakouts
+    BREAKOUT_1C4P_4C1P = 'breakout-1c4p-4c1p'
+    BREAKOUT_1C6P_6C1P = 'breakout-1c6p-6c1p'
+    BREAKOUT_2C4P_8C1P_SHUFFLE = 'breakout-2c4p-8c1p-shuffle'
+
+    CHOICES = (
+        (
+            _('Single'),
+            (
+                (SINGLE_1C1P, _('1C1P')),
+                (SINGLE_1C2P, _('1C2P')),
+                (SINGLE_1C4P, _('1C4P')),
+                (SINGLE_1C6P, _('1C6P')),
+                (SINGLE_1C8P, _('1C8P')),
+                (SINGLE_1C12P, _('1C12P')),
+                (SINGLE_1C16P, _('1C16P')),
+            ),
+        ),
+        (
+            _('Trunk'),
+            (
+                (TRUNK_2C1P, _('2C1P trunk')),
+                (TRUNK_2C2P, _('2C2P trunk')),
+                (TRUNK_2C4P, _('2C4P trunk')),
+                (TRUNK_2C4P_SHUFFLE, _('2C4P trunk (shuffle)')),
+                (TRUNK_2C6P, _('2C6P trunk')),
+                (TRUNK_2C8P, _('2C8P trunk')),
+                (TRUNK_2C12P, _('2C12P trunk')),
+                (TRUNK_4C1P, _('4C1P trunk')),
+                (TRUNK_4C2P, _('4C2P trunk')),
+                (TRUNK_4C4P, _('4C4P trunk')),
+                (TRUNK_4C4P_SHUFFLE, _('4C4P trunk (shuffle)')),
+                (TRUNK_4C6P, _('4C6P trunk')),
+                (TRUNK_4C8P, _('4C8P trunk')),
+                (TRUNK_8C4P, _('8C4P trunk')),
+            ),
+        ),
+        (
+            _('Breakout'),
+            (
+                (BREAKOUT_1C4P_4C1P, _('1C4P:4C1P breakout')),
+                (BREAKOUT_1C6P_6C1P, _('1C6P:6C1P breakout')),
+                (BREAKOUT_2C4P_8C1P_SHUFFLE, _('2C4P:8C1P breakout (shuffle)')),
+            ),
+        ),
+    )
+
 
 class CableTypeChoices(ChoiceSet):
     # Copper - Twisted Pair (UTP/STP)

+ 13 - 2
netbox/dcim/constants.py

@@ -20,12 +20,23 @@ RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
 RACK_STARTING_UNIT_DEFAULT = 1
 
 
+#
+# Cables
+#
+
+CABLE_CONNECTOR_MIN = 1
+CABLE_CONNECTOR_MAX = 256
+
+CABLE_POSITION_MIN = 1
+CABLE_POSITION_MAX = 1024
+
+
 #
 # RearPorts
 #
 
-REARPORT_POSITIONS_MIN = 1
-REARPORT_POSITIONS_MAX = 1024
+PORT_POSITION_MIN = 1
+PORT_POSITION_MAX = 1024
 
 
 #

+ 123 - 68
netbox/dcim/filtersets.py

@@ -11,16 +11,18 @@ from ipam.filtersets import PrimaryIPFilterSet
 from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF
 from netbox.choices import ColorChoices
 from netbox.filtersets import (
-    AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
-    OrganizationalModelFilterSet,
+    AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet,
+    OrganizationalModelFilterSet, PrimaryModelFilterSet, NetBoxModelFilterSet,
 )
 from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
 from tenancy.models import *
+from users.filterset_mixins import OwnerFilterMixin
 from users.models import User
 from utilities.filters import (
     ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
     NumericArrayFilter, TreeNodeMultipleChoiceFilter,
 )
+from utilities.filtersets import register_filterset
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
 from vpn.models import L2VPN
 from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
@@ -83,6 +85,7 @@ __all__ = (
 )
 
 
+@register_filterset
 class RegionFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Region.objects.all(),
@@ -113,6 +116,7 @@ class RegionFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
         fields = ('id', 'name', 'slug', 'description')
 
 
+@register_filterset
 class SiteGroupFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
@@ -143,7 +147,8 @@ class SiteGroupFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
         fields = ('id', 'name', 'slug', 'description')
 
 
-class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
+@register_filterset
+class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     status = django_filters.MultipleChoiceFilter(
         choices=SiteStatusChoices,
         null_value=None
@@ -207,6 +212,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
         return queryset.filter(qs_filter).distinct()
 
 
+@register_filterset
 class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupModelFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
@@ -286,6 +292,7 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
         return queryset
 
 
+@register_filterset
 class RackRoleFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
@@ -293,7 +300,8 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
         fields = ('id', 'name', 'slug', 'color', 'description')
 
 
-class RackTypeFilterSet(NetBoxModelFilterSet):
+@register_filterset
+class RackTypeFilterSet(PrimaryModelFilterSet):
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Manufacturer.objects.all(),
         label=_('Manufacturer (ID)'),
@@ -316,6 +324,9 @@ class RackTypeFilterSet(NetBoxModelFilterSet):
         fields = (
             'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height',
             'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
+
+            # Counters
+            'rack_count',
         )
 
     def search(self, queryset, name, value):
@@ -328,7 +339,8 @@ class RackTypeFilterSet(NetBoxModelFilterSet):
         )
 
 
-class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
+@register_filterset
+class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         field_name='site__region',
@@ -444,7 +456,8 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
         )
 
 
-class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+@register_filterset
+class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     rack_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Rack.objects.all(),
         label=_('Rack (ID)'),
@@ -533,6 +546,7 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         )
 
 
+@register_filterset
 class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
 
     class Meta:
@@ -540,7 +554,8 @@ class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet)
         fields = ('id', 'name', 'slug', 'description')
 
 
-class DeviceTypeFilterSet(NetBoxModelFilterSet):
+@register_filterset
+class DeviceTypeFilterSet(PrimaryModelFilterSet):
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Manufacturer.objects.all(),
         label=_('Manufacturer (ID)'),
@@ -626,6 +641,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
             'device_bay_template_count',
             'module_bay_template_count',
             'inventory_item_template_count',
+            'device_count',
         )
 
     def search(self, queryset, name, value):
@@ -682,7 +698,8 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
         return queryset.exclude(inventoryitemtemplates__isnull=value)
 
 
-class ModuleTypeProfileFilterSet(NetBoxModelFilterSet):
+@register_filterset
+class ModuleTypeProfileFilterSet(PrimaryModelFilterSet):
 
     class Meta:
         model = ModuleTypeProfile
@@ -698,7 +715,8 @@ class ModuleTypeProfileFilterSet(NetBoxModelFilterSet):
         )
 
 
-class ModuleTypeFilterSet(AttributeFiltersMixin, NetBoxModelFilterSet):
+@register_filterset
+class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
     profile_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ModuleTypeProfile.objects.all(),
         label=_('Profile (ID)'),
@@ -746,7 +764,12 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, NetBoxModelFilterSet):
 
     class Meta:
         model = ModuleType
-        fields = ('id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description')
+        fields = (
+            'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
+
+            # Counters
+            'module_count',
+        )
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -809,6 +832,7 @@ class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet):
     )
 
 
+@register_filterset
 class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 
     class Meta:
@@ -816,6 +840,7 @@ class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
         fields = ('id', 'name', 'label', 'type', 'description')
 
 
+@register_filterset
 class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 
     class Meta:
@@ -823,6 +848,7 @@ class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDevi
         fields = ('id', 'name', 'label', 'type', 'description')
 
 
+@register_filterset
 class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 
     class Meta:
@@ -830,6 +856,7 @@ class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
         fields = ('id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
 
 
+@register_filterset
 class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
     feed_leg = django_filters.MultipleChoiceFilter(
         choices=PowerOutletFeedLegChoices,
@@ -842,9 +869,10 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
 
     class Meta:
         model = PowerOutletTemplate
-        fields = ('id', 'name', 'label', 'type', 'feed_leg', 'description')
+        fields = ('id', 'name', 'label', 'type', 'color', 'feed_leg', 'description')
 
 
+@register_filterset
 class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=InterfaceTypeChoices,
@@ -869,31 +897,43 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
         fields = ('id', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description')
 
 
+@register_filterset
 class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=PortTypeChoices,
         null_value=None
     )
     rear_port_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=RearPortTemplate.objects.all()
+        field_name='mappings__rear_port',
+        queryset=RearPortTemplate.objects.all(),
+        to_field_name='rear_port',
+        label=_('Rear port (ID)'),
     )
 
     class Meta:
         model = FrontPortTemplate
-        fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description')
+        fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description')
 
 
+@register_filterset
 class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=PortTypeChoices,
         null_value=None
     )
+    front_port_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='mappings__front_port',
+        queryset=FrontPort.objects.all(),
+        to_field_name='front_port',
+        label=_('Front port (ID)'),
+    )
 
     class Meta:
         model = RearPortTemplate
         fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description')
 
 
+@register_filterset
 class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 
     class Meta:
@@ -901,6 +941,7 @@ class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
         fields = ('id', 'name', 'label', 'position', 'description')
 
 
+@register_filterset
 class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
 
     class Meta:
@@ -908,6 +949,7 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
         fields = ('id', 'name', 'label', 'description')
 
 
+@register_filterset
 class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=InventoryItemTemplate.objects.all(),
@@ -951,7 +993,8 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo
         return queryset.filter(qs_filter)
 
 
-class DeviceRoleFilterSet(OrganizationalModelFilterSet):
+@register_filterset
+class DeviceRoleFilterSet(NestedGroupModelFilterSet):
     config_template_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ConfigTemplate.objects.all(),
         label=_('Config template (ID)'),
@@ -985,7 +1028,8 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
         fields = ('id', 'name', 'slug', 'color', 'vm_role', 'description')
 
 
-class PlatformFilterSet(OrganizationalModelFilterSet):
+@register_filterset
+class PlatformFilterSet(NestedGroupModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Platform.objects.all(),
         label=_('Immediate parent platform (ID)'),
@@ -1042,8 +1086,9 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
         return queryset.filter(Q(manufacturer=None) | Q(manufacturer__device_types=value))
 
 
+@register_filterset
 class DeviceFilterSet(
-    NetBoxModelFilterSet,
+    PrimaryModelFilterSet,
     TenancyFilterSet,
     ContactModelFilterSet,
     LocalConfigContextFilterSet,
@@ -1344,7 +1389,8 @@ class DeviceFilterSet(
         return queryset.exclude(params)
 
 
-class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
+@register_filterset
+class VirtualDeviceContextFilterSet(PrimaryModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
     device_id = django_filters.ModelMultipleChoiceFilter(
         field_name='device',
         queryset=Device.objects.all(),
@@ -1393,7 +1439,8 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, Prim
         return queryset.exclude(params)
 
 
-class ModuleFilterSet(NetBoxModelFilterSet):
+@register_filterset
+class ModuleFilterSet(PrimaryModelFilterSet):
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         field_name='module_type__manufacturer',
         queryset=Manufacturer.objects.all(),
@@ -1515,7 +1562,7 @@ class ModuleFilterSet(NetBoxModelFilterSet):
         ).distinct()
 
 
-class DeviceComponentFilterSet(django_filters.FilterSet):
+class DeviceComponentFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label=_('Search'),
@@ -1692,12 +1739,8 @@ class PathEndpointFilterSet(django_filters.FilterSet):
             return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False))
 
 
-class ConsolePortFilterSet(
-    ModularDeviceComponentFilterSet,
-    NetBoxModelFilterSet,
-    CabledObjectFilterSet,
-    PathEndpointFilterSet
-):
+@register_filterset
+class ConsolePortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=ConsolePortTypeChoices,
         null_value=None
@@ -1705,15 +1748,13 @@ class ConsolePortFilterSet(
 
     class Meta:
         model = ConsolePort
-        fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end')
+        fields = (
+            'id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_connector',
+        )
 
 
-class ConsoleServerPortFilterSet(
-    ModularDeviceComponentFilterSet,
-    NetBoxModelFilterSet,
-    CabledObjectFilterSet,
-    PathEndpointFilterSet
-):
+@register_filterset
+class ConsoleServerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=ConsolePortTypeChoices,
         null_value=None
@@ -1721,15 +1762,13 @@ class ConsoleServerPortFilterSet(
 
     class Meta:
         model = ConsoleServerPort
-        fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end')
+        fields = (
+            'id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_connector',
+        )
 
 
-class PowerPortFilterSet(
-    ModularDeviceComponentFilterSet,
-    NetBoxModelFilterSet,
-    CabledObjectFilterSet,
-    PathEndpointFilterSet
-):
+@register_filterset
+class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=PowerPortTypeChoices,
         null_value=None
@@ -1739,15 +1778,12 @@ class PowerPortFilterSet(
         model = PowerPort
         fields = (
             'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end',
+            'cable_connector',
         )
 
 
-class PowerOutletFilterSet(
-    ModularDeviceComponentFilterSet,
-    NetBoxModelFilterSet,
-    CabledObjectFilterSet,
-    PathEndpointFilterSet
-):
+@register_filterset
+class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=PowerOutletTypeChoices,
         null_value=None
@@ -1769,10 +1805,12 @@ class PowerOutletFilterSet(
         model = PowerOutlet
         fields = (
             'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end',
+            'cable_connector',
         )
 
 
-class MACAddressFilterSet(NetBoxModelFilterSet):
+@register_filterset
+class MACAddressFilterSet(PrimaryModelFilterSet):
     mac_address = MultiValueMACAddressFilter()
     assigned_object_type = ContentTypeFilter()
     device = MultiValueCharFilter(
@@ -1953,9 +1991,9 @@ class CommonInterfaceFilterSet(django_filters.FilterSet):
         )
 
 
+@register_filterset
 class InterfaceFilterSet(
     ModularDeviceComponentFilterSet,
-    NetBoxModelFilterSet,
     CabledObjectFilterSet,
     PathEndpointFilterSet,
     CommonInterfaceFilterSet
@@ -2077,7 +2115,7 @@ class InterfaceFilterSet(
         fields = (
             'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
             'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected',
-            'cable_id', 'cable_end',
+            'cable_id', 'cable_end', 'cable_connector',
         )
 
     def filter_virtual_chassis_member_or_master(self, queryset, name, value):
@@ -2116,44 +2154,50 @@ class InterfaceFilterSet(
             )
 
 
-class FrontPortFilterSet(
-    ModularDeviceComponentFilterSet,
-    NetBoxModelFilterSet,
-    CabledObjectFilterSet
-):
+@register_filterset
+class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=PortTypeChoices,
         null_value=None
     )
     rear_port_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=RearPort.objects.all()
+        field_name='mappings__rear_port',
+        queryset=RearPort.objects.all(),
+        to_field_name='rear_port',
+        label=_('Rear port (ID)'),
     )
 
     class Meta:
         model = FrontPort
         fields = (
-            'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end',
+            'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end',
+            'cable_connector',
         )
 
 
-class RearPortFilterSet(
-    ModularDeviceComponentFilterSet,
-    NetBoxModelFilterSet,
-    CabledObjectFilterSet
-):
+@register_filterset
+class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=PortTypeChoices,
         null_value=None
     )
+    front_port_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='mappings__front_port',
+        queryset=FrontPort.objects.all(),
+        to_field_name='front_port',
+        label=_('Front port (ID)'),
+    )
 
     class Meta:
         model = RearPort
         fields = (
             'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end',
+            'cable_connector',
         )
 
 
-class ModuleBayFilterSet(ModularDeviceComponentFilterSet, NetBoxModelFilterSet):
+@register_filterset
+class ModuleBayFilterSet(ModularDeviceComponentFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ModuleBay.objects.all(),
         label=_('Parent module bay (ID)'),
@@ -2169,7 +2213,8 @@ class ModuleBayFilterSet(ModularDeviceComponentFilterSet, NetBoxModelFilterSet):
         fields = ('id', 'name', 'label', 'position', 'description')
 
 
-class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
+@register_filterset
+class DeviceBayFilterSet(DeviceComponentFilterSet):
     installed_device_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Device.objects.all(),
         label=_('Installed device (ID)'),
@@ -2186,7 +2231,8 @@ class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
         fields = ('id', 'name', 'label', 'description')
 
 
-class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
+@register_filterset
+class InventoryItemFilterSet(DeviceComponentFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=InventoryItem.objects.all(),
         label=_('Parent inventory item (ID)'),
@@ -2238,6 +2284,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
         return queryset.filter(qs_filter)
 
 
+@register_filterset
 class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
@@ -2245,7 +2292,8 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
         fields = ('id', 'name', 'slug', 'color', 'description')
 
 
-class VirtualChassisFilterSet(NetBoxModelFilterSet):
+@register_filterset
+class VirtualChassisFilterSet(PrimaryModelFilterSet):
     master_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Device.objects.all(),
         label=_('Master (ID)'),
@@ -2321,7 +2369,8 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet):
         return queryset.filter(qs_filter).distinct()
 
 
-class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
+@register_filterset
+class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
     termination_a_type = ContentTypeFilter(
         field_name='terminations__termination_type'
     )
@@ -2346,6 +2395,9 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
     status = django_filters.MultipleChoiceFilter(
         choices=LinkStatusChoices
     )
+    profile = django_filters.MultipleChoiceFilter(
+        choices=CableProfileChoices
+    )
     color = django_filters.MultipleChoiceFilter(
         choices=ColorChoices
     )
@@ -2490,6 +2542,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         return self.filter_by_termination_object(queryset, CircuitTermination, value)
 
 
+@register_filterset
 class CableTerminationFilterSet(ChangeLoggedModelFilterSet):
     termination_type = ContentTypeFilter()
 
@@ -2498,7 +2551,8 @@ class CableTerminationFilterSet(ChangeLoggedModelFilterSet):
         fields = ('id', 'cable', 'cable_end', 'termination_type', 'termination_id')
 
 
-class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
+@register_filterset
+class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         field_name='site__region',
@@ -2556,7 +2610,8 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
         return queryset.filter(qs_filter)
 
 
-class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet, TenancyFilterSet):
+@register_filterset
+class PowerFeedFilterSet(PrimaryModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet, TenancyFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         field_name='power_panel__site__region',
@@ -2612,7 +2667,7 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi
         model = PowerFeed
         fields = (
             'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization',
-            'available_power', 'mark_connected', 'cable_end', 'description',
+            'available_power', 'mark_connected', 'cable_end', 'cable_connector', 'description',
         )
 
     def search(self, queryset, name, value):

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio