Arthur 1 هفته پیش
والد
کامیت
8aab80b56f
100فایلهای تغییر یافته به همراه3341 افزوده شده و 1204 حذف شده
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 1 1
      .github/ISSUE_TEMPLATE/03-performance.yaml
  4. 1 1
      base_requirements.txt
  5. 924 1
      contrib/openapi.json
  6. 23 11
      docs/administration/netbox-shell.md
  7. 12 3
      docs/configuration/data-validation.md
  8. 1 1
      docs/configuration/index.md
  9. 1 1
      docs/development/release-checklist.md
  10. 4 4
      docs/installation/1-postgresql.md
  11. 14 14
      docs/installation/3-netbox.md
  12. 14 8
      docs/installation/4a-gunicorn.md
  13. 1 1
      docs/installation/5-http-server.md
  14. 2 2
      docs/installation/index.md
  15. 4 4
      docs/installation/upgrading.md
  16. 49 5
      docs/integrations/graphql-api.md
  17. 46 4
      docs/integrations/rest-api.md
  18. 48 0
      docs/release-notes/version-4.5.md
  19. 16 8
      netbox/circuits/forms/filtersets.py
  20. 2 1
      netbox/core/api/serializers_/jobs.py
  21. 16 1
      netbox/core/data_backends.py
  22. 5 1
      netbox/core/filtersets.py
  23. 7 2
      netbox/core/forms/filtersets.py
  24. 18 0
      netbox/core/migrations/0021_job_queue_name.py
  25. 14 3
      netbox/core/models/jobs.py
  26. 11 0
      netbox/core/models/object_types.py
  27. 2 1
      netbox/core/signals.py
  28. 4 1
      netbox/core/tables/jobs.py
  29. 116 0
      netbox/core/tests/test_data_backends.py
  30. 38 1
      netbox/core/tests/test_models.py
  31. 78 48
      netbox/dcim/forms/filtersets.py
  32. 1 1
      netbox/dcim/forms/mixins.py
  33. 2 1
      netbox/dcim/forms/object_create.py
  34. 5 1
      netbox/dcim/graphql/filters.py
  35. 1 1
      netbox/dcim/graphql/types.py
  36. 6 1
      netbox/dcim/models/modules.py
  37. 2 2
      netbox/dcim/models/racks.py
  38. 28 0
      netbox/dcim/tables/devices.py
  39. 18 0
      netbox/dcim/tables/template_code.py
  40. 136 0
      netbox/dcim/tests/test_models.py
  41. 3 3
      netbox/dcim/utils.py
  42. 10 0
      netbox/dcim/views.py
  43. 3 9
      netbox/extras/api/customfields.py
  44. 2 1
      netbox/extras/dashboard/widgets.py
  45. 72 56
      netbox/extras/events.py
  46. 33 69
      netbox/extras/forms/filtersets.py
  47. 7 0
      netbox/extras/forms/model_forms.py
  48. 14 1
      netbox/extras/models/customfields.py
  49. 1 1
      netbox/extras/models/scripts.py
  50. 1 1
      netbox/extras/scripts.py
  51. 9 12
      netbox/extras/signals.py
  52. 41 5
      netbox/extras/tests/test_views.py
  53. 7 1
      netbox/extras/views.py
  54. 33 17
      netbox/ipam/forms/filtersets.py
  55. 3 1
      netbox/ipam/models/services.py
  56. 5 0
      netbox/ipam/tables/ip.py
  57. 1 1
      netbox/ipam/tables/template_code.py
  58. 18 2
      netbox/ipam/tables/vlans.py
  59. 41 0
      netbox/ipam/tests/test_tables.py
  60. 3 0
      netbox/ipam/utils.py
  61. 2 1
      netbox/netbox/api/fields.py
  62. 21 16
      netbox/netbox/api/serializers/base.py
  63. 54 16
      netbox/netbox/api/viewsets/__init__.py
  64. 9 5
      netbox/netbox/api/viewsets/mixins.py
  65. 2 0
      netbox/netbox/context.py
  66. 4 1
      netbox/netbox/context_managers.py
  67. 6 11
      netbox/netbox/filtersets.py
  68. 5 4
      netbox/netbox/forms/bulk_import.py
  69. 9 16
      netbox/netbox/forms/filtersets.py
  70. 62 7
      netbox/netbox/forms/mixins.py
  71. 50 0
      netbox/netbox/graphql/pagination.py
  72. 16 5
      netbox/netbox/models/features.py
  73. 48 87
      netbox/netbox/navigation/menu.py
  74. 6 4
      netbox/netbox/search/backends.py
  75. 9 0
      netbox/netbox/settings.py
  76. 26 8
      netbox/netbox/tables/tables.py
  77. 143 20
      netbox/netbox/tests/test_graphql.py
  78. 61 1
      netbox/netbox/tests/test_model_features.py
  79. 5 7
      netbox/netbox/views/generic/bulk_views.py
  80. 11 1
      netbox/netbox/views/misc.py
  81. 6 6
      netbox/project-static/package.json
  82. 93 82
      netbox/project-static/yarn.lock
  83. 2 2
      netbox/release.yaml
  84. 1 1
      netbox/templates/base/layout.html
  85. 4 0
      netbox/templates/core/job.html
  86. 2 1
      netbox/templates/dcim/device_edit.html
  87. 2 1
      netbox/templates/dcim/htmx/cable_edit.html
  88. 7 27
      netbox/templates/dcim/interface.html
  89. 2 1
      netbox/templates/dcim/virtualchassis_edit.html
  90. 1 0
      netbox/templates/extras/inc/script_list_content.html
  91. 4 1
      netbox/templates/generic/bulk_edit.html
  92. 3 0
      netbox/templates/htmx/form.html
  93. 1 1
      netbox/templates/ipam/prefix/prefixes.html
  94. 2 1
      netbox/templates/ipam/vlan_edit.html
  95. 10 5
      netbox/tenancy/forms/filtersets.py
  96. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  97. 336 274
      netbox/translations/cs/LC_MESSAGES/django.po
  98. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  99. 336 274
      netbox/translations/da/LC_MESSAGES/django.po
  100. BIN
      netbox/translations/de/LC_MESSAGES/django.mo

+ 1 - 1
.github/ISSUE_TEMPLATE/01-feature_request.yaml

@@ -15,7 +15,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.5.1
+      placeholder: v4.5.2
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

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

@@ -27,7 +27,7 @@ body:
     attributes:
     attributes:
       label: NetBox Version
       label: NetBox Version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.5.1
+      placeholder: v4.5.2
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/03-performance.yaml

@@ -8,7 +8,7 @@ body:
     attributes:
     attributes:
       label: NetBox Version
       label: NetBox Version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.5.1
+      placeholder: v4.5.2
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
base_requirements.txt

@@ -85,7 +85,7 @@ drf-spectacular-sidecar
 feedparser
 feedparser
 
 
 # WSGI HTTP server
 # WSGI HTTP server
-# https://docs.gunicorn.org/en/latest/news.html
+# https://gunicorn.org/news/
 gunicorn
 gunicorn
 
 
 # Platform-agnostic template rendering engine
 # Platform-agnostic template rendering engine

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 924 - 1
contrib/openapi.json


+ 23 - 11
docs/administration/netbox-shell.md

@@ -3,31 +3,43 @@
 NetBox includes a Python management shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
 NetBox includes a Python management shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
 
 
 ```
 ```
-./manage.py nbshell
+cd /opt/netbox
+source /opt/netbox/venv/bin/activate
+python3 netbox/manage.py nbshell
 ```
 ```
 
 
-This will launch a lightly customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
+This will launch a lightly customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models preloaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
 
 
 ```
 ```
-$ ./manage.py nbshell
+(venv) $ python3 netbox/manage.py nbshell
 ### NetBox interactive shell (localhost)
 ### NetBox interactive shell (localhost)
-### Python 3.7.10 | Django 3.2.5 | NetBox 3.0
-### lsmodels() will show available models. Use help(<model>) for more info.
+### Python v3.12.3 | Django v5.2.10 | NetBox Community v4.5.1
+### lsapps() & lsmodels() will show available models. Use help(<model>) for more info.
 ```
 ```
 
 
 The function `lsmodels()` will print a list of all available NetBox models:
 The function `lsmodels()` will print a list of all available NetBox models:
 
 
 ```
 ```
 >>> lsmodels()
 >>> lsmodels()
+  ...
 DCIM:
 DCIM:
-  ConsolePort
-  ConsolePortTemplate
-  ConsoleServerPort
-  ConsoleServerPortTemplate
-  Device
+  dcim.Cable
+  dcim.CableTermination
+  dcim.ConsolePort
+  dcim.ConsolePortTemplate
+  dcim.ConsoleServerPort
+  dcim.ConsoleServerPortTemplate
+  dcim.Device
   ...
   ...
 ```
 ```
 
 
+To exit the NetBox shell, type `exit()` or press `Ctrl+D`.
+
+```
+>>> exit()
+(venv) $
+```
+
 !!! warning
 !!! warning
     The NetBox shell affords direct access to NetBox data and function with very little validation in place. As such, it is crucial to ensure that only authorized, knowledgeable users are ever granted access to it. Never perform any action in the management shell without having a full backup in place.
     The NetBox shell affords direct access to NetBox data and function with very little validation in place. As such, it is crucial to ensure that only authorized, knowledgeable users are ever granted access to it. Never perform any action in the management shell without having a full backup in place.
 
 
@@ -114,7 +126,7 @@ Reverse relationships can be traversed as well. For example, the following will
 >>> Device.objects.filter(interfaces__name="em0")
 >>> Device.objects.filter(interfaces__name="em0")
 ```
 ```
 
 
-Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the later of which is case-insensitive).
+Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the latter of which is case-insensitive).
 
 
 ```
 ```
 >>> Device.objects.filter(name__icontains="testdevice")
 >>> Device.objects.filter(name__icontains="testdevice")

+ 12 - 3
docs/configuration/data-validation.md

@@ -8,7 +8,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
 
 
 ```python
 ```python
 CUSTOM_VALIDATORS = {
 CUSTOM_VALIDATORS = {
-    "dcim.site": [
+    "dcim.Site": [
         {
         {
             "name": {
             "name": {
                 "min_length": 5,
                 "min_length": 5,
@@ -17,12 +17,15 @@ CUSTOM_VALIDATORS = {
         },
         },
         "my_plugin.validators.Validator1"
         "my_plugin.validators.Validator1"
     ],
     ],
-    "dcim.device": [
+    "dcim.Device": [
         "my_plugin.validators.Validator1"
         "my_plugin.validators.Validator1"
     ]
     ]
 }
 }
 ```
 ```
 
 
+!!! info "Case-Insensitive Model Names"
+    Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.
+
 ---
 ---
 
 
 ## FIELD_CHOICES
 ## FIELD_CHOICES
@@ -53,6 +56,9 @@ FIELD_CHOICES = {
 }
 }
 ```
 ```
 
 
+!!! info "Case-Insensitive Field Identifiers"
+    Field identifiers are case-insensitive. Both `dcim.Site.status` and `dcim.site.status` are valid and equivalent.
+
 The following model fields support configurable choices:
 The following model fields support configurable choices:
 
 
 * `circuits.Circuit.status`
 * `circuits.Circuit.status`
@@ -98,7 +104,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
 
 
 ```python
 ```python
 PROTECTION_RULES = {
 PROTECTION_RULES = {
-    "dcim.site": [
+    "dcim.Site": [
         {
         {
             "status": {
             "status": {
                 "eq": "decommissioning"
                 "eq": "decommissioning"
@@ -108,3 +114,6 @@ PROTECTION_RULES = {
     ]
     ]
 }
 }
 ```
 ```
+
+!!! info "Case-Insensitive Model Names"
+    Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.

+ 1 - 1
docs/configuration/index.md

@@ -15,7 +15,7 @@ Some configuration parameters may alternatively be defined either in `configurat
 
 
 ## Dynamic Configuration Parameters
 ## Dynamic Configuration Parameters
 
 
-Some configuration parameters are primarily controlled via NetBox's admin interface (under Admin > Extras > Configuration Revisions). These are noted where applicable in the documentation. These settings may also be overridden in `configuration.py` to prevent them from being modified via the UI. A complete list of supported parameters is provided below:
+Some configuration parameters are primarily controlled via NetBox's admin interface (under Admin > System > Configuration History). These are noted where applicable in the documentation. These settings may also be overridden in `configuration.py` to prevent them from being modified via the UI. A complete list of supported parameters is provided below:
 
 
 * [`ALLOWED_URL_SCHEMES`](./security.md#allowed_url_schemes)
 * [`ALLOWED_URL_SCHEMES`](./security.md#allowed_url_schemes)
 * [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom)
 * [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom)

+ 1 - 1
docs/development/release-checklist.md

@@ -144,7 +144,7 @@ Then, compile these portable (`.po`) files for use in the application:
 
 
 * Update the version number and published date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
 * Update the version number and published date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
 * Copy the version number from `release.yaml` to `pyproject.toml` in the project root.
 * Copy the version number from `release.yaml` to `pyproject.toml` in the project root.
-* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
+* Update the example version numbers in the feature request, bug report, and performance templates under `.github/ISSUE_TEMPLATES/`.
 * Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
 * Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
 
 
 !!! tip
 !!! tip

+ 4 - 4
docs/installation/1-postgresql.md

@@ -51,14 +51,14 @@ You can verify that authentication works by executing the `psql` command and pas
 
 
 ```no-highlight
 ```no-highlight
 $ psql --username netbox --password --host localhost netbox
 $ psql --username netbox --password --host localhost netbox
-Password for user netbox: 
-psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
-SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
+Password:
+psql (16.11 (Ubuntu 16.11-0ubuntu0.24.04.1))
+SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
 Type "help" for help.
 Type "help" for help.
 
 
 netbox=> \conninfo
 netbox=> \conninfo
 You are connected to database "netbox" as user "netbox" on host "localhost" (address "127.0.0.1") at port "5432".
 You are connected to database "netbox" as user "netbox" on host "localhost" (address "127.0.0.1") at port "5432".
-SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
+SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
 netbox=> \q
 netbox=> \q
 ```
 ```
 
 

+ 14 - 14
docs/installation/3-netbox.md

@@ -36,7 +36,7 @@ sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
 ```
 ```
 
 
 !!! note
 !!! note
-    It is recommended to install NetBox in a directory named for its version number. For example, NetBox v3.0.0 would be installed into `/opt/netbox-3.0.0`, and a symlink from `/opt/netbox/` would point to this location. (You can verify this configuration with the command `ls -l /opt | grep netbox`.) This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
+    It is recommended to install NetBox in a directory named for its version number. For example, NetBox v4.0.0 would be installed into `/opt/netbox-4.0.0`, and a symlink from `/opt/netbox/` would point to this location. (You can verify this configuration with the command `ls -l /opt | grep netbox`.) This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
 
 
 ### Option B: Clone the Git Repository
 ### Option B: Clone the Git Repository
 
 
@@ -63,12 +63,12 @@ This command should generate output similar to the following:
 
 
 ```
 ```
 Cloning into '.'...
 Cloning into '.'...
-remote: Enumerating objects: 996, done.
-remote: Counting objects: 100% (996/996), done.
-remote: Compressing objects: 100% (935/935), done.
-remote: Total 996 (delta 148), reused 386 (delta 34), pack-reused 0
-Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done.
-Resolving deltas: 100% (148/148), done.
+remote: Enumerating objects: 148317, done.
+remote: Counting objects: 100% (183/183), done.
+remote: Compressing objects: 100% (115/115), done.
+remote: Total 148317 (delta 127), reused 68 (delta 68), pack-reused 148134 (from 3)
+Receiving objects: 100% (148317/148317), 165.12 MiB | 28.71 MiB/s, done.
+Resolving deltas: 100% (116428/116428), done.
 ```
 ```
 
 
 Finally, check out the tag for the desired release. You can find these on our [releases page](https://github.com/netbox-community/netbox/releases). Replace `vX.Y.Z` with your selected release tag below.
 Finally, check out the tag for the desired release. You can find these on our [releases page](https://github.com/netbox-community/netbox/releases). Replace `vX.Y.Z` with your selected release tag below.
@@ -102,7 +102,8 @@ sudo cp configuration_example.py configuration.py
 Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations:
 Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations:
 
 
 * `ALLOWED_HOSTS`
 * `ALLOWED_HOSTS`
-* `DATABASES` (or `DATABASE`)
+* `API_TOKEN_PEPPERS`
+* `DATABASES`
 * `REDIS`
 * `REDIS`
 * `SECRET_KEY`
 * `SECRET_KEY`
 
 
@@ -158,7 +159,7 @@ DATABASES = {
 
 
 ### REDIS
 ### REDIS
 
 
-Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-parameters.md#redis) for more detail on individual parameters.
+Redis is an in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-parameters.md#redis) for more detail on individual parameters.
 
 
 Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID.
 Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID.
 
 
@@ -252,7 +253,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
 sudo /opt/netbox/upgrade.sh
 sudo /opt/netbox/upgrade.sh
 ```
 ```
 
 
-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.)
+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
 ```no-highlight
 sudo PYTHON=/usr/bin/python3.12 /opt/netbox/upgrade.sh
 sudo PYTHON=/usr/bin/python3.12 /opt/netbox/upgrade.sh
@@ -295,13 +296,12 @@ python3 manage.py runserver 0.0.0.0:8000 --insecure
 If successful, you should see output similar to the following:
 If successful, you should see output similar to the following:
 
 
 ```no-highlight
 ```no-highlight
-Watching for file changes with StatReloader
 Performing system checks...
 Performing system checks...
 
 
 System check identified no issues (0 silenced).
 System check identified no issues (0 silenced).
-August 30, 2021 - 18:02:23
-Django version 3.2.6, using settings 'netbox.settings'
-Starting development server at http://127.0.0.1:8000/
+January 26, 2026 - 17:00:00
+Django version 5.2.10, using settings 'netbox.settings'
+Starting development server at http://0.0.0.0:8000/
 Quit the server with CONTROL-C.
 Quit the server with CONTROL-C.
 ```
 ```
 
 

+ 14 - 8
docs/installation/4a-gunicorn.md

@@ -43,16 +43,22 @@ You should see output similar to the following:
 
 
 ```no-highlight
 ```no-highlight
 ● netbox.service - NetBox WSGI Service
 ● netbox.service - NetBox WSGI Service
-     Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
-     Active: active (running) since Mon 2021-08-30 04:02:36 UTC; 14h ago
+     Loaded: loaded (/etc/systemd/system/netbox.service; enabled; preset: enabled)
+     Active: active (running) since Mon 2026-01-26 11:00:00 CST; 7s ago
        Docs: https://docs.netbox.dev/
        Docs: https://docs.netbox.dev/
-   Main PID: 1140492 (gunicorn)
-      Tasks: 19 (limit: 4683)
-     Memory: 666.2M
+   Main PID: 7283 (gunicorn)
+      Tasks: 6 (limit: 4545)
+     Memory: 556.1M (peak: 556.3M)
+        CPU: 3.387s
      CGroup: /system.slice/netbox.service
      CGroup: /system.slice/netbox.service
-             ├─1140492 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
-             ├─1140513 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
-             ├─1140514 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
+             ├─7283 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
+             ├─7285 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
+             ├─7286 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
+             ├─7287 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
+             ├─7288 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
+             └─7289 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
+
+Jan 26 11:00:00 netbox systemd[1]: Started netbox.service - NetBox WSGI Service.
 ...
 ...
 ```
 ```
 
 

+ 1 - 1
docs/installation/5-http-server.md

@@ -3,7 +3,7 @@
 This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
 This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
 
 
 !!! info
 !!! info
-    For the sake of brevity, only Ubuntu 20.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.
+    For the sake of brevity, only Ubuntu 24.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.
 
 
 ## Obtain an SSL Certificate
 ## Obtain an SSL Certificate
 
 

+ 2 - 2
docs/installation/index.md

@@ -12,12 +12,12 @@
 
 
 </div>
 </div>
 
 
-The installation instructions provided here have been tested to work on Ubuntu 22.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
+The installation instructions provided here have been tested to work on Ubuntu 24.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
 
 
 The following sections detail how to set up a new instance of NetBox:
 The following sections detail how to set up a new instance of NetBox:
 
 
 1. [PostgreSQL database](1-postgresql.md)
 1. [PostgreSQL database](1-postgresql.md)
-1. [Redis](2-redis.md)
+2. [Redis](2-redis.md)
 3. [NetBox components](3-netbox.md)
 3. [NetBox components](3-netbox.md)
 4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md)
 4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md)
 5. [HTTP server](5-http-server.md)
 5. [HTTP server](5-http-server.md)

+ 4 - 4
docs/installation/upgrading.md

@@ -65,7 +65,7 @@ Download and extract the latest version:
 
 
 ```no-highlight
 ```no-highlight
 # Set $NEWVER to the NetBox version being installed
 # Set $NEWVER to the NetBox version being installed
-NEWVER=3.5.0
+NEWVER=4.5.0
 wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz
 wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz
 sudo tar -xzf v$NEWVER.tar.gz -C /opt
 sudo tar -xzf v$NEWVER.tar.gz -C /opt
 sudo ln -sfn /opt/netbox-$NEWVER/ /opt/netbox
 sudo ln -sfn /opt/netbox-$NEWVER/ /opt/netbox
@@ -75,7 +75,7 @@ Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if pres
 
 
 ```no-highlight
 ```no-highlight
 # Set $OLDVER to the NetBox version currently installed
 # Set $OLDVER to the NetBox version currently installed
-OLDVER=3.4.9
+OLDVER=4.4.10
 sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
 sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
 sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
 sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
 sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
 sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
@@ -116,7 +116,7 @@ Check out the desired release by specifying its tag. For example:
 ```
 ```
 cd /opt/netbox && \
 cd /opt/netbox && \
 sudo git fetch --tags && \
 sudo git fetch --tags && \
-sudo git checkout v4.2.7
+sudo git checkout v4.5.0
 ```
 ```
 
 
 ## 4. Run the Upgrade Script
 ## 4. Run the Upgrade Script
@@ -128,7 +128,7 @@ sudo ./upgrade.sh
 ```
 ```
 
 
 !!! warning
 !!! warning
-    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:
+    If the default version of Python is not **at least 3.12**, 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
     ```no-highlight
     sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh
     sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh

+ 49 - 5
docs/integrations/graphql-api.md

@@ -133,24 +133,68 @@ The field "class_type" is an easy way to distinguish what type of object it is w
 
 
 ## Pagination
 ## Pagination
 
 
-Queries can be paginated by specifying pagination in the query and supplying an offset and optionaly a limit in the query.  If no limit is given, a default of 100 is used.  Queries are not paginated unless requested in the query. An example paginated query is shown below:
+The GraphQL API supports two types of pagination. Offset-based pagination operates using an offset relative to the first record in a set, specified by the `offset` parameter. For example, the response to a request specifying an offset of 100 will contain the 101st and later matching records. Offset-based pagination feels very natural, but its performance can suffer when dealing with large data sets due to the overhead involved in calculating the relative offset.
+
+The alternative approach is cursor-based pagination, which operates using absolute (rather than relative) primary key values. (These are the numeric IDs assigned to each object in the database.) When using cursor-based pagination, the response will contain records with a primary key greater than or equal to the specified start value, up to the maximum number of results. This strategy requires keeping track of the last seen primary key from each response when paginating through data, but is extremely performant. The cursor is specified by passing the starting object ID via the `start` parameter.
+
+To ensure consistent ordering, objects will always be ordered by their primary keys when cursor-based pagination is used.
+
+!!! note "Cursor-based pagination was introduced in NetBox v4.5.2."
+
+Both pagination strategies support passing an optional `limit` parameter. In both approaches, this specifies the maximum number of objects to include in the response. If no limit is specified, a default value of 100 is used.
+
+### Offset Pagination
+
+The first page will have an `offset` of zero, or the `offset` parameter will be omitted:
 
 
 ```
 ```
 query {
 query {
-  device_list(pagination: { offset: 0, limit: 20 }) {
+  device_list(pagination: {offset: 0, limit: 20}) {
     id
     id
   }
   }
 }
 }
 ```
 ```
 
 
-## Authentication
+The second page will have an offset equal to the size of the first page. If the number of records is less than the specified limit, there are no more records to process. For example, if a request specifies a `limit` of 20 but returns only 13 records, we can conclude that this is the final page of records.
+
+```
+query {
+  device_list(pagination: {offset: 20, limit: 20}) {
+    id
+  }
+}
+```
+
+### Cursor Pagination
+
+Set the `start` value to zero to fetch the first page. Note that if the `start` parameter is omitted, offset-based pagination will be used by default.
 
 
-NetBox's GraphQL API uses the same API authentication tokens as its REST API. Authentication tokens are included with requests by attaching an `Authorization` HTTP header in the following form:
+```
+query {
+  device_list(pagination: {start: 0, limit: 20}) {
+    id
+  }
+}
+```
+
+To determine the `start` value for the next page, add 1 to the primary key (`id`) of the last record in the previous page.
+
+For example, if the ID of the last record in the previous response was 123, we would specify a `start` value of 124:
 
 
 ```
 ```
-Authorization: Token $TOKEN
+query {
+  device_list(pagination: {start: 124, limit: 20}) {
+    id
+  }
+}
 ```
 ```
 
 
+This will return up to 20 records with an ID greater than or equal to 124.
+
+## Authentication
+
+NetBox's GraphQL API uses the same API authentication tokens as its REST API. See the [REST API authentication](./rest-api.md#authentication) documentation for further detail.
+
 ## Disabling the GraphQL API
 ## Disabling the GraphQL API
 
 
 If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/graphql-api.md#graphql_enabled) configuration parameter to False and restarting NetBox.
 If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/graphql-api.md#graphql_enabled) configuration parameter to False and restarting NetBox.

+ 46 - 4
docs/integrations/rest-api.md

@@ -215,9 +215,51 @@ http://netbox/api/ipam/ip-addresses/ \
 
 
 If we wanted to assign this IP address to a virtual machine interface instead, we would have set `assigned_object_type` to `virtualization.vminterface` and updated the object ID appropriately.
 If we wanted to assign this IP address to a virtual machine interface instead, we would have set `assigned_object_type` to `virtualization.vminterface` and updated the object ID appropriately.
 
 
-### Brief Format
+### Specifying Fields
 
 
-Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of a prefix looks like this:
+A REST API response will include all available fields for the object type by default. If you wish to return only a subset of the available fields, you can append `?fields=` to the URL followed by a comma-separated list of field names. For example, the following request will return only the `id`, `name`, `status`, and `region` fields for each site in the response.
+
+```
+GET /api/dcim/sites/?fields=id,name,status,region
+```
+
+```json
+{
+    "id": 1,
+    "name": "DM-NYC",
+    "status": {
+        "value": "active",
+        "label": "Active"
+    },
+    "region": {
+        "id": 43,
+        "url": "http://netbox:8000/api/dcim/regions/43/",
+        "display": "New York",
+        "name": "New York",
+        "slug": "us-ny",
+        "description": "",
+        "site_count": 0,
+        "_depth": 2
+    }
+}
+```
+
+Similarly, you can opt to omit only specific fields by passing the `omit` parameter:
+
+```
+GET /api/dcim/sites/?omit=circuit_count,device_count,virtualmachine_count
+```
+
+!!! note "The `omit` parameter was introduced in NetBox v4.5.2."
+
+Strategic use of the `fields` and `omit` parameters can drastically improve REST API performance, as the exclusion of fields which reference related objects reduces the number and complexity of underlying database queries needed to generate the response.
+
+!!! note
+    The `fields` and `omit` parameters should be considered mutually exclusive. If both are passed, `fields` takes precedence.
+
+#### Brief Format
+
+Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. It's also more convenient than listing out individual fields via the `fields` or `omit` parameters. As an example, the default (complete) format of a prefix looks like this:
 
 
 ```no-highlight
 ```no-highlight
 GET /api/ipam/prefixes/13980/
 GET /api/ipam/prefixes/13980/
@@ -270,10 +312,10 @@ GET /api/ipam/prefixes/13980/
 }
 }
 ```
 ```
 
 
-The brief format is much more terse:
+The brief format includes only a few fields:
 
 
 ```no-highlight
 ```no-highlight
-GET /api/ipam/prefixes/13980/?brief=1
+GET /api/ipam/prefixes/13980/?brief=true
 ```
 ```
 
 
 ```json
 ```json

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

@@ -1,5 +1,53 @@
 # NetBox v4.5
 # NetBox v4.5
 
 
+## v4.5.2 (2026-02-03)
+
+### Enhancements
+
+* [#15801](https://github.com/netbox-community/netbox/issues/15801) - Add link peer and connection columns to the VLAN device interfaces table
+* [#19221](https://github.com/netbox-community/netbox/issues/19221) - Truncate long image attachment filenames in the UI
+* [#19869](https://github.com/netbox-community/netbox/issues/19869) - Display peer connections for LAG member interfaces
+* [#20052](https://github.com/netbox-community/netbox/issues/20052) - Increase logging level of error message when a custom script fails to load
+* [#20172](https://github.com/netbox-community/netbox/issues/20172) - Add `cabled` filter for interfaces in GraphQL API
+* [#21081](https://github.com/netbox-community/netbox/issues/21081) - Add owner group table columns & filters across all supported object list views
+* [#21088](https://github.com/netbox-community/netbox/issues/21088) - Add max depth and max length dropdowns for child prefix views
+* [#21110](https://github.com/netbox-community/netbox/issues/21110) - Support cursor-based pagination in GraphQL API
+* [#21201](https://github.com/netbox-community/netbox/issues/21201) - Pre-populate GenericForeignKey form fields when cloning
+* [#21209](https://github.com/netbox-community/netbox/issues/21209) - Ignore case sensitivity for configuration parameters which specify an app label and model name
+* [#21228](https://github.com/netbox-community/netbox/issues/21228) - Support image attachments for rack types
+* [#21244](https://github.com/netbox-community/netbox/issues/21244) - Enable omitting specific fields from REST API responses with `?omit=` parameter
+
+### Performance Improvements
+
+* [#21249](https://github.com/netbox-community/netbox/issues/21249) - Avoid extraneous user query when no event rules are present
+* [#21259](https://github.com/netbox-community/netbox/issues/21259) - Cache ObjectType lookups for the duration of a request
+* [#21260](https://github.com/netbox-community/netbox/issues/21260) - Defer object serialization for events pipeline processing
+* [#21263](https://github.com/netbox-community/netbox/issues/21263) - Prefetch related objects after creating/updating objects via REST API
+* [#21300](https://github.com/netbox-community/netbox/issues/21300) - Cache custom field lookups for the duration of a request
+* [#21302](https://github.com/netbox-community/netbox/issues/21302) - Avoid redundant uniqueness checks in ValidatedModelSerializer
+* [#21303](https://github.com/netbox-community/netbox/issues/21303) - Cache post-change snapshot on each instance after serialization
+* [#21327](https://github.com/netbox-community/netbox/issues/21327) - Always leverage `get_by_natural_key()` to resolve ContentTypes
+
+### Bug Fixes
+
+* [#20212](https://github.com/netbox-community/netbox/issues/20212) - Fix support for image attachment thumbnails when using S3 storage
+* [#20383](https://github.com/netbox-community/netbox/issues/20383) - When editing a device, clearing the assigned unit should also clear the rack face selection
+* [#20902](https://github.com/netbox-community/netbox/issues/20902) - Avoid `SyncError` exception when Git URL contains an embedded username
+* [#20977](https://github.com/netbox-community/netbox/issues/20977) - "Run again" button should respect script variable defaults
+* [#21115](https://github.com/netbox-community/netbox/issues/21115) - Include `attribute_data` in ModuleType YAML export
+* [#21129](https://github.com/netbox-community/netbox/issues/21129) - Store queue name on the Job model to ensure deletion of associated RQ task when a non-default queue is used
+* [#21168](https://github.com/netbox-community/netbox/issues/21168) - Fix Application Service cloning to preserve parent object
+* [#21173](https://github.com/netbox-community/netbox/issues/21173) - Ensure all plugin menu items are registered regardless of initialization order
+* [#21176](https://github.com/netbox-community/netbox/issues/21176) - Remove checkboxes from IP ranges in mixed-type tables
+* [#21202](https://github.com/netbox-community/netbox/issues/21202) - Fix scoped form cloning clearing the `scope` field when `scope_type` changes
+* [#21214](https://github.com/netbox-community/netbox/issues/21214) - Clean up AutoSyncRecord when detaching from DataSource
+* [#21242](https://github.com/netbox-community/netbox/issues/21242) - Navigation menu items for authentication should not require `staff_only` permission
+* [#21254](https://github.com/netbox-community/netbox/issues/21254) - Fix `AttributeError` exception when checking for latest release
+* [#21262](https://github.com/netbox-community/netbox/issues/21262) - Assigned scope should be replicated when cloning a prefix
+* [#21269](https://github.com/netbox-community/netbox/issues/21269) - Fix replication of front/rear port assignments from the module type when installing a module
+
+---
+
 ## v4.5.1 (2026-01-20)
 ## v4.5.1 (2026-01-20)
 
 
 ### Enhancements
 ### Enhancements

+ 16 - 8
netbox/circuits/forms/filtersets.py

@@ -34,9 +34,10 @@ __all__ = (
 class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
 class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
     model = Provider
     model = Provider
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
         FieldSet('asn_id', name=_('ASN')),
         FieldSet('asn_id', name=_('ASN')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
     )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
@@ -69,8 +70,9 @@ class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
 class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
 class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
     model = ProviderAccount
     model = ProviderAccount
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('provider_id', 'account', name=_('Attributes')),
         FieldSet('provider_id', 'account', name=_('Attributes')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
     )
     provider_id = DynamicModelMultipleChoiceField(
     provider_id = DynamicModelMultipleChoiceField(
@@ -88,8 +90,9 @@ class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetFor
 class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
 class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
     model = ProviderNetwork
     model = ProviderNetwork
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('provider_id', 'service_id', name=_('Attributes')),
         FieldSet('provider_id', 'service_id', name=_('Attributes')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     provider_id = DynamicModelMultipleChoiceField(
     provider_id = DynamicModelMultipleChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
@@ -107,8 +110,9 @@ class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
 class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
 class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
     model = CircuitType
     model = CircuitType
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('color', name=_('Attributes')),
         FieldSet('color', name=_('Attributes')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
@@ -121,7 +125,7 @@ class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
 class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
 class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
     model = Circuit
     model = Circuit
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
         FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
         FieldSet(
         FieldSet(
             'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
             'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
@@ -129,6 +133,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelF
         ),
         ),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
     )
     selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id')
     selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id')
@@ -274,8 +279,9 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
 class CircuitGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
 class CircuitGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
     model = CircuitGroup
     model = CircuitGroup
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
@@ -312,8 +318,9 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
 class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
 class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
     model = VirtualCircuitType
     model = VirtualCircuitType
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('color', name=_('Attributes')),
         FieldSet('color', name=_('Attributes')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
@@ -326,10 +333,11 @@ class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
 class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
 class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
     model = VirtualCircuit
     model = VirtualCircuit
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
         FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
         FieldSet('type_id', 'status', name=_('Attributes')),
         FieldSet('type_id', 'status', name=_('Attributes')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
     selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
     provider_id = DynamicModelMultipleChoiceField(
     provider_id = DynamicModelMultipleChoiceField(

+ 2 - 1
netbox/core/api/serializers_/jobs.py

@@ -31,7 +31,8 @@ class JobSerializer(BaseModelSerializer):
         model = Job
         model = Job
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
             'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
-            'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
+            'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'queue_name',
+            'log_entries',
         ]
         ]
         brief_fields = ('url', 'created', 'completed', 'user', 'status')
         brief_fields = ('url', 'created', 'completed', 'user', 'status')
 
 

+ 16 - 1
netbox/core/data_backends.py

@@ -21,11 +21,24 @@ __all__ = (
     'GitBackend',
     'GitBackend',
     'LocalBackend',
     'LocalBackend',
     'S3Backend',
     'S3Backend',
+    'url_has_embedded_credentials',
 )
 )
 
 
 logger = logging.getLogger('netbox.data_backends')
 logger = logging.getLogger('netbox.data_backends')
 
 
 
 
+def url_has_embedded_credentials(url):
+    """
+    Check if a URL contains embedded credentials (username in the URL).
+
+    URLs like 'https://user@bitbucket.org/...' have embedded credentials.
+    This is used to avoid passing explicit credentials to dulwich when the
+    URL already contains them, which would cause authentication conflicts.
+    """
+    parsed = urlparse(url)
+    return bool(parsed.username)
+
+
 @register_data_backend()
 @register_data_backend()
 class LocalBackend(DataBackend):
 class LocalBackend(DataBackend):
     name = 'local'
     name = 'local'
@@ -102,7 +115,9 @@ class GitBackend(DataBackend):
             clone_args['pool_manager'] = ProxyPoolManager(self.socks_proxy)
             clone_args['pool_manager'] = ProxyPoolManager(self.socks_proxy)
 
 
         if self.url_scheme in ('http', 'https'):
         if self.url_scheme in ('http', 'https'):
-            if self.params.get('username'):
+            # Only pass explicit credentials if URL doesn't already contain embedded username
+            # to avoid credential conflicts (see #20902)
+            if not url_has_embedded_credentials(self.url) and self.params.get('username'):
                 clone_args.update(
                 clone_args.update(
                     {
                     {
                         "username": self.params.get('username'),
                         "username": self.params.get('username'),

+ 5 - 1
netbox/core/filtersets.py

@@ -129,10 +129,14 @@ class JobFilterSet(BaseFilterSet):
         choices=JobStatusChoices,
         choices=JobStatusChoices,
         null_value=None
         null_value=None
     )
     )
+    queue_name = django_filters.CharFilter()
 
 
     class Meta:
     class Meta:
         model = Job
         model = Job
-        fields = ('id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id')
+        fields = (
+            'id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id',
+            'queue_name',
+        )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 7 - 2
netbox/core/forms/filtersets.py

@@ -26,8 +26,9 @@ __all__ = (
 class DataSourceFilterForm(PrimaryModelFilterSetForm):
 class DataSourceFilterForm(PrimaryModelFilterSetForm):
     model = DataSource
     model = DataSource
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')),
         FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
         label=_('Type'),
         label=_('Type'),
@@ -71,7 +72,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
     model = Job
     model = Job
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id'),
         FieldSet('q', 'filter_id'),
-        FieldSet('object_type_id', 'status', name=_('Attributes')),
+        FieldSet('object_type_id', 'status', 'queue_name', name=_('Attributes')),
         FieldSet(
         FieldSet(
             'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
             'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
             'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
             'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
@@ -87,6 +88,10 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
         choices=JobStatusChoices,
         choices=JobStatusChoices,
         required=False
         required=False
     )
     )
+    queue_name = forms.CharField(
+        label=_('Queue'),
+        required=False
+    )
     created__after = forms.DateTimeField(
     created__after = forms.DateTimeField(
         label=_('Created after'),
         label=_('Created after'),
         required=False,
         required=False,

+ 18 - 0
netbox/core/migrations/0021_job_queue_name.py

@@ -0,0 +1,18 @@
+# Generated by Django 5.2.9 on 2026-01-27 00:39
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0020_owner'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='job',
+            name='queue_name',
+            field=models.CharField(blank=True, max_length=100),
+        ),
+    ]

+ 14 - 3
netbox/core/models/jobs.py

@@ -112,6 +112,12 @@ class Job(models.Model):
         verbose_name=_('job ID'),
         verbose_name=_('job ID'),
         unique=True
         unique=True
     )
     )
+    queue_name = models.CharField(
+        verbose_name=_('queue name'),
+        max_length=100,
+        blank=True,
+        help_text=_('Name of the queue in which this job was enqueued')
+    )
     log_entries = ArrayField(
     log_entries = ArrayField(
         verbose_name=_('log entries'),
         verbose_name=_('log entries'),
         base_field=models.JSONField(
         base_field=models.JSONField(
@@ -179,11 +185,15 @@ class Job(models.Model):
         return f"{int(minutes)} minutes, {seconds:.2f} seconds"
         return f"{int(minutes)} minutes, {seconds:.2f} seconds"
 
 
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
+        # Use the stored queue name, or fall back to get_queue_for_model for legacy jobs
+        rq_queue_name = self.queue_name or get_queue_for_model(self.object_type.model if self.object_type else None)
+        rq_job_id = str(self.job_id)
+
         super().delete(*args, **kwargs)
         super().delete(*args, **kwargs)
 
 
-        rq_queue_name = get_queue_for_model(self.object_type.model if self.object_type else None)
+        # Cancel the RQ job using the stored queue name
         queue = django_rq.get_queue(rq_queue_name)
         queue = django_rq.get_queue(rq_queue_name)
-        job = queue.fetch_job(str(self.job_id))
+        job = queue.fetch_job(rq_job_id)
 
 
         if job:
         if job:
             try:
             try:
@@ -288,7 +298,8 @@ class Job(models.Model):
             scheduled=schedule_at,
             scheduled=schedule_at,
             interval=interval,
             interval=interval,
             user=user,
             user=user,
-            job_id=uuid.uuid4()
+            job_id=uuid.uuid4(),
+            queue_name=rq_queue_name
         )
         )
         job.full_clean()
         job.full_clean()
         job.save()
         job.save()

+ 11 - 0
netbox/core/models/object_types.py

@@ -9,6 +9,7 @@ from django.db import connection, models
 from django.db.models import Q
 from django.db.models import Q
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
+from netbox.context import query_cache
 from netbox.plugins import PluginConfig
 from netbox.plugins import PluginConfig
 from netbox.registry import registry
 from netbox.registry import registry
 from utilities.string import title
 from utilities.string import title
@@ -70,6 +71,12 @@ class ObjectTypeManager(models.Manager):
         """
         """
         from netbox.models.features import get_model_features, model_is_public
         from netbox.models.features import get_model_features, model_is_public
 
 
+        # Check the request cache before hitting the database
+        cache = query_cache.get()
+        if cache is not None:
+            if ot := cache['object_types'].get((model._meta.model, for_concrete_model)):
+                return ot
+
         # TODO: Remove this in NetBox v5.0
         # TODO: Remove this in NetBox v5.0
         # If the ObjectType table has not yet been provisioned (e.g. because we're in a pre-v4.4 migration),
         # If the ObjectType table has not yet been provisioned (e.g. because we're in a pre-v4.4 migration),
         # fall back to ContentType.
         # fall back to ContentType.
@@ -96,6 +103,10 @@ class ObjectTypeManager(models.Manager):
                 features=get_model_features(model),
                 features=get_model_features(model),
             )[0]
             )[0]
 
 
+        # Populate the request cache to avoid redundant lookups
+        if cache is not None:
+            cache['object_types'][(model._meta.model, for_concrete_model)] = ot
+
         return ot
         return ot
 
 
     def get_for_models(self, *models, for_concrete_models=True):
     def get_for_models(self, *models, for_concrete_models=True):

+ 2 - 1
netbox/core/signals.py

@@ -18,6 +18,7 @@ from extras.events import enqueue_event
 from extras.models import Tag
 from extras.models import Tag
 from extras.utils import run_validators
 from extras.utils import run_validators
 from netbox.config import get_config
 from netbox.config import get_config
+from utilities.data import get_config_value_ci
 from netbox.context import current_request, events_queue
 from netbox.context import current_request, events_queue
 from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
 from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
@@ -168,7 +169,7 @@ def handle_deleted_object(sender, instance, **kwargs):
     # to queueing any events for the object being deleted, in case a validation error is
     # to queueing any events for the object being deleted, in case a validation error is
     # raised, causing the deletion to fail.
     # raised, causing the deletion to fail.
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
-    validators = get_config().PROTECTION_RULES.get(model_name, [])
+    validators = get_config_value_ci(get_config().PROTECTION_RULES, model_name, default=[])
     try:
     try:
         run_validators(instance, validators)
         run_validators(instance, validators)
     except ValidationError as e:
     except ValidationError as e:

+ 4 - 1
netbox/core/tables/jobs.py

@@ -42,6 +42,9 @@ class JobTable(NetBoxTable):
     completed = columns.DateTimeColumn(
     completed = columns.DateTimeColumn(
         verbose_name=_('Completed'),
         verbose_name=_('Completed'),
     )
     )
+    queue_name = tables.Column(
+        verbose_name=_('Queue'),
+    )
     log_entries = tables.Column(
     log_entries = tables.Column(
         verbose_name=_('Log Entries'),
         verbose_name=_('Log Entries'),
     )
     )
@@ -53,7 +56,7 @@ class JobTable(NetBoxTable):
         model = Job
         model = Job
         fields = (
         fields = (
             'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
             'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
-            'completed', 'user', 'error', 'job_id',
+            'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
             'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',

+ 116 - 0
netbox/core/tests/test_data_backends.py

@@ -0,0 +1,116 @@
+from unittest import skipIf
+from unittest.mock import patch
+
+from django.test import TestCase
+
+from core.data_backends import url_has_embedded_credentials
+
+try:
+    import dulwich  # noqa: F401
+    DULWICH_AVAILABLE = True
+except ImportError:
+    DULWICH_AVAILABLE = False
+
+
+class URLEmbeddedCredentialsTests(TestCase):
+    def test_url_with_embedded_username(self):
+        self.assertTrue(url_has_embedded_credentials('https://myuser@bitbucket.org/workspace/repo.git'))
+
+    def test_url_without_embedded_username(self):
+        self.assertFalse(url_has_embedded_credentials('https://bitbucket.org/workspace/repo.git'))
+
+    def test_url_with_username_and_password(self):
+        self.assertTrue(url_has_embedded_credentials('https://user:pass@bitbucket.org/workspace/repo.git'))
+
+    def test_various_providers_with_embedded_username(self):
+        urls = [
+            'https://user@bitbucket.org/workspace/repo.git',
+            'https://user@github.com/owner/repo.git',
+            'https://deploy-key@gitlab.com/group/project.git',
+            'http://user@internal-git.example.com/repo.git',
+        ]
+        for url in urls:
+            with self.subTest(url=url):
+                self.assertTrue(url_has_embedded_credentials(url))
+
+    def test_various_providers_without_embedded_username(self):
+        """Various Git providers without embedded usernames."""
+        urls = [
+            'https://bitbucket.org/workspace/repo.git',
+            'https://github.com/owner/repo.git',
+            'https://gitlab.com/group/project.git',
+            'http://internal-git.example.com/repo.git',
+        ]
+        for url in urls:
+            with self.subTest(url=url):
+                self.assertFalse(url_has_embedded_credentials(url))
+
+    def test_ssh_url(self):
+        # git@host:path format doesn't parse as having a username in the traditional sense
+        self.assertFalse(url_has_embedded_credentials('git@github.com:owner/repo.git'))
+
+    def test_file_url(self):
+        self.assertFalse(url_has_embedded_credentials('file:///path/to/repo'))
+
+
+@skipIf(not DULWICH_AVAILABLE, "dulwich is not installed")
+class GitBackendCredentialIntegrationTests(TestCase):
+    """
+    Integration tests that verify GitBackend correctly applies credential logic.
+
+    These tests require dulwich to be installed and verify the full integration
+    of the credential handling in GitBackend.fetch().
+    """
+
+    def _get_clone_kwargs(self, url, **params):
+        from core.data_backends import GitBackend
+
+        backend = GitBackend(url=url, **params)
+
+        with patch('dulwich.porcelain.clone') as mock_clone, \
+             patch('dulwich.porcelain.NoneStream'):
+            try:
+                with backend.fetch():
+                    pass
+            except Exception:
+                pass
+
+            if mock_clone.called:
+                return mock_clone.call_args.kwargs
+            return {}
+
+    def test_url_with_embedded_username_skips_explicit_credentials(self):
+        kwargs = self._get_clone_kwargs(
+            url='https://myuser@bitbucket.org/workspace/repo.git',
+            username='myuser',
+            password='my-api-key'
+        )
+
+        self.assertEqual(kwargs.get('username'), None)
+        self.assertEqual(kwargs.get('password'), None)
+
+    def test_url_without_embedded_username_passes_explicit_credentials(self):
+        kwargs = self._get_clone_kwargs(
+            url='https://bitbucket.org/workspace/repo.git',
+            username='myuser',
+            password='my-api-key'
+        )
+
+        self.assertEqual(kwargs.get('username'), 'myuser')
+        self.assertEqual(kwargs.get('password'), 'my-api-key')
+
+    def test_url_with_embedded_username_no_explicit_credentials(self):
+        kwargs = self._get_clone_kwargs(
+            url='https://myuser@bitbucket.org/workspace/repo.git'
+        )
+
+        self.assertEqual(kwargs.get('username'), None)
+        self.assertEqual(kwargs.get('password'), None)
+
+    def test_public_repo_no_credentials(self):
+        kwargs = self._get_clone_kwargs(
+            url='https://github.com/public/repo.git'
+        )
+
+        self.assertEqual(kwargs.get('username'), None)
+        self.assertEqual(kwargs.get('password'), None)

+ 38 - 1
netbox/core/tests/test_models.py

@@ -1,8 +1,10 @@
+from unittest.mock import patch, MagicMock
+
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
 from django.test import TestCase
 from django.test import TestCase
 
 
-from core.models import DataSource, ObjectType
+from core.models import DataSource, Job, ObjectType
 from core.choices import ObjectChangeActionChoices
 from core.choices import ObjectChangeActionChoices
 from dcim.models import Site, Location, Device
 from dcim.models import Site, Location, Device
 from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
 from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
@@ -200,3 +202,38 @@ class ObjectTypeTest(TestCase):
         bookmarks_ots = ObjectType.objects.with_feature('bookmarks')
         bookmarks_ots = ObjectType.objects.with_feature('bookmarks')
         self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots)
         self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots)
         self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots)
         self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots)
+
+
+class JobTest(TestCase):
+
+    @patch('core.models.jobs.django_rq.get_queue')
+    def test_delete_cancels_job_from_correct_queue(self, mock_get_queue):
+        """
+        Test that when a job is deleted, it's canceled from the correct queue.
+        """
+        mock_queue = MagicMock()
+        mock_rq_job = MagicMock()
+        mock_queue.fetch_job.return_value = mock_rq_job
+        mock_get_queue.return_value = mock_queue
+
+        def dummy_func(**kwargs):
+            pass
+
+        # Enqueue a job with a custom queue name
+        custom_queue = 'my_custom_queue'
+        job = Job.enqueue(
+            func=dummy_func,
+            name='Test Job',
+            queue_name=custom_queue
+        )
+
+        # Reset mock to clear enqueue call
+        mock_get_queue.reset_mock()
+
+        # Delete the job
+        job.delete()
+
+        # Verify the correct queue was used for cancellation
+        mock_get_queue.assert_called_with(custom_queue)
+        mock_queue.fetch_job.assert_called_with(str(job.job_id))
+        mock_rq_job.cancel.assert_called_once()

+ 78 - 48
netbox/dcim/forms/filtersets.py

@@ -12,11 +12,12 @@ from netbox.forms import (
     NestedGroupModelFilterSetForm, NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm,
     NestedGroupModelFilterSetForm, NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm,
     PrimaryModelFilterSetForm,
     PrimaryModelFilterSetForm,
 )
 )
+from netbox.forms.mixins import OwnerFilterMixin
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from users.models import Owner, User
+from users.models import User
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
-from utilities.forms.fields import ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
+from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import NumberWithOptions
 from utilities.forms.widgets import NumberWithOptions
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -70,11 +71,11 @@ __all__ = (
     'SiteFilterForm',
     'SiteFilterForm',
     'SiteGroupFilterForm',
     'SiteGroupFilterForm',
     'VirtualChassisFilterForm',
     'VirtualChassisFilterForm',
-    'VirtualDeviceContextFilterForm'
+    'VirtualDeviceContextFilterForm',
 )
 )
 
 
 
 
-class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
+class DeviceComponentFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
     name = forms.CharField(
     name = forms.CharField(
         label=_('Name'),
         label=_('Name'),
         required=False
         required=False
@@ -157,18 +158,14 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
         required=False,
         required=False,
         label=_('Device Status'),
         label=_('Device Status'),
     )
     )
-    owner_id = DynamicModelChoiceField(
-        queryset=Owner.objects.all(),
-        required=False,
-        label=_('Owner'),
-    )
 
 
 
 
 class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
 class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
     model = Region
     model = Region
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('parent_id', name=_('Region')),
         FieldSet('parent_id', name=_('Region')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
     )
     )
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
@@ -182,8 +179,9 @@ class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
 class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
 class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
     model = SiteGroup
     model = SiteGroup
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('parent_id', name=_('Site Group')),
         FieldSet('parent_id', name=_('Site Group')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
     )
     )
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
@@ -197,9 +195,10 @@ class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm)
 class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
 class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
     model = Site
     model = Site
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('status', 'region_id', 'group_id', 'asn_id', name=_('Attributes')),
         FieldSet('status', 'region_id', 'group_id', 'asn_id', name=_('Attributes')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
     )
     selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
     selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
@@ -229,9 +228,10 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilt
 class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupModelFilterSetForm):
 class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupModelFilterSetForm):
     model = Location
     model = Location
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', 'facility', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', 'facility', name=_('Attributes')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
     )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
@@ -277,7 +277,8 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupM
 class RackRoleFilterForm(OrganizationalModelFilterSetForm):
 class RackRoleFilterForm(OrganizationalModelFilterSetForm):
     model = RackRole
     model = RackRole
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
@@ -328,10 +329,11 @@ class RackBaseFilterForm(PrimaryModelFilterSetForm):
 class RackTypeFilterForm(RackBaseFilterForm):
 class RackTypeFilterForm(RackBaseFilterForm):
     model = RackType
     model = RackType
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')),
         FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')),
         FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
         FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
     manufacturer_id = DynamicModelMultipleChoiceField(
     manufacturer_id = DynamicModelMultipleChoiceField(
@@ -350,13 +352,14 @@ class RackTypeFilterForm(RackBaseFilterForm):
 class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
 class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
     model = Rack
     model = Rack
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
-        FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
         FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
         FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
         FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
         FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
         FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
+        FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
     )
     selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
     selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
@@ -433,9 +436,10 @@ class RackElevationFilterForm(RackFilterForm):
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')),
         FieldSet('status', 'role_id', name=_('Function')),
         FieldSet('status', 'role_id', name=_('Function')),
         FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')),
         FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')),
+        FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
-        FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
     )
     )
     id = DynamicModelMultipleChoiceField(
     id = DynamicModelMultipleChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
@@ -451,10 +455,11 @@ class RackElevationFilterForm(RackFilterForm):
 class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     model = RackReservation
     model = RackReservation
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('status', 'user_id', name=_('Reservation')),
         FieldSet('status', 'user_id', name=_('Reservation')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -509,7 +514,8 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm):
 class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm):
     model = Manufacturer
     model = Manufacturer
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
@@ -518,7 +524,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSe
 class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
 class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
     model = DeviceType
     model = DeviceType
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet(
         FieldSet(
             'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
             'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
             'subdevice_role', 'airflow', name=_('Hardware')
             'subdevice_role', 'airflow', name=_('Hardware')
@@ -529,6 +535,7 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
             'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components')
             'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components')
         ),
         ),
         FieldSet('weight', 'weight_unit', name=_('Weight')),
         FieldSet('weight', 'weight_unit', name=_('Weight')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
     manufacturer_id = DynamicModelMultipleChoiceField(
     manufacturer_id = DynamicModelMultipleChoiceField(
@@ -652,7 +659,8 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
 class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
 class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
     model = ModuleTypeProfile
     model = ModuleTypeProfile
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     selector_fields = ('filter_id', 'q')
     selector_fields = ('filter_id', 'q')
     tag = TagFilterField(model)
     tag = TagFilterField(model)
@@ -661,7 +669,7 @@ class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
 class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
 class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
     model = ModuleType
     model = ModuleType
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet(
         FieldSet(
             'profile_id', 'manufacturer_id', 'part_number', 'module_count',
             'profile_id', 'manufacturer_id', 'part_number', 'module_count',
             'airflow', name=_('Hardware')
             'airflow', name=_('Hardware')
@@ -671,6 +679,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
             'pass_through_ports', name=_('Components')
             'pass_through_ports', name=_('Components')
         ),
         ),
         FieldSet('weight', 'weight_unit', name=_('Weight')),
         FieldSet('weight', 'weight_unit', name=_('Weight')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
     profile_id = DynamicModelMultipleChoiceField(
     profile_id = DynamicModelMultipleChoiceField(
@@ -754,8 +763,9 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
 class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
 class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
     model = DeviceRole
     model = DeviceRole
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
-        FieldSet('parent_id', 'config_template_id', name=_('Device Role'))
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('parent_id', 'config_template_id', name=_('Device Role')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     config_template_id = DynamicModelMultipleChoiceField(
     config_template_id = DynamicModelMultipleChoiceField(
         queryset=ConfigTemplate.objects.all(),
         queryset=ConfigTemplate.objects.all(),
@@ -773,8 +783,9 @@ class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
 class PlatformFilterForm(NestedGroupModelFilterSetForm):
 class PlatformFilterForm(NestedGroupModelFilterSetForm):
     model = Platform
     model = Platform
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
-        FieldSet('manufacturer_id', 'parent_id', 'config_template_id', name=_('Platform'))
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('manufacturer_id', 'parent_id', 'config_template_id', name=_('Platform')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
@@ -803,11 +814,12 @@ class DeviceFilterForm(
 ):
 ):
     model = Device
     model = Device
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_('Operation')),
         FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_('Operation')),
         FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')),
         FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet(
         FieldSet(
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
@@ -996,9 +1008,10 @@ class DeviceFilterForm(
 class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     model = VirtualDeviceContext
     model = VirtualDeviceContext
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('device', 'status', 'has_primary_ip', name=_('Attributes')),
         FieldSet('device', 'status', 'has_primary_ip', name=_('Attributes')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     device = DynamicModelMultipleChoiceField(
     device = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
@@ -1023,9 +1036,10 @@ class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetFor
 class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
 class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
     model = Module
     model = Module
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
         FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')),
         FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     device_id = DynamicModelMultipleChoiceField(
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
@@ -1106,9 +1120,10 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryM
 class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     model = VirtualChassis
     model = VirtualChassis
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
-        FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -1135,10 +1150,11 @@ class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     model = Cable
     model = Cable
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
         FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
         FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
         FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -1224,8 +1240,9 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
 class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
     model = PowerPanel
     model = PowerPanel
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
     )
     selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
     selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
@@ -1263,10 +1280,11 @@ class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
 class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     model = PowerFeed
     model = PowerFeed
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id', name=_('Location')),
-        FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Attributes')),
         FieldSet('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Attributes')),
+        FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -1390,7 +1408,7 @@ class PathEndpointFilterForm(CabledFilterForm):
 class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = ConsolePort
     model = ConsolePort
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
         FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
@@ -1398,6 +1416,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
             name=_('Device')
             name=_('Device')
         ),
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
         label=_('Type'),
         label=_('Type'),
@@ -1429,7 +1448,7 @@ class ConsolePortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
 class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = ConsoleServerPort
     model = ConsoleServerPort
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
         FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
@@ -1437,6 +1456,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
             name=_('Device')
             name=_('Device')
         ),
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
         label=_('Type'),
         label=_('Type'),
@@ -1468,7 +1488,7 @@ class ConsoleServerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterFo
 class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = PowerPort
     model = PowerPort
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'type', name=_('Attributes')),
         FieldSet('name', 'label', 'type', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
@@ -1476,6 +1496,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
             name=_('Device')
             name=_('Device')
         ),
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
         label=_('Type'),
         label=_('Type'),
@@ -1502,7 +1523,7 @@ class PowerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
 class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = PowerOutlet
     model = PowerOutlet
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
         FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
@@ -1510,6 +1531,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
             name=_('Device')
             name=_('Device')
         ),
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
         label=_('Type'),
         label=_('Type'),
@@ -1545,7 +1567,7 @@ class PowerOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
 class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = Interface
     model = Interface
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
         FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
         FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
@@ -1558,6 +1580,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
             name=_('Device')
             name=_('Device')
         ),
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     selector_fields = ('filter_id', 'q', 'device_id')
     selector_fields = ('filter_id', 'q', 'device_id')
     vdc_id = DynamicModelMultipleChoiceField(
     vdc_id = DynamicModelMultipleChoiceField(
@@ -1716,7 +1739,7 @@ class InterfaceTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
 
 
 class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
 class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
         FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
@@ -1724,6 +1747,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
             name=_('Device')
             name=_('Device')
         ),
         ),
         FieldSet('cabled', 'occupied', name=_('Cable')),
         FieldSet('cabled', 'occupied', name=_('Cable')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     model = FrontPort
     model = FrontPort
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
@@ -1759,7 +1783,7 @@ class FrontPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
 class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
 class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
     model = RearPort
     model = RearPort
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
         FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
@@ -1767,6 +1791,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
             name=_('Device')
             name=_('Device')
         ),
         ),
         FieldSet('cabled', 'occupied', name=_('Cable')),
         FieldSet('cabled', 'occupied', name=_('Cable')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
         label=_('Type'),
         label=_('Type'),
@@ -1801,13 +1826,14 @@ class RearPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
 class ModuleBayFilterForm(DeviceComponentFilterForm):
 class ModuleBayFilterForm(DeviceComponentFilterForm):
     model = ModuleBay
     model = ModuleBay
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'position', name=_('Attributes')),
         FieldSet('name', 'label', 'position', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
             'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
             name=_('Device')
         ),
         ),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
     position = forms.CharField(
     position = forms.CharField(
@@ -1832,13 +1858,14 @@ class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
 class DeviceBayFilterForm(DeviceComponentFilterForm):
 class DeviceBayFilterForm(DeviceComponentFilterForm):
     model = DeviceBay
     model = DeviceBay
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', name=_('Attributes')),
         FieldSet('name', 'label', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
             'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
             name=_('Device')
         ),
         ),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
@@ -1855,7 +1882,7 @@ class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
 class InventoryItemFilterForm(DeviceComponentFilterForm):
 class InventoryItemFilterForm(DeviceComponentFilterForm):
     model = InventoryItem
     model = InventoryItem
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet(
         FieldSet(
             'name', 'label', 'status', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered',
             'name', 'label', 'status', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered',
             name=_('Attributes')
             name=_('Attributes')
@@ -1865,6 +1892,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
             'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
             name=_('Device')
         ),
         ),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     role_id = DynamicModelMultipleChoiceField(
     role_id = DynamicModelMultipleChoiceField(
         queryset=InventoryItemRole.objects.all(),
         queryset=InventoryItemRole.objects.all(),
@@ -1925,7 +1953,8 @@ class InventoryItemTemplateFilterForm(DeviceComponentTemplateFilterForm):
 class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
 class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
     model = InventoryItemRole
     model = InventoryItemRole
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
@@ -1937,9 +1966,10 @@ class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
 class MACAddressFilterForm(PrimaryModelFilterSetForm):
 class MACAddressFilterForm(PrimaryModelFilterSetForm):
     model = MACAddress
     model = MACAddress
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('mac_address', name=_('Attributes')),
         FieldSet('mac_address', name=_('Attributes')),
         FieldSet('device_id', 'virtual_machine_id', 'assigned', 'primary', name=_('Assignments')),
         FieldSet('device_id', 'virtual_machine_id', 'assigned', 'primary', name=_('Assignments')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id')
     selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id')
     mac_address = forms.CharField(
     mac_address = forms.CharField(

+ 1 - 1
netbox/dcim/forms/mixins.py

@@ -75,7 +75,7 @@ class ScopedForm(forms.Form):
             except ObjectDoesNotExist:
             except ObjectDoesNotExist:
                 pass
                 pass
 
 
-            if self.instance and scope_type_id != self.instance.scope_type_id:
+            if self.instance and self.instance.pk and scope_type_id != self.instance.scope_type_id:
                 self.initial['scope'] = None
                 self.initial['scope'] = None
 
 
         else:
         else:

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

@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from dcim.models import *
 from dcim.models import *
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
+from netbox.forms.mixins import OwnerMixin
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
 from utilities.forms.rendering import FieldSet, TabbedGroups
 from utilities.forms.rendering import FieldSet, TabbedGroups
 from utilities.forms.widgets import APISelect
 from utilities.forms.widgets import APISelect
@@ -271,7 +272,7 @@ class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm
 # Virtual chassis
 # Virtual chassis
 #
 #
 
 
-class VirtualChassisCreateForm(NetBoxModelForm):
+class VirtualChassisCreateForm(OwnerMixin, NetBoxModelForm):
     region = DynamicModelChoiceField(
     region = DynamicModelChoiceField(
         label=_('Region'),
         label=_('Region'),
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),

+ 5 - 1
netbox/dcim/graphql/filters.py

@@ -550,6 +550,10 @@ class InterfaceFilter(
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
 
 
+    @strawberry_django.filter_field
+    def cabled(self, value: bool, prefix: str):
+        return Q(**{f'{prefix}cable__isnull': (not value)})
+
     @strawberry_django.filter_field
     @strawberry_django.filter_field
     def connected(self, queryset, value: bool, prefix: str):
     def connected(self, queryset, value: bool, prefix: str):
         if value is True:
         if value is True:
@@ -889,7 +893,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
 
 
 
 
 @strawberry_django.filter_type(models.RackType, lookups=True)
 @strawberry_django.filter_type(models.RackType, lookups=True)
-class RackTypeFilter(RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
+class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
     form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )

+ 1 - 1
netbox/dcim/graphql/types.py

@@ -734,7 +734,7 @@ class PowerPortTemplateType(ModularComponentTemplateType):
     filters=RackTypeFilter,
     filters=RackTypeFilter,
     pagination=True
     pagination=True
 )
 )
-class RackTypeType(PrimaryObjectType):
+class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType):
     rack_count: BigInt
     rack_count: BigInt
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
 
 

+ 6 - 1
netbox/dcim/models/modules.py

@@ -8,7 +8,7 @@ from jsonschema.exceptions import ValidationError as JSONValidationError
 from mptt.models import MPTTModel
 from mptt.models import MPTTModel
 
 
 from dcim.choices import *
 from dcim.choices import *
-from dcim.utils import update_interface_bridges
+from dcim.utils import create_port_mappings, update_interface_bridges
 from extras.models import ConfigContextModel, CustomField
 from extras.models import ConfigContextModel, CustomField
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
 from netbox.models.features import ImageAttachmentsMixin
 from netbox.models.features import ImageAttachmentsMixin
@@ -156,6 +156,8 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             'description': self.description,
             'description': self.description,
             'weight': float(self.weight) if self.weight is not None else None,
             'weight': float(self.weight) if self.weight is not None else None,
             'weight_unit': self.weight_unit,
             'weight_unit': self.weight_unit,
+            'airflow': self.airflow,
+            'attribute_data': self.attribute_data,
             'comments': self.comments,
             'comments': self.comments,
         }
         }
 
 
@@ -367,5 +369,8 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
             if issubclass(component_model, MPTTModel) and update_instances:
             if issubclass(component_model, MPTTModel) and update_instances:
                 component_model.objects.rebuild()
                 component_model.objects.rebuild()
 
 
+        # Replicate any front/rear port mappings from the ModuleType
+        create_port_mappings(self.device, self.module_type, self)
+
         # Interface bridges have to be set after interface instantiation
         # Interface bridges have to be set after interface instantiation
         update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
         update_interface_bridges(self.device, self.module_type.interfacetemplates, self)

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

@@ -122,7 +122,7 @@ class RackBase(WeightMixin, PrimaryModel):
         abstract = True
         abstract = True
 
 
 
 
-class RackType(RackBase):
+class RackType(ImageAttachmentsMixin, RackBase):
     """
     """
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
     Each Rack is assigned to a Site and (optionally) a Location.
     Each Rack is assigned to a Site and (optionally) a Location.
@@ -373,7 +373,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
         super().clean()
         super().clean()
 
 
         # Validate location/site assignment
         # Validate location/site assignment
-        if self.site and self.location and self.location.site != self.site:
+        if self.site_id and self.location_id and self.location.site_id != self.site_id:
             raise ValidationError(_("Assigned location must belong to parent site ({site}).").format(site=self.site))
             raise ValidationError(_("Assigned location must belong to parent site ({site}).").format(site=self.site))
 
 
         # Validate outer dimensions and unit
         # Validate outer dimensions and unit

+ 28 - 0
netbox/dcim/tables/devices.py

@@ -27,6 +27,7 @@ __all__ = (
     'DeviceTable',
     'DeviceTable',
     'FrontPortTable',
     'FrontPortTable',
     'InterfaceTable',
     'InterfaceTable',
+    'InterfaceLAGMemberTable',
     'InventoryItemRoleTable',
     'InventoryItemRoleTable',
     'InventoryItemTable',
     'InventoryItemTable',
     'MACAddressTable',
     'MACAddressTable',
@@ -689,6 +690,33 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
 
 
 
 
+class InterfaceLAGMemberTable(PathEndpointTable, NetBoxTable):
+    parent = tables.Column(
+        verbose_name=_('Parent'),
+        accessor=Accessor('device'),
+        linkify=True,
+    )
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True,
+        order_by=('_name',),
+    )
+    connection = columns.TemplateColumn(
+        accessor='connected_endpoints',
+        template_code=INTERFACE_LAG_MEMBERS_LINKTERMINATION,
+        verbose_name=_('Peer'),
+        orderable=False,
+    )
+    tags = columns.TagColumn(
+        url_name='dcim:interface_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = models.Interface
+        fields = ('pk', 'parent', 'name', 'type', 'connection')
+        default_columns = ('pk', 'parent', 'name', 'type', 'connection')
+
+
 class DeviceInterfaceTable(InterfaceTable):
 class DeviceInterfaceTable(InterfaceTable):
     name = tables.TemplateColumn(
     name = tables.TemplateColumn(
         verbose_name=_('Name'),
         verbose_name=_('Name'),

+ 18 - 0
netbox/dcim/tables/template_code.py

@@ -24,6 +24,24 @@ INTERFACE_LINKTERMINATION = """
 {% else %}""" + LINKTERMINATION + """{% endif %}
 {% else %}""" + LINKTERMINATION + """{% endif %}
 """
 """
 
 
+INTERFACE_LAG_MEMBERS_LINKTERMINATION = """
+{% for termination in value %}
+  {% if termination.parent_object %}
+    <a href="{{ termination.parent_object.get_absolute_url }}">{{ termination.parent_object }}</a>
+    <i class="mdi mdi-chevron-right"></i>
+  {% endif %}
+  <a href="{{ termination.get_absolute_url }}">{{ termination }}</a>
+  {% if termination.lag %}
+    <i class="mdi mdi-chevron-right"></i>
+    <a href="{{ termination.lag.get_absolute_url }}">{{ termination.lag }}</a>
+    <span class="text-muted">(LAG)</span>
+  {% endif %}
+  {% if not forloop.last %}<br />{% endif %}
+{% empty %}
+  {{ ''|placeholder }}
+{% endfor %}
+"""
+
 CABLE_LENGTH = """
 CABLE_LENGTH = """
 {% load helpers %}
 {% load helpers %}
 {% if record.length %}{{ record.length|floatformat:"-2" }} {{ record.length_unit }}{% endif %}
 {% if record.length %}{{ record.length|floatformat:"-2" }} {{ record.length_unit }}{% endif %}

+ 136 - 0
netbox/dcim/tests/test_models.py

@@ -875,6 +875,142 @@ class ModuleBayTestCase(TestCase):
         self.assertIsNone(bay2.parent)
         self.assertIsNone(bay2.parent)
         self.assertIsNone(bay2.module)
         self.assertIsNone(bay2.module)
 
 
+    def test_module_installation_creates_port_mappings(self):
+        """
+        Test that installing a module with front/rear port templates correctly
+        creates PortMapping instances for the device.
+        """
+        device = Device.objects.first()
+        manufacturer = Manufacturer.objects.first()
+        module_bay = ModuleBay.objects.create(device=device, name='Test Bay PortMapping 1')
+
+        # Create a module type with a rear port template
+        module_type_with_mappings = ModuleType.objects.create(
+            manufacturer=manufacturer,
+            model='Module Type With Mappings',
+        )
+
+        # Create a rear port template with 12 positions (splice)
+        rear_port_template = RearPortTemplate.objects.create(
+            module_type=module_type_with_mappings,
+            name='Rear Port 1',
+            type=PortTypeChoices.TYPE_SPLICE,
+            positions=12,
+        )
+
+        # Create 12 front port templates mapped to the rear port
+        front_port_templates = []
+        for i in range(1, 13):
+            front_port_template = FrontPortTemplate.objects.create(
+                module_type=module_type_with_mappings,
+                name=f'port {i}',
+                type=PortTypeChoices.TYPE_LC,
+                positions=1,
+            )
+            front_port_templates.append(front_port_template)
+
+            # Create port template mapping
+            PortTemplateMapping.objects.create(
+                device_type=None,
+                module_type=module_type_with_mappings,
+                front_port=front_port_template,
+                front_port_position=1,
+                rear_port=rear_port_template,
+                rear_port_position=i,
+            )
+
+        # Install the module
+        module = Module.objects.create(
+            device=device,
+            module_bay=module_bay,
+            module_type=module_type_with_mappings,
+            status=ModuleStatusChoices.STATUS_ACTIVE,
+        )
+
+        # Verify that front ports were created
+        front_ports = FrontPort.objects.filter(device=device, module=module)
+        self.assertEqual(front_ports.count(), 12)
+
+        # Verify that the rear port was created
+        rear_ports = RearPort.objects.filter(device=device, module=module)
+        self.assertEqual(rear_ports.count(), 1)
+        rear_port = rear_ports.first()
+        self.assertEqual(rear_port.positions, 12)
+
+        # Verify that port mappings were created
+        port_mappings = PortMapping.objects.filter(front_port__module=module)
+        self.assertEqual(port_mappings.count(), 12)
+
+        # Verify each mapping is correct
+        for i, front_port_template in enumerate(front_port_templates, start=1):
+            front_port = FrontPort.objects.get(
+                device=device,
+                name=front_port_template.name,
+                module=module,
+            )
+
+            # Check that a mapping exists for this front port
+            mapping = PortMapping.objects.get(
+                device=device,
+                front_port=front_port,
+                front_port_position=1,
+            )
+
+            self.assertEqual(mapping.rear_port, rear_port)
+            self.assertEqual(mapping.front_port_position, 1)
+            self.assertEqual(mapping.rear_port_position, i)
+
+    def test_module_installation_without_mappings(self):
+        """
+        Test that installing a module without port template mappings
+        doesn't create any PortMapping instances.
+        """
+        device = Device.objects.first()
+        manufacturer = Manufacturer.objects.first()
+        module_bay = ModuleBay.objects.create(device=device, name='Test Bay PortMapping 2')
+
+        # Create a module type without any port template mappings
+        module_type_no_mappings = ModuleType.objects.create(
+            manufacturer=manufacturer,
+            model='Module Type Without Mappings',
+        )
+
+        # Create a rear port template
+        RearPortTemplate.objects.create(
+            module_type=module_type_no_mappings,
+            name='Rear Port 1',
+            type=PortTypeChoices.TYPE_SPLICE,
+            positions=12,
+        )
+
+        # Create front port templates but DO NOT create PortTemplateMapping rows
+        for i in range(1, 13):
+            FrontPortTemplate.objects.create(
+                module_type=module_type_no_mappings,
+                name=f'port {i}',
+                type=PortTypeChoices.TYPE_LC,
+                positions=1,
+            )
+
+        # Install the module
+        module = Module.objects.create(
+            device=device,
+            module_bay=module_bay,
+            module_type=module_type_no_mappings,
+            status=ModuleStatusChoices.STATUS_ACTIVE,
+        )
+
+        # Verify no port mappings were created for this module
+        port_mappings = PortMapping.objects.filter(
+            device=device,
+            front_port__module=module,
+            front_port_position=1,
+        )
+        self.assertEqual(port_mappings.count(), 0)
+        self.assertEqual(FrontPort.objects.filter(module=module).count(), 12)
+        self.assertEqual(RearPort.objects.filter(module=module).count(), 1)
+        self.assertEqual(PortMapping.objects.filter(front_port__module=module).count(), 0)
+
 
 
 class CableTestCase(TestCase):
 class CableTestCase(TestCase):
 
 

+ 3 - 3
netbox/dcim/utils.py

@@ -85,13 +85,13 @@ def update_interface_bridges(device, interface_templates, module=None):
             interface.save()
             interface.save()
 
 
 
 
-def create_port_mappings(device, device_type, module=None):
+def create_port_mappings(device, device_or_module_type, module=None):
     """
     """
-    Replicate all front/rear port mappings from a DeviceType to the given device.
+    Replicate all front/rear port mappings from a DeviceType or ModuleType to the given device.
     """
     """
     from dcim.models import FrontPort, PortMapping, RearPort
     from dcim.models import FrontPort, PortMapping, RearPort
 
 
-    templates = device_type.port_mappings.prefetch_related('front_port', 'rear_port')
+    templates = device_or_module_type.port_mappings.prefetch_related('front_port', 'rear_port')
 
 
     # Cache front & rear ports for efficient lookups by name
     # Cache front & rear ports for efficient lookups by name
     front_ports = {
     front_ports = {

+ 10 - 0
netbox/dcim/views.py

@@ -880,6 +880,7 @@ class RackTypeView(GetRelatedModelsMixin, generic.ObjectView):
             panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']),
             panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']),
             CustomFieldsPanel(),
             CustomFieldsPanel(),
             RelatedObjectsPanel(),
             RelatedObjectsPanel(),
+            ImageAttachmentsPanel(),
         ],
         ],
     )
     )
 
 
@@ -3135,6 +3136,14 @@ class InterfaceView(generic.ObjectView):
         )
         )
         child_interfaces_table.configure(request)
         child_interfaces_table.configure(request)
 
 
+        # Get LAG interfaces
+        lag_interfaces = Interface.objects.restrict(request.user, 'view').filter(lag=instance)
+        lag_interfaces_table = tables.InterfaceLAGMemberTable(
+            lag_interfaces,
+            orderable=False
+        )
+        lag_interfaces_table.configure(request)
+
         # Get assigned VLANs and annotate whether each is tagged or untagged
         # Get assigned VLANs and annotate whether each is tagged or untagged
         vlans = []
         vlans = []
         if instance.untagged_vlan is not None:
         if instance.untagged_vlan is not None:
@@ -3164,6 +3173,7 @@ class InterfaceView(generic.ObjectView):
             'bridge_interfaces': bridge_interfaces,
             'bridge_interfaces': bridge_interfaces,
             'bridge_interfaces_table': bridge_interfaces_table,
             'bridge_interfaces_table': bridge_interfaces_table,
             'child_interfaces_table': child_interfaces_table,
             'child_interfaces_table': child_interfaces_table,
+            'lag_interfaces_table': lag_interfaces_table,
             'vlan_table': vlan_table,
             'vlan_table': vlan_table,
             'vlan_translation_table': vlan_translation_table,
             'vlan_translation_table': vlan_translation_table,
         }
         }

+ 3 - 9
netbox/extras/api/customfields.py

@@ -4,7 +4,6 @@ from drf_spectacular.utils import extend_schema_field
 from rest_framework.fields import Field
 from rest_framework.fields import Field
 from rest_framework.serializers import ValidationError
 from rest_framework.serializers import ValidationError
 
 
-from core.models import ObjectType
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
 from extras.constants import CUSTOMFIELD_EMPTY_VALUES
 from extras.constants import CUSTOMFIELD_EMPTY_VALUES
 from extras.models import CustomField
 from extras.models import CustomField
@@ -24,13 +23,9 @@ class CustomFieldDefaultValues:
     def __call__(self, serializer_field):
     def __call__(self, serializer_field):
         self.model = serializer_field.parent.Meta.model
         self.model = serializer_field.parent.Meta.model
 
 
-        # Retrieve the CustomFields for the parent model
-        object_type = ObjectType.objects.get_for_model(self.model)
-        fields = CustomField.objects.filter(object_types=object_type)
-
-        # Populate the default value for each CustomField
+        # Populate the default value for each CustomField on the model
         value = {}
         value = {}
-        for field in fields:
+        for field in CustomField.objects.get_for_model(self.model):
             if field.default is not None:
             if field.default is not None:
                 value[field.name] = field.default
                 value[field.name] = field.default
             else:
             else:
@@ -47,8 +42,7 @@ class CustomFieldsDataField(Field):
         Cache CustomFields assigned to this model to avoid redundant database queries
         Cache CustomFields assigned to this model to avoid redundant database queries
         """
         """
         if not hasattr(self, '_custom_fields'):
         if not hasattr(self, '_custom_fields'):
-            object_type = ObjectType.objects.get_for_model(self.parent.Meta.model)
-            self._custom_fields = CustomField.objects.filter(object_types=object_type)
+            self._custom_fields = CustomField.objects.get_for_model(self.parent.Meta.model)
         return self._custom_fields
         return self._custom_fields
 
 
     def to_representation(self, obj):
     def to_representation(self, obj):

+ 2 - 1
netbox/extras/dashboard/widgets.py

@@ -75,10 +75,11 @@ def get_bookmarks_object_type_choices():
 def get_models_from_content_types(content_types):
 def get_models_from_content_types(content_types):
     """
     """
     Return a list of models corresponding to the given content types, identified by natural key.
     Return a list of models corresponding to the given content types, identified by natural key.
+    Accepts both lowercase (e.g. "dcim.site") and PascalCase (e.g. "dcim.Site") model names.
     """
     """
     models = []
     models = []
     for content_type_id in content_types:
     for content_type_id in content_types:
-        app_label, model_name = content_type_id.split('.')
+        app_label, model_name = content_type_id.lower().split('.')
         try:
         try:
             content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
             content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
             if content_type.model_class():
             if content_type.model_class():

+ 72 - 56
netbox/extras/events.py

@@ -1,5 +1,5 @@
 import logging
 import logging
-from collections import defaultdict
+from collections import UserDict, defaultdict
 
 
 from django.conf import settings
 from django.conf import settings
 from django.utils import timezone
 from django.utils import timezone
@@ -12,7 +12,6 @@ from core.models import ObjectType
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.constants import RQ_QUEUE_DEFAULT
 from netbox.constants import RQ_QUEUE_DEFAULT
 from netbox.models.features import has_feature
 from netbox.models.features import has_feature
-from users.models import User
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from utilities.request import copy_safe_request
 from utilities.request import copy_safe_request
 from utilities.rqworker import get_rq_retry
 from utilities.rqworker import get_rq_retry
@@ -23,6 +22,21 @@ from .models import EventRule
 logger = logging.getLogger('netbox.events_processor')
 logger = logging.getLogger('netbox.events_processor')
 
 
 
 
+class EventContext(UserDict):
+    """
+    A custom dictionary that automatically serializes its associated object on demand.
+    """
+
+    # We're emulating a dictionary here (rather than using a custom class) because prior to NetBox v4.5.2, events were
+    # queued as dictionaries for processing by handles in EVENTS_PIPELINE. We need to avoid introducing any breaking
+    # changes until a suitable minor release.
+    def __getitem__(self, item):
+        if item == 'data' and 'data' not in self:
+            data = serialize_for_event(self['object'])
+            self.__setitem__('data', data)
+        return super().__getitem__(item)
+
+
 def serialize_for_event(instance):
 def serialize_for_event(instance):
     """
     """
     Return a serialized representation of the given instance suitable for use in a queued event.
     Return a serialized representation of the given instance suitable for use in a queued event.
@@ -37,18 +51,26 @@ def serialize_for_event(instance):
 
 
 
 
 def get_snapshots(instance, event_type):
 def get_snapshots(instance, event_type):
-    snapshots = {
+    """
+    Return a dictionary of pre- and post-change snapshots for the given instance.
+    """
+    if event_type == OBJECT_DELETED:
+        # Post-change snapshot must be empty for deleted objects
+        postchange_snapshot = None
+    elif hasattr(instance, '_postchange_snapshot'):
+        # Use the cached post-change snapshot if one is available
+        postchange_snapshot = instance._postchange_snapshot
+    elif hasattr(instance, 'serialize_object'):
+        # Use model's serialize_object() method if defined
+        postchange_snapshot = instance.serialize_object()
+    else:
+        # Fall back to the serialize_object() utility function
+        postchange_snapshot = serialize_object(instance)
+
+    return {
         'prechange': getattr(instance, '_prechange_snapshot', None),
         'prechange': getattr(instance, '_prechange_snapshot', None),
-        'postchange': None,
+        'postchange': postchange_snapshot,
     }
     }
-    if event_type != OBJECT_DELETED:
-        # Use model's serialize_object() method if defined; fall back to serialize_object() utility function
-        if hasattr(instance, 'serialize_object'):
-            snapshots['postchange'] = instance.serialize_object()
-        else:
-            snapshots['postchange'] = serialize_object(instance)
-
-    return snapshots
 
 
 
 
 def enqueue_event(queue, instance, request, event_type):
 def enqueue_event(queue, instance, request, event_type):
@@ -66,37 +88,42 @@ def enqueue_event(queue, instance, request, event_type):
     assert instance.pk is not None
     assert instance.pk is not None
     key = f'{app_label}.{model_name}:{instance.pk}'
     key = f'{app_label}.{model_name}:{instance.pk}'
     if key in queue:
     if key in queue:
-        queue[key]['data'] = serialize_for_event(instance)
         queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
         queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
         # If the object is being deleted, update any prior "update" event to "delete"
         # If the object is being deleted, update any prior "update" event to "delete"
         if event_type == OBJECT_DELETED:
         if event_type == OBJECT_DELETED:
             queue[key]['event_type'] = event_type
             queue[key]['event_type'] = event_type
     else:
     else:
-        queue[key] = {
-            'object_type': ObjectType.objects.get_for_model(instance),
-            'object_id': instance.pk,
-            'event_type': event_type,
-            'data': serialize_for_event(instance),
-            'snapshots': get_snapshots(instance, event_type),
-            'request': request,
+        queue[key] = EventContext(
+            object_type=ObjectType.objects.get_for_model(instance),
+            object_id=instance.pk,
+            object=instance,
+            event_type=event_type,
+            snapshots=get_snapshots(instance, event_type),
+            request=request,
+            user=request.user,
             # Legacy request attributes for backward compatibility
             # Legacy request attributes for backward compatibility
-            'username': request.user.username,
-            'request_id': request.id,
-        }
+            username=request.user.username,
+            request_id=request.id,
+        )
+    # Force serialization of objects prior to them actually being deleted
+    if event_type == OBJECT_DELETED:
+        queue[key]['data'] = serialize_for_event(instance)
 
 
 
 
-def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request=None):
-    user = None  # To be resolved from the username if needed
+def process_event_rules(event_rules, object_type, event):
+    """
+    Process a list of EventRules against an event.
+    """
 
 
     for event_rule in event_rules:
     for event_rule in event_rules:
 
 
         # Evaluate event rule conditions (if any)
         # Evaluate event rule conditions (if any)
-        if not event_rule.eval_conditions(data):
+        if not event_rule.eval_conditions(event['data']):
             continue
             continue
 
 
         # Compile event data
         # Compile event data
         event_data = event_rule.action_data or {}
         event_data = event_rule.action_data or {}
-        event_data.update(data)
+        event_data.update(event['data'])
 
 
         # Webhooks
         # Webhooks
         if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
         if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
@@ -109,50 +136,41 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
             params = {
             params = {
                 "event_rule": event_rule,
                 "event_rule": event_rule,
                 "object_type": object_type,
                 "object_type": object_type,
-                "event_type": event_type,
+                "event_type": event['event_type'],
                 "data": event_data,
                 "data": event_data,
-                "snapshots": snapshots,
+                "snapshots": event.get('snapshots'),
                 "timestamp": timezone.now().isoformat(),
                 "timestamp": timezone.now().isoformat(),
-                "username": username,
+                "username": event['username'],
                 "retry": get_rq_retry()
                 "retry": get_rq_retry()
             }
             }
-            if snapshots:
-                params["snapshots"] = snapshots
-            if request:
+            if 'request' in event:
                 # Exclude FILES - webhooks don't need uploaded files,
                 # Exclude FILES - webhooks don't need uploaded files,
                 # which can cause pickle errors with Pillow.
                 # which can cause pickle errors with Pillow.
-                params["request"] = copy_safe_request(request, include_files=False)
+                params['request'] = copy_safe_request(event['request'], include_files=False)
 
 
             # Enqueue the task
             # Enqueue the task
-            rq_queue.enqueue(
-                "extras.webhooks.send_webhook",
-                **params
-            )
+            rq_queue.enqueue('extras.webhooks.send_webhook', **params)
 
 
         # Scripts
         # Scripts
         elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
         elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
             # Resolve the script from action parameters
             # Resolve the script from action parameters
             script = event_rule.action_object.python_class()
             script = event_rule.action_object.python_class()
 
 
-            # Retrieve the User if not already resolved
-            if user is None:
-                user = User.objects.get(username=username)
-
             # Enqueue a Job to record the script's execution
             # Enqueue a Job to record the script's execution
             from extras.jobs import ScriptJob
             from extras.jobs import ScriptJob
             params = {
             params = {
                 "instance": event_rule.action_object,
                 "instance": event_rule.action_object,
                 "name": script.name,
                 "name": script.name,
-                "user": user,
+                "user": event['user'],
                 "data": event_data
                 "data": event_data
             }
             }
-            if snapshots:
-                params["snapshots"] = snapshots
-            if request:
-                params["request"] = copy_safe_request(request)
-            ScriptJob.enqueue(
-                **params
-            )
+            if 'snapshots' in event:
+                params['snapshots'] = event['snapshots']
+            if 'request' in event:
+                params['request'] = copy_safe_request(event['request'])
+
+            # Enqueue the job
+            ScriptJob.enqueue(**params)
 
 
         # Notification groups
         # Notification groups
         elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION:
         elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION:
@@ -161,7 +179,7 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
                 object_type=object_type,
                 object_type=object_type,
                 object_id=event_data['id'],
                 object_id=event_data['id'],
                 object_repr=event_data.get('display'),
                 object_repr=event_data.get('display'),
-                event_type=event_type
+                event_type=event['event_type']
             )
             )
 
 
         else:
         else:
@@ -173,6 +191,8 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
 def process_event_queue(events):
 def process_event_queue(events):
     """
     """
     Flush a list of object representation to RQ for EventRule processing.
     Flush a list of object representation to RQ for EventRule processing.
+
+    This is the default processor listed in EVENTS_PIPELINE.
     """
     """
     events_cache = defaultdict(dict)
     events_cache = defaultdict(dict)
 
 
@@ -192,11 +212,7 @@ def process_event_queue(events):
         process_event_rules(
         process_event_rules(
             event_rules=event_rules,
             event_rules=event_rules,
             object_type=object_type,
             object_type=object_type,
-            event_type=event['event_type'],
-            data=event['data'],
-            username=event['username'],
-            snapshots=event['snapshots'],
-            request=event['request'],
+            event=event,
         )
         )
 
 
 
 

+ 33 - 69
netbox/extras/forms/filtersets.py

@@ -7,13 +7,12 @@ from extras.choices import *
 from extras.models import *
 from extras.models import *
 from netbox.events import get_event_type_choices
 from netbox.events import get_event_type_choices
 from netbox.forms import NetBoxModelFilterSetForm, PrimaryModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm, PrimaryModelFilterSetForm
-from netbox.forms.mixins import SavedFiltersMixin
+from netbox.forms.mixins import OwnerFilterMixin, SavedFiltersMixin
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
-from users.models import Group, Owner, User
+from users.models import Group, User
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 from utilities.forms.fields import (
 from utilities.forms.fields import (
-    ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
-    TagFilterField,
+    ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
 )
 )
 from utilities.forms.rendering import FieldSet
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import DateTimePicker
 from utilities.forms.widgets import DateTimePicker
@@ -39,7 +38,7 @@ __all__ = (
 )
 )
 
 
 
 
-class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
+class CustomFieldFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
     model = CustomField
     model = CustomField
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id'),
         FieldSet('q', 'filter_id'),
@@ -47,6 +46,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
         FieldSet('choice_set_id', 'related_object_type_id', name=_('Type Options')),
         FieldSet('choice_set_id', 'related_object_type_id', name=_('Type Options')),
         FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
         FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
         FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')),
         FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     object_type_id = ContentTypeMultipleChoiceField(
     object_type_id = ContentTypeMultipleChoiceField(
         queryset=ObjectType.objects.with_feature('custom_fields'),
         queryset=ObjectType.objects.with_feature('custom_fields'),
@@ -119,18 +119,14 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
         label=_('Validation regex'),
         label=_('Validation regex'),
         required=False
         required=False
     )
     )
-    owner_id = DynamicModelChoiceField(
-        queryset=Owner.objects.all(),
-        required=False,
-        label=_('Owner'),
-    )
 
 
 
 
-class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
+class CustomFieldChoiceSetFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
     model = CustomFieldChoiceSet
     model = CustomFieldChoiceSet
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id'),
         FieldSet('q', 'filter_id'),
         FieldSet('base_choices', 'choice', name=_('Choices')),
         FieldSet('base_choices', 'choice', name=_('Choices')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     base_choices = forms.MultipleChoiceField(
     base_choices = forms.MultipleChoiceField(
         choices=CustomFieldChoiceSetBaseChoices,
         choices=CustomFieldChoiceSetBaseChoices,
@@ -139,18 +135,14 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
     choice = forms.CharField(
     choice = forms.CharField(
         required=False
         required=False
     )
     )
-    owner_id = DynamicModelChoiceField(
-        queryset=Owner.objects.all(),
-        required=False,
-        label=_('Owner'),
-    )
 
 
 
 
-class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
+class CustomLinkFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
     model = CustomLink
     model = CustomLink
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id'),
         FieldSet('q', 'filter_id'),
         FieldSet('object_type_id', 'enabled', 'new_window', 'weight', name=_('Attributes')),
         FieldSet('object_type_id', 'enabled', 'new_window', 'weight', name=_('Attributes')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     object_type_id = ContentTypeMultipleChoiceField(
     object_type_id = ContentTypeMultipleChoiceField(
         label=_('Object types'),
         label=_('Object types'),
@@ -175,19 +167,15 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
         label=_('Weight'),
         label=_('Weight'),
         required=False
         required=False
     )
     )
-    owner_id = DynamicModelChoiceField(
-        queryset=Owner.objects.all(),
-        required=False,
-        label=_('Owner'),
-    )
 
 
 
 
-class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
+class ExportTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
     model = ExportTemplate
     model = ExportTemplate
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'object_type_id'),
         FieldSet('q', 'filter_id', 'object_type_id'),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
         FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
         FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     data_source_id = DynamicModelMultipleChoiceField(
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
@@ -226,11 +214,6 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
             choices=BOOLEAN_WITH_BLANK_CHOICES
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
         )
     )
     )
-    owner_id = DynamicModelChoiceField(
-        queryset=Owner.objects.all(),
-        required=False,
-        label=_('Owner'),
-    )
 
 
 
 
 class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
 class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
@@ -250,11 +233,12 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
     )
     )
 
 
 
 
-class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
+class SavedFilterFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
     model = SavedFilter
     model = SavedFilter
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id'),
         FieldSet('q', 'filter_id'),
         FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')),
         FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     object_type_id = ContentTypeMultipleChoiceField(
     object_type_id = ContentTypeMultipleChoiceField(
         label=_('Object types'),
         label=_('Object types'),
@@ -279,11 +263,6 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
         label=_('Weight'),
         label=_('Weight'),
         required=False
         required=False
     )
     )
-    owner_id = DynamicModelChoiceField(
-        queryset=Owner.objects.all(),
-        required=False,
-        label=_('Owner'),
-    )
 
 
 
 
 class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
 class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
@@ -317,11 +296,12 @@ class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
     )
     )
 
 
 
 
-class WebhookFilterForm(NetBoxModelFilterSetForm):
+class WebhookFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
     model = Webhook
     model = Webhook
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('payload_url', 'http_method', 'http_content_type', name=_('Attributes')),
         FieldSet('payload_url', 'http_method', 'http_content_type', name=_('Attributes')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     http_content_type = forms.CharField(
     http_content_type = forms.CharField(
         label=_('HTTP content type'),
         label=_('HTTP content type'),
@@ -336,19 +316,15 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
         required=False,
         required=False,
         label=_('HTTP method')
         label=_('HTTP method')
     )
     )
-    owner_id = DynamicModelChoiceField(
-        queryset=Owner.objects.all(),
-        required=False,
-        label=_('Owner'),
-    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class EventRuleFilterForm(NetBoxModelFilterSetForm):
+class EventRuleFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
     model = EventRule
     model = EventRule
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('object_type_id', 'event_type', 'action_type', 'enabled', name=_('Attributes')),
         FieldSet('object_type_id', 'event_type', 'action_type', 'enabled', name=_('Attributes')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     object_type_id = ContentTypeMultipleChoiceField(
     object_type_id = ContentTypeMultipleChoiceField(
         queryset=ObjectType.objects.with_feature('event_rules'),
         queryset=ObjectType.objects.with_feature('event_rules'),
@@ -372,16 +348,16 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm):
             choices=BOOLEAN_WITH_BLANK_CHOICES
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
         )
     )
     )
-    owner_id = DynamicModelChoiceField(
-        queryset=Owner.objects.all(),
-        required=False,
-        label=_('Owner'),
-    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class TagFilterForm(SavedFiltersMixin, FilterForm):
+class TagFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
     model = Tag
     model = Tag
+    fieldsets = (
+        FieldSet('q', 'filter_id'),
+        FieldSet('content_type_id', 'for_object_type_id', name=_('Attributes')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
+    )
     content_type_id = ContentTypeMultipleChoiceField(
     content_type_id = ContentTypeMultipleChoiceField(
         queryset=ObjectType.objects.with_feature('tags'),
         queryset=ObjectType.objects.with_feature('tags'),
         required=False,
         required=False,
@@ -392,11 +368,6 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
         required=False,
         required=False,
         label=_('Allowed object type')
         label=_('Allowed object type')
     )
     )
-    owner_id = DynamicModelChoiceField(
-        queryset=Owner.objects.all(),
-        required=False,
-        label=_('Owner'),
-    )
 
 
 
 
 class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
 class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
@@ -404,6 +375,7 @@ class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id'),
         FieldSet('q', 'filter_id'),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     data_source_id = DynamicModelMultipleChoiceField(
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
@@ -420,16 +392,17 @@ class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
     )
     )
 
 
 
 
-class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
+class ConfigContextFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
     model = ConfigContext
     model = ConfigContext
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag_id'),
         FieldSet('q', 'filter_id', 'tag_id'),
-        FieldSet('profile', name=_('Config Context')),
+        FieldSet('profile_id', name=_('Config Context')),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
         FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
         FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
         FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
-        FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
+        FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     profile_id = DynamicModelMultipleChoiceField(
     profile_id = DynamicModelMultipleChoiceField(
         queryset=ConfigContextProfile.objects.all(),
         queryset=ConfigContextProfile.objects.all(),
@@ -514,19 +487,15 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
         required=False,
         required=False,
         label=_('Tags')
         label=_('Tags')
     )
     )
-    owner_id = DynamicModelChoiceField(
-        queryset=Owner.objects.all(),
-        required=False,
-        label=_('Owner'),
-    )
 
 
 
 
-class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
+class ConfigTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
     model = ConfigTemplate
     model = ConfigTemplate
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('data_source_id', 'data_file_id', 'auto_sync_enabled', name=_('Data')),
         FieldSet('data_source_id', 'data_file_id', 'auto_sync_enabled', name=_('Data')),
-        FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering'))
+        FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     data_source_id = DynamicModelMultipleChoiceField(
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
@@ -568,11 +537,6 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
             choices=BOOLEAN_WITH_BLANK_CHOICES
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
         )
     )
     )
-    owner_id = DynamicModelChoiceField(
-        queryset=Owner.objects.all(),
-        required=False,
-        label=_('Owner'),
-    )
 
 
 
 
 class LocalConfigContextFilterForm(forms.Form):
 class LocalConfigContextFilterForm(forms.Form):

+ 7 - 0
netbox/extras/forms/model_forms.py

@@ -178,6 +178,13 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
         ) + ' <code>choice1:First Choice</code>')
         ) + ' <code>choice1:First Choice</code>')
     )
     )
 
 
+    fieldsets = (
+        FieldSet(
+            'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
+            name=_('Custom Field Choice Set')
+        ),
+    )
+
     class Meta:
     class Meta:
         model = CustomFieldChoiceSet
         model = CustomFieldChoiceSet
         fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'owner')
         fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'owner')

+ 14 - 1
netbox/extras/models/customfields.py

@@ -19,6 +19,7 @@ from django.utils.translation import gettext_lazy as _
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.data import CHOICE_SETS
 from extras.data import CHOICE_SETS
+from netbox.context import query_cache
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from netbox.models.mixins import OwnerMixin
 from netbox.models.mixins import OwnerMixin
@@ -58,8 +59,20 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
         """
         """
         Return all CustomFields assigned to the given model.
         Return all CustomFields assigned to the given model.
         """
         """
+        # Check the request cache before hitting the database
+        cache = query_cache.get()
+        if cache is not None:
+            if custom_fields := cache['custom_fields'].get(model._meta.model):
+                return custom_fields
+
         content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
         content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
-        return self.get_queryset().filter(object_types=content_type)
+        custom_fields = self.get_queryset().filter(object_types=content_type)
+
+        # Populate the request cache to avoid redundant lookups
+        if cache is not None:
+            cache['custom_fields'][model._meta.model] = custom_fields
+
+        return custom_fields
 
 
     def get_defaults_for_model(self, model):
     def get_defaults_for_model(self, model):
         """
         """

+ 1 - 1
netbox/extras/models/scripts.py

@@ -137,7 +137,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
             module = self.get_module()
             module = self.get_module()
         except Exception as e:
         except Exception as e:
             self.error = e
             self.error = e
-            logger.debug(f"Failed to load script: {self.python_name} error: {e}")
+            logger.error(f"Failed to load script: {self.python_name} error: {e}")
             module = None
             module = None
 
 
         scripts = {}
         scripts = {}

+ 1 - 1
netbox/extras/scripts.py

@@ -61,7 +61,7 @@ class ScriptVariable:
             self.field_attrs['label'] = label
             self.field_attrs['label'] = label
         if description:
         if description:
             self.field_attrs['help_text'] = description
             self.field_attrs['help_text'] = description
-        if default:
+        if default is not None:
             self.field_attrs['initial'] = default
             self.field_attrs['initial'] = default
         if widget:
         if widget:
             self.field_attrs['widget'] = widget
             self.field_attrs['widget'] = widget

+ 9 - 12
netbox/extras/signals.py

@@ -4,11 +4,12 @@ from django.dispatch import receiver
 
 
 from core.events import *
 from core.events import *
 from core.signals import job_end, job_start
 from core.signals import job_end, job_start
-from extras.events import process_event_rules
+from extras.events import EventContext, process_event_rules
 from extras.models import EventRule, Notification, Subscription
 from extras.models import EventRule, Notification, Subscription
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.models.features import has_feature
 from netbox.models.features import has_feature
 from netbox.signals import post_clean
 from netbox.signals import post_clean
+from utilities.data import get_config_value_ci
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
 from .models import CustomField, TaggedItem
 from .models import CustomField, TaggedItem
 from .utils import run_validators
 from .utils import run_validators
@@ -65,7 +66,7 @@ def run_save_validators(sender, instance, **kwargs):
     Run any custom validation rules for the model prior to calling save().
     Run any custom validation rules for the model prior to calling save().
     """
     """
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
-    validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
+    validators = get_config_value_ci(get_config().CUSTOM_VALIDATORS, model_name, default=[])
 
 
     run_validators(instance, validators)
     run_validators(instance, validators)
 
 
@@ -102,14 +103,12 @@ def process_job_start_event_rules(sender, **kwargs):
         enabled=True,
         enabled=True,
         object_types=sender.object_type
         object_types=sender.object_type
     )
     )
-    username = sender.user.username if sender.user else None
-    process_event_rules(
-        event_rules=event_rules,
-        object_type=sender.object_type,
+    event = EventContext(
         event_type=JOB_STARTED,
         event_type=JOB_STARTED,
         data=sender.data,
         data=sender.data,
-        username=username
+        user=sender.user,
     )
     )
+    process_event_rules(event_rules, sender.object_type, event)
 
 
 
 
 @receiver(job_end)
 @receiver(job_end)
@@ -122,14 +121,12 @@ def process_job_end_event_rules(sender, **kwargs):
         enabled=True,
         enabled=True,
         object_types=sender.object_type
         object_types=sender.object_type
     )
     )
-    username = sender.user.username if sender.user else None
-    process_event_rules(
-        event_rules=event_rules,
-        object_type=sender.object_type,
+    event = EventContext(
         event_type=JOB_COMPLETED,
         event_type=JOB_COMPLETED,
         data=sender.data,
         data=sender.data,
-        username=username
+        user=sender.user,
     )
     )
+    process_event_rules(event_rules, sender.object_type, event)
 
 
 
 
 #
 #

+ 41 - 5
netbox/extras/tests/test_views.py

@@ -1,6 +1,7 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 from django.test import tag
 from django.test import tag
+from unittest.mock import patch, PropertyMock
 
 
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
 from core.events import *
 from core.events import *
@@ -906,7 +907,7 @@ class ScriptValidationErrorTest(TestCase):
     user_permissions = ['extras.view_script', 'extras.run_script']
     user_permissions = ['extras.view_script', 'extras.run_script']
 
 
     class TestScriptMixin:
     class TestScriptMixin:
-        bar = IntegerVar(min_value=0, max_value=30, default=30)
+        bar = IntegerVar(min_value=0, max_value=30)
 
 
     class TestScriptClass(TestScriptMixin, PythonClass):
     class TestScriptClass(TestScriptMixin, PythonClass):
         class Meta:
         class Meta:
@@ -930,8 +931,6 @@ class ScriptValidationErrorTest(TestCase):
 
 
     @tag('regression')
     @tag('regression')
     def test_script_validation_error_displays_message(self):
     def test_script_validation_error_displays_message(self):
-        from unittest.mock import patch
-
         url = reverse('extras:script', kwargs={'pk': self.script.pk})
         url = reverse('extras:script', kwargs={'pk': self.script.pk})
 
 
         with patch('extras.views.get_workers_for_queue', return_value=['worker']):
         with patch('extras.views.get_workers_for_queue', return_value=['worker']):
@@ -944,8 +943,6 @@ class ScriptValidationErrorTest(TestCase):
 
 
     @tag('regression')
     @tag('regression')
     def test_script_validation_error_no_toast_for_fieldset_fields(self):
     def test_script_validation_error_no_toast_for_fieldset_fields(self):
-        from unittest.mock import patch, PropertyMock
-
         class FieldsetScript(PythonClass):
         class FieldsetScript(PythonClass):
             class Meta:
             class Meta:
                 name = 'Fieldset test'
                 name = 'Fieldset test'
@@ -967,3 +964,42 @@ class ScriptValidationErrorTest(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         messages = list(response.context['messages'])
         messages = list(response.context['messages'])
         self.assertEqual(len(messages), 0)
         self.assertEqual(len(messages), 0)
+
+
+class ScriptDefaultValuesTest(TestCase):
+    user_permissions = ['extras.view_script', 'extras.run_script']
+
+    class TestScriptClass(PythonClass):
+        class Meta:
+            name = 'Test script'
+            commit_default = False
+
+        bool_default_true = BooleanVar(default=True)
+        bool_default_false = BooleanVar(default=False)
+        int_with_default = IntegerVar(default=0)
+        int_without_default = IntegerVar(required=False)
+
+        def run(self, data, commit):
+            return "Complete"
+
+    @classmethod
+    def setUpTestData(cls):
+        module = ScriptModule.objects.create(file_root=ManagedFileRootPathChoices.SCRIPTS, file_path='test_script.py')
+        cls.script = Script.objects.create(module=module, name='Test script', is_executable=True)
+
+    def setUp(self):
+        super().setUp()
+        Script.python_class = property(lambda self: ScriptDefaultValuesTest.TestScriptClass)
+
+    def test_default_values_are_used(self):
+        url = reverse('extras:script', kwargs={'pk': self.script.pk})
+
+        with patch('extras.views.get_workers_for_queue', return_value=['worker']):
+            with patch('extras.jobs.ScriptJob.enqueue') as mock_enqueue:
+                mock_enqueue.return_value.pk = 1
+                self.client.post(url, {})
+                call_kwargs = mock_enqueue.call_args.kwargs
+                self.assertEqual(call_kwargs['data']['bool_default_true'], True)
+                self.assertEqual(call_kwargs['data']['bool_default_false'], False)
+                self.assertEqual(call_kwargs['data']['int_with_default'], 0)
+                self.assertIsNone(call_kwargs['data']['int_without_default'])

+ 7 - 1
netbox/extras/views.py

@@ -1511,7 +1511,13 @@ class ScriptView(BaseScriptView):
                 'script': script,
                 'script': script,
             })
             })
 
 
-        form = script_class.as_form(request.POST, request.FILES)
+        # Populate missing variables with their default values, if defined
+        post_data = request.POST.copy()
+        for name, var in script_class._get_vars().items():
+            if name not in post_data and (initial := var.field_attrs.get('initial')) is not None:
+                post_data[name] = initial
+
+        form = script_class.as_form(post_data, request.FILES)
 
 
         # Allow execution only if RQ worker process is running
         # Allow execution only if RQ worker process is running
         if not get_workers_for_queue('default'):
         if not get_workers_for_queue('default'):

+ 33 - 17
netbox/ipam/forms/filtersets.py

@@ -45,9 +45,10 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
 class VRFFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class VRFFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     model = VRF
     model = VRF
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('import_target_id', 'export_target_id', name=_('Route Targets')),
         FieldSet('import_target_id', 'export_target_id', name=_('Route Targets')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     import_target_id = DynamicModelMultipleChoiceField(
     import_target_id = DynamicModelMultipleChoiceField(
         queryset=RouteTarget.objects.all(),
         queryset=RouteTarget.objects.all(),
@@ -65,9 +66,10 @@ class VRFFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class RouteTargetFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class RouteTargetFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     model = RouteTarget
     model = RouteTarget
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('importing_vrf_id', 'exporting_vrf_id', name=_('VRF')),
         FieldSet('importing_vrf_id', 'exporting_vrf_id', name=_('VRF')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     importing_vrf_id = DynamicModelMultipleChoiceField(
     importing_vrf_id = DynamicModelMultipleChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
@@ -85,8 +87,9 @@ class RouteTargetFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class RIRFilterForm(OrganizationalModelFilterSetForm):
 class RIRFilterForm(OrganizationalModelFilterSetForm):
     model = RIR
     model = RIR
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('is_private', name=_('RIR')),
         FieldSet('is_private', name=_('RIR')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     is_private = forms.NullBooleanField(
     is_private = forms.NullBooleanField(
         required=False,
         required=False,
@@ -101,9 +104,10 @@ class RIRFilterForm(OrganizationalModelFilterSetForm):
 class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
 class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
     model = Aggregate
     model = Aggregate
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('family', 'rir_id', name=_('Attributes')),
         FieldSet('family', 'rir_id', name=_('Attributes')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
     )
     family = forms.ChoiceField(
     family = forms.ChoiceField(
@@ -122,9 +126,10 @@ class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
 class ASNRangeFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
 class ASNRangeFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
     model = ASNRange
     model = ASNRange
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('rir_id', 'start', 'end', name=_('Range')),
         FieldSet('rir_id', 'start', 'end', name=_('Range')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     rir_id = DynamicModelMultipleChoiceField(
     rir_id = DynamicModelMultipleChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
@@ -145,9 +150,10 @@ class ASNRangeFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
 class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     model = ASN
     model = ASN
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('rir_id', 'site_group_id', 'site_id', name=_('Assignment')),
         FieldSet('rir_id', 'site_group_id', 'site_id', name=_('Assignment')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     rir_id = DynamicModelMultipleChoiceField(
     rir_id = DynamicModelMultipleChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
@@ -170,7 +176,8 @@ class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class RoleFilterForm(OrganizationalModelFilterSetForm):
 class RoleFilterForm(OrganizationalModelFilterSetForm):
     model = Role
     model = Role
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
@@ -178,7 +185,7 @@ class RoleFilterForm(OrganizationalModelFilterSetForm):
 class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
 class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
     model = Prefix
     model = Prefix
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet(
         FieldSet(
             'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
             'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
             name=_('Addressing')
             name=_('Addressing')
@@ -187,6 +194,7 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
         FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
         FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
     )
     mask_length__lte = forms.IntegerField(
     mask_length__lte = forms.IntegerField(
@@ -284,9 +292,10 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
 class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
 class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
     model = IPRange
     model = IPRange
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')),
         FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
     )
     family = forms.ChoiceField(
     family = forms.ChoiceField(
@@ -331,14 +340,15 @@ class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelF
 class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
 class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
     model = IPAddress
     model = IPAddress
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet(
         FieldSet(
             'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name',
             'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name',
             name=_('Attributes')
             name=_('Attributes')
         ),
         ),
         FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
         FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
-        FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
         FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
+        FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
     )
     selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
     selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
@@ -409,9 +419,10 @@ class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
 class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
 class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
     model = FHRPGroup
     model = FHRPGroup
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'protocol', 'group_id', name=_('Attributes')),
         FieldSet('name', 'protocol', 'group_id', name=_('Attributes')),
         FieldSet('auth_type', 'auth_key', name=_('Authentication')),
         FieldSet('auth_type', 'auth_key', name=_('Authentication')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     name = forms.CharField(
     name = forms.CharField(
         label=_('Name'),
         label=_('Name'),
@@ -441,11 +452,12 @@ class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
 
 
 class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
 class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region', 'site_group', 'site', 'location', 'rack', name=_('Location')),
         FieldSet('region', 'site_group', 'site', 'location', 'rack', name=_('Location')),
         FieldSet('cluster_group', 'cluster', name=_('Cluster')),
         FieldSet('cluster_group', 'cluster', name=_('Cluster')),
         FieldSet('contains_vid', name=_('VLANs')),
         FieldSet('contains_vid', name=_('VLANs')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     model = VLANGroup
     model = VLANGroup
     region = DynamicModelMultipleChoiceField(
     region = DynamicModelMultipleChoiceField(
@@ -495,8 +507,9 @@ class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
 class VLANTranslationPolicyFilterForm(PrimaryModelFilterSetForm):
 class VLANTranslationPolicyFilterForm(PrimaryModelFilterSetForm):
     model = VLANTranslationPolicy
     model = VLANTranslationPolicy
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', name=_('Attributes')),
         FieldSet('name', name=_('Attributes')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     name = forms.CharField(
     name = forms.CharField(
         required=False,
         required=False,
@@ -532,11 +545,12 @@ class VLANTranslationRuleFilterForm(NetBoxModelFilterSetForm):
 class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     model = VLAN
     model = VLAN
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
         FieldSet('group_id', 'status', 'role_id', 'vid', 'l2vpn_id', name=_('Attributes')),
         FieldSet('group_id', 'status', 'role_id', 'vid', 'l2vpn_id', name=_('Attributes')),
         FieldSet('qinq_role', 'qinq_svlan_id', name=_('Q-in-Q/802.1ad')),
         FieldSet('qinq_role', 'qinq_svlan_id', name=_('Q-in-Q/802.1ad')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     selector_fields = ('filter_id', 'q', 'group_id')
     selector_fields = ('filter_id', 'q', 'group_id')
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
@@ -604,8 +618,9 @@ class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
 class ServiceTemplateFilterForm(PrimaryModelFilterSetForm):
 class ServiceTemplateFilterForm(PrimaryModelFilterSetForm):
     model = ServiceTemplate
     model = ServiceTemplate
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('protocol', 'port', name=_('Attributes')),
         FieldSet('protocol', 'port', name=_('Attributes')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     protocol = forms.ChoiceField(
     protocol = forms.ChoiceField(
         label=_('Protocol'),
         label=_('Protocol'),
@@ -622,9 +637,10 @@ class ServiceTemplateFilterForm(PrimaryModelFilterSetForm):
 class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
 class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
     model = Service
     model = Service
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('protocol', 'port', name=_('Attributes')),
         FieldSet('protocol', 'port', name=_('Attributes')),
         FieldSet('device_id', 'virtual_machine_id', 'fhrpgroup_id', name=_('Assignment')),
         FieldSet('device_id', 'virtual_machine_id', 'fhrpgroup_id', name=_('Assignment')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
     )
     device_id = DynamicModelMultipleChoiceField(
     device_id = DynamicModelMultipleChoiceField(

+ 3 - 1
netbox/ipam/models/services.py

@@ -87,7 +87,9 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel):
         help_text=_("The specific IP addresses (if any) to which this application service is bound")
         help_text=_("The specific IP addresses (if any) to which this application service is bound")
     )
     )
 
 
-    clone_fields = ['protocol', 'ports', 'description', 'parent', 'ipaddresses', ]
+    clone_fields = (
+        'protocol', 'ports', 'description', 'parent_object_type', 'parent_object_id', 'ipaddresses',
+    )
 
 
     class Meta:
     class Meta:
         indexes = (
         indexes = (

+ 5 - 0
netbox/ipam/tables/ip.py

@@ -370,6 +370,11 @@ class AnnotatedIPAddressTable(IPAddressTable):
         verbose_name=_('IP Address')
         verbose_name=_('IP Address')
     )
     )
 
 
+    def render_pk(self, value, record, bound_column):
+        if type(record) is not self._meta.model:
+            return ''
+        return bound_column.column.render(value, bound_column, record)
+
     class Meta(IPAddressTable.Meta):
     class Meta(IPAddressTable.Meta):
         pass
         pass
 
 

+ 1 - 1
netbox/ipam/tables/template_code.py

@@ -6,7 +6,7 @@ PREFIX_LINK = """
 {% if record.pk %}
 {% if record.pk %}
   <a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
   <a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
 {% else %}
 {% else %}
-  <a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
+  <a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.scope %}&scope_type={{ object.scope_type.pk }}&scope={{ object.scope.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
 {% endif %}
 {% endif %}
 """
 """
 
 

+ 18 - 2
netbox/ipam/tables/vlans.py

@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
 from dcim.models import Interface
 from dcim.models import Interface
+from dcim.tables.template_code import INTERFACE_LINKTERMINATION, LINKTERMINATION
 from ipam.models import *
 from ipam.models import *
 from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
 from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
 from tenancy.tables import TenancyColumnsMixin, TenantColumn
 from tenancy.tables import TenancyColumnsMixin, TenantColumn
@@ -159,11 +160,26 @@ class VLANDevicesTable(VLANMembersTable):
     actions = columns.ActionsColumn(
     actions = columns.ActionsColumn(
         actions=('edit',)
         actions=('edit',)
     )
     )
+    link_peer = columns.TemplateColumn(
+        accessor='link_peers',
+        template_code=LINKTERMINATION,
+        orderable=False,
+        verbose_name=_('Link Peers'),
+    )
+
+    # Override PathEndpointTable.connection to accommodate virtual circuits
+    connection = columns.TemplateColumn(
+        accessor='_path__destinations',
+        template_code=INTERFACE_LINKTERMINATION,
+        orderable=False,
+        verbose_name=_('Connection'),
+    )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Interface
         model = Interface
-        fields = ('device', 'name', 'tagged', 'actions')
-        exclude = ('id', )
+        fields = ('device', 'name', 'link_peer', 'connection', 'tagged', 'actions')
+        default_columns = ('device', 'name', 'connection', 'tagged', 'actions')
+        exclude = ('id',)
 
 
 
 
 class VLANVirtualMachinesTable(VLANMembersTable):
 class VLANVirtualMachinesTable(VLANMembersTable):

+ 41 - 0
netbox/ipam/tests/test_tables.py

@@ -0,0 +1,41 @@
+from django.test import RequestFactory, TestCase
+from netaddr import IPNetwork
+
+from ipam.models import IPAddress, IPRange, Prefix
+from ipam.tables import AnnotatedIPAddressTable
+from ipam.utils import annotate_ip_space
+
+
+class AnnotatedIPAddressTableTest(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.prefix = Prefix.objects.create(
+            prefix=IPNetwork('10.1.1.0/24'),
+            status='active'
+        )
+
+        cls.ip_address = IPAddress.objects.create(
+            address='10.1.1.1/24',
+            status='active'
+        )
+
+        cls.ip_range = IPRange.objects.create(
+            start_address=IPNetwork('10.1.1.2/24'),
+            end_address=IPNetwork('10.1.1.10/24'),
+            status='active'
+        )
+
+    def test_ipaddress_has_checkbox_iprange_does_not(self):
+        data = annotate_ip_space(self.prefix)
+        table = AnnotatedIPAddressTable(data, orderable=False)
+        table.columns.show('pk')
+
+        request = RequestFactory().get('/')
+        html = table.as_html(request)
+
+        ipaddress_checkbox_count = html.count(f'name="pk" value="{self.ip_address.pk}"')
+        self.assertEqual(ipaddress_checkbox_count, 1)
+
+        iprange_checkbox_count = html.count(f'name="pk" value="{self.ip_range.pk}"')
+        self.assertEqual(iprange_checkbox_count, 0)

+ 3 - 0
netbox/ipam/utils.py

@@ -49,6 +49,9 @@ def add_requested_prefixes(parent, prefix_list, show_available=True, show_assign
     if prefix_list and show_available:
     if prefix_list and show_available:
 
 
         # Find all unallocated space, add fake Prefix objects to child_prefixes.
         # Find all unallocated space, add fake Prefix objects to child_prefixes.
+        # IMPORTANT: These are unsaved Prefix instances (pk=None). If this is ever changed to use
+        # saved Prefix instances with real pks, bulk delete will fail for mixed-type selections
+        # due to single-model form validation. See: https://github.com/netbox-community/netbox/issues/21176
         available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
         available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
         available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
         available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
         child_prefixes = child_prefixes + available_prefixes
         child_prefixes = child_prefixes + available_prefixes

+ 2 - 1
netbox/netbox/api/fields.py

@@ -1,3 +1,4 @@
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
@@ -109,7 +110,7 @@ class ContentTypeField(RelatedField):
     def to_internal_value(self, data):
     def to_internal_value(self, data):
         try:
         try:
             app_label, model = data.split('.')
             app_label, model = data.split('.')
-            return self.queryset.get(app_label=app_label, model=model)
+            return ContentType.objects.get_by_natural_key(app_label=app_label, model=model)
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:
             self.fail('does_not_exist', content_type=data)
             self.fail('does_not_exist', content_type=data)
         except (AttributeError, TypeError, ValueError):
         except (AttributeError, TypeError, ValueError):

+ 21 - 16
netbox/netbox/api/serializers/base.py

@@ -1,9 +1,8 @@
 from functools import cached_property
 from functools import cached_property
 
 
-from rest_framework import serializers
-from rest_framework.utils.serializer_helpers import BindingDict
-from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
 
 
 from utilities.api import get_related_object_by_attrs
 from utilities.api import get_related_object_by_attrs
 from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField
 from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField
@@ -19,16 +18,18 @@ class BaseModelSerializer(serializers.ModelSerializer):
     display_url = NetBoxURLHyperlinkedIdentityField()
     display_url = NetBoxURLHyperlinkedIdentityField()
     display = serializers.SerializerMethodField(read_only=True)
     display = serializers.SerializerMethodField(read_only=True)
 
 
-    def __init__(self, *args, nested=False, fields=None, **kwargs):
+    def __init__(self, *args, nested=False, fields=None, omit=None, **kwargs):
         """
         """
         Extends the base __init__() method to support dynamic fields.
         Extends the base __init__() method to support dynamic fields.
 
 
         :param nested: Set to True if this serializer is being employed within a parent serializer
         :param nested: Set to True if this serializer is being employed within a parent serializer
         :param fields: An iterable of fields to include when rendering the serialized object, If nested is
         :param fields: An iterable of fields to include when rendering the serialized object, If nested is
             True but no fields are specified, Meta.brief_fields will be used.
             True but no fields are specified, Meta.brief_fields will be used.
+        :param omit: An iterable of fields to omit from the serialized object
         """
         """
         self.nested = nested
         self.nested = nested
-        self._requested_fields = fields
+        self._include_fields = fields or []
+        self._omit_fields = omit or []
 
 
         # Disable validators for nested objects (which already exist)
         # Disable validators for nested objects (which already exist)
         if self.nested:
         if self.nested:
@@ -36,8 +37,8 @@ class BaseModelSerializer(serializers.ModelSerializer):
 
 
         # If this serializer is nested but no fields have been specified,
         # If this serializer is nested but no fields have been specified,
         # default to using Meta.brief_fields (if set)
         # default to using Meta.brief_fields (if set)
-        if self.nested and not fields:
-            self._requested_fields = getattr(self.Meta, 'brief_fields', None)
+        if self.nested and not fields and not omit:
+            self._include_fields = getattr(self.Meta, 'brief_fields', None)
 
 
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
@@ -54,16 +55,19 @@ class BaseModelSerializer(serializers.ModelSerializer):
     @cached_property
     @cached_property
     def fields(self):
     def fields(self):
         """
         """
-        Override the fields property to check for requested fields. If defined,
-        return only the applicable fields.
+        Override the fields property to return only specifically requested fields if needed.
         """
         """
-        if not self._requested_fields:
-            return super().fields
+        fields = super().fields
+
+        # Include only requested fields
+        if self._include_fields:
+            for field_name in set(fields) - set(self._include_fields):
+                fields.pop(field_name, None)
+
+        # Remove omitted fields
+        for field_name in set(self._omit_fields):
+            fields.pop(field_name, None)
 
 
-        fields = BindingDict(self)
-        for key, value in self.get_fields().items():
-            if key in self._requested_fields:
-                fields[key] = value
         return fields
         return fields
 
 
     @extend_schema_field(OpenApiTypes.STR)
     @extend_schema_field(OpenApiTypes.STR)
@@ -108,6 +112,7 @@ class ValidatedModelSerializer(BaseModelSerializer):
             for k, v in attrs.items():
             for k, v in attrs.items():
                 setattr(instance, k, v)
                 setattr(instance, k, v)
         instance._m2m_values = m2m_values
         instance._m2m_values = m2m_values
-        instance.full_clean()
+        # Skip uniqueness validation of individual fields inside `full_clean()` (this is handled by the serializer)
+        instance.full_clean(validate_unique=False)
 
 
         return data
         return data

+ 54 - 16
netbox/netbox/api/viewsets/__init__.py

@@ -5,13 +5,13 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import router, transaction
 from django.db import router, transaction
 from django.db.models import ProtectedError, RestrictedError
 from django.db.models import ProtectedError, RestrictedError
 from django_pglocks import advisory_lock
 from django_pglocks import advisory_lock
-from netbox.constants import ADVISORY_LOCK_KEYS
 from rest_framework import mixins as drf_mixins
 from rest_framework import mixins as drf_mixins
 from rest_framework import status
 from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet
 from rest_framework.viewsets import GenericViewSet
 
 
 from netbox.api.serializers.features import ChangeLogMessageSerializer
 from netbox.api.serializers.features import ChangeLogMessageSerializer
+from netbox.constants import ADVISORY_LOCK_KEYS
 from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
 from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
 from utilities.query import reapply_model_ordering
 from utilities.query import reapply_model_ordering
@@ -59,33 +59,38 @@ class BaseViewSet(GenericViewSet):
         serializer_class = self.get_serializer_class()
         serializer_class = self.get_serializer_class()
 
 
         # Dynamically resolve prefetches for included serializer fields and attach them to the queryset
         # Dynamically resolve prefetches for included serializer fields and attach them to the queryset
-        if prefetch := get_prefetches_for_serializer(serializer_class, fields_to_include=self.requested_fields):
+        if prefetch := get_prefetches_for_serializer(serializer_class, **self.field_kwargs):
             qs = qs.prefetch_related(*prefetch)
             qs = qs.prefetch_related(*prefetch)
 
 
         # Dynamically resolve annotations for RelatedObjectCountFields on the serializer and attach them to the queryset
         # Dynamically resolve annotations for RelatedObjectCountFields on the serializer and attach them to the queryset
-        if annotations := get_annotations_for_serializer(serializer_class, fields_to_include=self.requested_fields):
+        if annotations := get_annotations_for_serializer(serializer_class, **self.field_kwargs):
             qs = qs.annotate(**annotations)
             qs = qs.annotate(**annotations)
 
 
         return qs
         return qs
 
 
     def get_serializer(self, *args, **kwargs):
     def get_serializer(self, *args, **kwargs):
-
-        # If specific fields have been requested, pass them to the serializer
-        if self.requested_fields:
-            kwargs['fields'] = self.requested_fields
-
+        # Pass the fields/omit kwargs (if specified by the request) to the serializer
+        kwargs.update(**self.field_kwargs)
         return super().get_serializer(*args, **kwargs)
         return super().get_serializer(*args, **kwargs)
 
 
     @cached_property
     @cached_property
-    def requested_fields(self):
+    def field_kwargs(self):
+        """Return a dictionary of keyword arguments to be passed when instantiating the serializer."""
         # An explicit list of fields was requested
         # An explicit list of fields was requested
         if requested_fields := self.request.query_params.get('fields'):
         if requested_fields := self.request.query_params.get('fields'):
-            return requested_fields.split(',')
+            return {'fields': requested_fields.split(',')}
+
+        # An explicit list of fields to omit was requested
+        if omit_fields := self.request.query_params.get('omit'):
+            return {'omit': omit_fields.split(',')}
+
         # Brief mode has been enabled for this request
         # Brief mode has been enabled for this request
-        elif self.brief:
+        if self.brief:
             serializer_class = self.get_serializer_class()
             serializer_class = self.get_serializer_class()
-            return getattr(serializer_class.Meta, 'brief_fields', None)
-        return None
+            if brief_fields := getattr(serializer_class.Meta, 'brief_fields', None):
+                return {'fields': brief_fields}
+
+        return {}
 
 
 
 
 class NetBoxReadOnlyModelViewSet(
 class NetBoxReadOnlyModelViewSet(
@@ -165,6 +170,28 @@ class NetBoxModelViewSet(
 
 
     # Creates
     # Creates
 
 
+    def create(self, request, *args, **kwargs):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        bulk_create = getattr(serializer, 'many', False)
+        self.perform_create(serializer)
+
+        # After creating the instance(s), re-initialize the serializer with a queryset
+        # to ensure related objects are prefetched.
+        if bulk_create:
+            instance_pks = [obj.pk for obj in serializer.instance]
+            # Order by PK to ensure that the ordering of objects in the response
+            # matches the ordering of those in the request.
+            qs = self.get_queryset().filter(pk__in=instance_pks).order_by('pk')
+        else:
+            qs = self.get_queryset().get(pk=serializer.instance.pk)
+
+        # Re-serialize the instance(s) with prefetched data
+        serializer = self.get_serializer(qs, many=bulk_create)
+
+        headers = self.get_success_headers(serializer.data)
+        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+
     def perform_create(self, serializer):
     def perform_create(self, serializer):
         model = self.queryset.model
         model = self.queryset.model
         logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
         logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
@@ -181,9 +208,20 @@ class NetBoxModelViewSet(
     # Updates
     # Updates
 
 
     def update(self, request, *args, **kwargs):
     def update(self, request, *args, **kwargs):
-        # Hotwire get_object() to ensure we save a pre-change snapshot
-        self.get_object = self.get_object_with_snapshot
-        return super().update(request, *args, **kwargs)
+        partial = kwargs.pop('partial', False)
+        instance = self.get_object_with_snapshot()
+        serializer = self.get_serializer(instance, data=request.data, partial=partial)
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+
+        # After updating the instance, re-initialize the serializer with a queryset
+        # to ensure related objects are prefetched.
+        qs = self.get_queryset().get(pk=serializer.instance.pk)
+
+        # Re-serialize the instance(s) with prefetched data
+        serializer = self.get_serializer(qs)
+
+        return Response(serializer.data)
 
 
     def perform_update(self, serializer):
     def perform_update(self, serializer):
         model = self.queryset.model
         model = self.queryset.model

+ 9 - 5
netbox/netbox/api/viewsets/mixins.py

@@ -108,13 +108,17 @@ class BulkUpdateModelMixin:
             obj.pop('id'): obj for obj in request.data
             obj.pop('id'): obj for obj in request.data
         }
         }
 
 
-        data = self.perform_bulk_update(qs, update_data, partial=partial)
+        object_pks = self.perform_bulk_update(qs, update_data, partial=partial)
 
 
-        return Response(data, status=status.HTTP_200_OK)
+        # Prefetch related objects for all updated instances
+        qs = self.get_queryset().filter(pk__in=object_pks)
+        serializer = self.get_serializer(qs, many=True)
+
+        return Response(serializer.data, status=status.HTTP_200_OK)
 
 
     def perform_bulk_update(self, objects, update_data, partial):
     def perform_bulk_update(self, objects, update_data, partial):
+        updated_pks = []
         with transaction.atomic(using=router.db_for_write(self.queryset.model)):
         with transaction.atomic(using=router.db_for_write(self.queryset.model)):
-            data_list = []
             for obj in objects:
             for obj in objects:
                 data = update_data.get(obj.id)
                 data = update_data.get(obj.id)
                 if hasattr(obj, 'snapshot'):
                 if hasattr(obj, 'snapshot'):
@@ -122,9 +126,9 @@ class BulkUpdateModelMixin:
                 serializer = self.get_serializer(obj, data=data, partial=partial)
                 serializer = self.get_serializer(obj, data=data, partial=partial)
                 serializer.is_valid(raise_exception=True)
                 serializer.is_valid(raise_exception=True)
                 self.perform_update(serializer)
                 self.perform_update(serializer)
-                data_list.append(serializer.data)
+                updated_pks.append(obj.pk)
 
 
-            return data_list
+        return updated_pks
 
 
     def bulk_partial_update(self, request, *args, **kwargs):
     def bulk_partial_update(self, request, *args, **kwargs):
         kwargs['partial'] = True
         kwargs['partial'] = True

+ 2 - 0
netbox/netbox/context.py

@@ -3,8 +3,10 @@ from contextvars import ContextVar
 __all__ = (
 __all__ = (
     'current_request',
     'current_request',
     'events_queue',
     'events_queue',
+    'query_cache',
 )
 )
 
 
 
 
 current_request = ContextVar('current_request', default=None)
 current_request = ContextVar('current_request', default=None)
 events_queue = ContextVar('events_queue', default=dict())
 events_queue = ContextVar('events_queue', default=dict())
+query_cache = ContextVar('query_cache', default=None)

+ 4 - 1
netbox/netbox/context_managers.py

@@ -1,6 +1,7 @@
+from collections import defaultdict
 from contextlib import contextmanager
 from contextlib import contextmanager
 
 
-from netbox.context import current_request, events_queue
+from netbox.context import current_request, events_queue, query_cache
 from netbox.utils import register_request_processor
 from netbox.utils import register_request_processor
 from extras.events import flush_events
 from extras.events import flush_events
 
 
@@ -16,6 +17,7 @@ def event_tracking(request):
     """
     """
     current_request.set(request)
     current_request.set(request)
     events_queue.set({})
     events_queue.set({})
+    query_cache.set(defaultdict(dict))
 
 
     yield
     yield
 
 
@@ -26,3 +28,4 @@ def event_tracking(request):
     # Clear context vars
     # Clear context vars
     current_request.set(None)
     current_request.set(None)
     events_queue.set({})
     events_queue.set({})
+    query_cache.set(None)

+ 6 - 11
netbox/netbox/filtersets.py

@@ -305,18 +305,13 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        # Dynamically add a Filter for each CustomField applicable to the parent model
-        custom_fields = CustomField.objects.filter(
-            object_types=ContentType.objects.get_for_model(self._meta.model)
-        ).exclude(
-            filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
-        )
-
         custom_field_filters = {}
         custom_field_filters = {}
-        for custom_field in custom_fields:
-            filter_name = f'cf_{custom_field.name}'
-            filter_instance = custom_field.to_filter()
-            if filter_instance:
+        for custom_field in CustomField.objects.get_for_model(self._meta.model):
+            if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_DISABLED:
+                # Skip disabled fields
+                continue
+            if filter_instance := custom_field.to_filter():
+                filter_name = f'cf_{custom_field.name}'
                 custom_field_filters[filter_name] = filter_instance
                 custom_field_filters[filter_name] = filter_instance
 
 
                 # Add relevant additional lookups
                 # Add relevant additional lookups

+ 5 - 4
netbox/netbox/forms/bulk_import.py

@@ -31,10 +31,11 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
     )
     )
 
 
     def _get_custom_fields(self, content_type):
     def _get_custom_fields(self, content_type):
-        return CustomField.objects.filter(
-            object_types=content_type,
-            ui_editable=CustomFieldUIEditableChoices.YES
-        )
+        # Return only custom fields that are editable in the UI
+        return [
+            cf for cf in CustomField.objects.get_for_model(content_type.model_class())
+            if cf.ui_editable == CustomFieldUIEditableChoices.YES
+        ]
 
 
     def _get_form_field(self, customfield):
     def _get_form_field(self, customfield):
         return customfield.to_form_field(for_csv_import=True)
         return customfield.to_form_field(for_csv_import=True)

+ 9 - 16
netbox/netbox/forms/filtersets.py

@@ -1,12 +1,10 @@
 from django import forms
 from django import forms
-from django.db.models import Q
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from extras.choices import *
 from extras.choices import *
-from users.models import Owner
-from utilities.forms.fields import DynamicModelChoiceField, QueryField
+from utilities.forms.fields import QueryField
 from utilities.forms.mixins import FilterModifierMixin
 from utilities.forms.mixins import FilterModifierMixin
-from .mixins import CustomFieldsMixin, SavedFiltersMixin
+from .mixins import CustomFieldsMixin, OwnerFilterMixin, SavedFiltersMixin
 
 
 __all__ = (
 __all__ = (
     'NestedGroupModelFilterSetForm',
     'NestedGroupModelFilterSetForm',
@@ -36,10 +34,13 @@ class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFilt
     selector_fields = ('filter_id', 'q')
     selector_fields = ('filter_id', 'q')
 
 
     def _get_custom_fields(self, content_type):
     def _get_custom_fields(self, content_type):
-        return super()._get_custom_fields(content_type).exclude(
-            Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
-            Q(type=CustomFieldTypeChoices.TYPE_JSON)
-        )
+        # Return only non-hidden custom fields for which filtering is enabled (excluding JSON fields)
+        return [
+            cf for cf in super()._get_custom_fields(content_type) if (
+                cf.filter_logic != CustomFieldFilterLogicChoices.FILTER_DISABLED and
+                cf.type != CustomFieldTypeChoices.TYPE_JSON
+            )
+        ]
 
 
     def _get_form_field(self, customfield):
     def _get_form_field(self, customfield):
         return customfield.to_form_field(
         return customfield.to_form_field(
@@ -47,14 +48,6 @@ class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFilt
         )
         )
 
 
 
 
-class OwnerFilterMixin(forms.Form):
-    owner_id = DynamicModelChoiceField(
-        queryset=Owner.objects.all(),
-        required=False,
-        label=_('Owner'),
-    )
-
-
 class PrimaryModelFilterSetForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
 class PrimaryModelFilterSetForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
     """
     """
     FilterSet form for models which inherit from PrimaryModel.
     FilterSet form for models which inherit from PrimaryModel.

+ 62 - 7
netbox/netbox/forms/mixins.py

@@ -4,13 +4,14 @@ from django.utils.translation import gettext as _
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
-from users.models import Owner
+from users.models import OwnerGroup, Owner
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 
 
 __all__ = (
 __all__ = (
     'ChangelogMessageMixin',
     'ChangelogMessageMixin',
     'CustomFieldsMixin',
     'CustomFieldsMixin',
     'OwnerMixin',
     'OwnerMixin',
+    'OwnerFilterMixin',
     'SavedFiltersMixin',
     'SavedFiltersMixin',
     'TagsMixin',
     'TagsMixin',
 )
 )
@@ -22,7 +23,7 @@ class ChangelogMessageMixin(forms.Form):
     """
     """
     changelog_message = forms.CharField(
     changelog_message = forms.CharField(
         required=False,
         required=False,
-        max_length=200
+        max_length=200,
     )
     )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -42,6 +43,7 @@ class CustomFieldsMixin:
     Attributes:
     Attributes:
         model: The model class
         model: The model class
     """
     """
+
     model = None
     model = None
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -63,9 +65,11 @@ class CustomFieldsMixin:
         return ObjectType.objects.get_for_model(self.model)
         return ObjectType.objects.get_for_model(self.model)
 
 
     def _get_custom_fields(self, content_type):
     def _get_custom_fields(self, content_type):
-        return CustomField.objects.filter(object_types=content_type).exclude(
-            ui_editable=CustomFieldUIEditableChoices.HIDDEN
-        )
+        # Return only custom fields that are not hidden from the UI
+        return [
+            cf for cf in CustomField.objects.get_for_model(content_type.model_class())
+            if cf.ui_editable != CustomFieldUIEditableChoices.HIDDEN
+        ]
 
 
     def _get_form_field(self, customfield):
     def _get_form_field(self, customfield):
         return customfield.to_form_field()
         return customfield.to_form_field()
@@ -86,13 +90,20 @@ class CustomFieldsMixin:
 
 
 
 
 class SavedFiltersMixin(forms.Form):
 class SavedFiltersMixin(forms.Form):
+    """
+    Form mixin for forms that support saved filters.
+
+    Provides a field for selecting a saved filter,
+    with options limited to those applicable to the form's model.
+    """
+
     filter_id = DynamicModelMultipleChoiceField(
     filter_id = DynamicModelMultipleChoiceField(
         queryset=SavedFilter.objects.all(),
         queryset=SavedFilter.objects.all(),
         required=False,
         required=False,
         label=_('Saved Filter'),
         label=_('Saved Filter'),
         query_params={
         query_params={
             'usable': True,
             'usable': True,
-        }
+        },
     )
     )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -107,6 +118,13 @@ class SavedFiltersMixin(forms.Form):
 
 
 
 
 class TagsMixin(forms.Form):
 class TagsMixin(forms.Form):
+    """
+    Mixin for forms that support tagging.
+
+    Provides a field for selecting tags,
+    with options limited to those applicable to the form's model.
+    """
+
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         required=False,
         required=False,
@@ -124,10 +142,47 @@ class TagsMixin(forms.Form):
 
 
 class OwnerMixin(forms.Form):
 class OwnerMixin(forms.Form):
     """
     """
-    Add an `owner` field to forms for models which support Owner assignment.
+    Mixin for forms which adds ownership fields.
+
+    Include this mixin in forms for models which
+    support owner and/or owner group assignment.
     """
     """
+
+    owner_group = DynamicModelChoiceField(
+        label=_('Owner group'),
+        queryset=OwnerGroup.objects.all(),
+        required=False,
+        null_option='None',
+        initial_params={'members': '$owner'},
+    )
     owner = DynamicModelChoiceField(
     owner = DynamicModelChoiceField(
         queryset=Owner.objects.all(),
         queryset=Owner.objects.all(),
         required=False,
         required=False,
+        query_params={'group_id': '$owner_group'},
+        label=_('Owner'),
+    )
+
+
+class OwnerFilterMixin(forms.Form):
+    """
+    Mixin for filterset forms which adds owner and owner group filtering.
+
+    Include this mixin in filterset forms for models
+    which support owner and/or owner group assignment.
+    """
+
+    owner_group_id = DynamicModelMultipleChoiceField(
+        queryset=OwnerGroup.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Owner Group'),
+    )
+    owner_id = DynamicModelMultipleChoiceField(
+        queryset=Owner.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'group_id': '$owner_group_id'
+        },
         label=_('Owner'),
         label=_('Owner'),
     )
     )

+ 50 - 0
netbox/netbox/graphql/pagination.py

@@ -0,0 +1,50 @@
+import strawberry
+from strawberry.types.unset import UNSET
+from strawberry_django.pagination import _QS, apply
+
+__all__ = (
+    'OffsetPaginationInfo',
+    'OffsetPaginationInput',
+    'apply_pagination',
+)
+
+
+@strawberry.type
+class OffsetPaginationInfo:
+    offset: int = 0
+    limit: int | None = UNSET
+    start: int | None = UNSET
+
+
+@strawberry.input
+class OffsetPaginationInput(OffsetPaginationInfo):
+    """
+    Customized implementation of OffsetPaginationInput to support cursor-based pagination.
+    """
+    pass
+
+
+def apply_pagination(
+    self,
+    queryset: _QS,
+    pagination: OffsetPaginationInput | None = None,
+    *,
+    related_field_id: str | None = None,
+) -> _QS:
+    """
+    Replacement for the `apply_pagination()` method on StrawberryDjangoField to support cursor-based pagination.
+    """
+    if pagination is not None and pagination.start not in (None, UNSET):
+        if pagination.offset:
+            raise ValueError('Cannot specify both `start` and `offset` in pagination.')
+        if pagination.start < 0:
+            raise ValueError('`start` must be greater than or equal to zero.')
+
+        # Filter the queryset to include only records with a primary key greater than or equal to the start value,
+        # and force ordering by primary key to ensure consistent pagination across all records.
+        queryset = queryset.filter(pk__gte=pagination.start).order_by('pk')
+
+        # Ignore `offset` when `start` is set
+        pagination.offset = 0
+
+    return apply(pagination, queryset, related_field_id=related_field_id)

+ 16 - 5
netbox/netbox/models/features.py

@@ -2,7 +2,7 @@ import json
 from collections import defaultdict
 from collections import defaultdict
 from functools import cached_property
 from functools import cached_property
 
 
-from django.contrib.contenttypes.fields import GenericRelation
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
@@ -121,9 +121,11 @@ class ChangeLoggingMixin(DeleteMixin, models.Model):
         if hasattr(self, '_prechange_snapshot'):
         if hasattr(self, '_prechange_snapshot'):
             objectchange.prechange_data = self._prechange_snapshot
             objectchange.prechange_data = self._prechange_snapshot
         if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE):
         if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE):
-            objectchange.postchange_data = self.serialize_object(exclude=exclude)
+            self._postchange_snapshot = self.serialize_object(exclude=exclude)
+            objectchange.postchange_data = self._postchange_snapshot
 
 
         return objectchange
         return objectchange
+    to_objectchange.alters_data = True
 
 
 
 
 class CloningMixin(models.Model):
 class CloningMixin(models.Model):
@@ -159,6 +161,13 @@ class CloningMixin(models.Model):
             elif field_value not in (None, ''):
             elif field_value not in (None, ''):
                 attrs[field_name] = field_value
                 attrs[field_name] = field_value
 
 
+        # Handle GenericForeignKeys. If the CT and ID fields are being cloned, also
+        # include the name of the GFK attribute itself, as this is what forms expect.
+        for field in self._meta.private_fields:
+            if isinstance(field, GenericForeignKey):
+                if field.ct_field in attrs and field.fk_field in attrs:
+                    attrs[field.name] = attrs[field.fk_field]
+
         # Include tags (if applicable)
         # Include tags (if applicable)
         if is_taggable(self):
         if is_taggable(self):
             attrs['tags'] = [tag.pk for tag in self.tags.all()]
             attrs['tags'] = [tag.pk for tag in self.tags.all()]
@@ -317,9 +326,11 @@ class CustomFieldsMixin(models.Model):
                 raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
                 raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
-        # Populate default values if omitted
-        for cf in self.custom_fields.filter(default__isnull=False):
-            if cf.name not in self.custom_field_data:
+        from extras.models import CustomField
+
+        # Populate default values for custom fields not already present in the object data
+        for cf in CustomField.objects.get_for_model(self):
+            if cf.name not in self.custom_field_data and cf.default is not None:
                 self.custom_field_data[cf.name] = cf.default
                 self.custom_field_data[cf.name] = cf.default
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)

+ 48 - 87
netbox/netbox/navigation/menu.py

@@ -1,3 +1,5 @@
+from functools import cache
+
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from netbox.registry import registry
 from netbox.registry import registry
@@ -409,60 +411,10 @@ ADMIN_MENU = Menu(
         MenuGroup(
         MenuGroup(
             label=_('Authentication'),
             label=_('Authentication'),
             items=(
             items=(
-                MenuItem(
-                    link='users:user_list',
-                    link_text=_('Users'),
-                    staff_only=True,
-                    permissions=['users.view_user'],
-                    buttons=(
-                        MenuItemButton(
-                            link='users:user_add',
-                            title='Add',
-                            icon_class='mdi mdi-plus-thick',
-                            permissions=['users.add_user']
-                        ),
-                        MenuItemButton(
-                            link='users:user_bulk_import',
-                            title='Import',
-                            icon_class='mdi mdi-upload',
-                            permissions=['users.add_user']
-                        )
-                    )
-                ),
-                MenuItem(
-                    link='users:group_list',
-                    link_text=_('Groups'),
-                    staff_only=True,
-                    permissions=['users.view_group'],
-                    buttons=(
-                        MenuItemButton(
-                            link='users:group_add',
-                            title='Add',
-                            icon_class='mdi mdi-plus-thick',
-                            permissions=['users.add_group']
-                        ),
-                        MenuItemButton(
-                            link='users:group_bulk_import',
-                            title='Import',
-                            icon_class='mdi mdi-upload',
-                            permissions=['users.add_group']
-                        )
-                    )
-                ),
-                MenuItem(
-                    link='users:token_list',
-                    link_text=_('API Tokens'),
-                    staff_only=True,
-                    permissions=['users.view_token'],
-                    buttons=get_model_buttons('users', 'token')
-                ),
-                MenuItem(
-                    link='users:objectpermission_list',
-                    link_text=_('Permissions'),
-                    staff_only=True,
-                    permissions=['users.view_objectpermission'],
-                    buttons=get_model_buttons('users', 'objectpermission', actions=['add'])
-                ),
+                get_model_item('users', 'user', _('Users')),
+                get_model_item('users', 'group', _('Groups')),
+                get_model_item('users', 'token', _('API Tokens')),
+                get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']),
             ),
             ),
         ),
         ),
         MenuGroup(
         MenuGroup(
@@ -501,40 +453,49 @@ ADMIN_MENU = Menu(
     ),
     ),
 )
 )
 
 
-MENUS = [
-    ORGANIZATION_MENU,
-    RACKS_MENU,
-    DEVICES_MENU,
-    CONNECTIONS_MENU,
-    WIRELESS_MENU,
-    IPAM_MENU,
-    VPN_MENU,
-    VIRTUALIZATION_MENU,
-    CIRCUITS_MENU,
-    POWER_MENU,
-    PROVISIONING_MENU,
-    CUSTOMIZATION_MENU,
-    OPERATIONS_MENU,
-]
 
 
-# Add top-level plugin menus
-for menu in registry['plugins']['menus']:
-    MENUS.append(menu)
+@cache
+def get_menus():
+    """
+    Dynamically build and return the list of navigation menus.
+    This ensures plugin menus registered during app initialization are included.
+    The result is cached since menus don't change without a Django restart.
+    """
+    menus = [
+        ORGANIZATION_MENU,
+        RACKS_MENU,
+        DEVICES_MENU,
+        CONNECTIONS_MENU,
+        WIRELESS_MENU,
+        IPAM_MENU,
+        VPN_MENU,
+        VIRTUALIZATION_MENU,
+        CIRCUITS_MENU,
+        POWER_MENU,
+        PROVISIONING_MENU,
+        CUSTOMIZATION_MENU,
+        OPERATIONS_MENU,
+    ]
+
+    # Add top-level plugin menus
+    for menu in registry['plugins']['menus']:
+        menus.append(menu)
 
 
-# Add the default "plugins" menu
-if registry['plugins']['menu_items']:
+    # Add the default "plugins" menu
+    if registry['plugins']['menu_items']:
+        # Build the default plugins menu
+        groups = [
+            MenuGroup(label=label, items=items)
+            for label, items in registry['plugins']['menu_items'].items()
+        ]
+        plugins_menu = Menu(
+            label=_("Plugins"),
+            icon_class="mdi mdi-puzzle",
+            groups=groups
+        )
+        menus.append(plugins_menu)
 
 
-    # Build the default plugins menu
-    groups = [
-        MenuGroup(label=label, items=items)
-        for label, items in registry['plugins']['menu_items'].items()
-    ]
-    plugins_menu = Menu(
-        label=_("Plugins"),
-        icon_class="mdi mdi-puzzle",
-        groups=groups
-    )
-    MENUS.append(plugins_menu)
+    # Add the admin menu last
+    menus.append(ADMIN_MENU)
 
 
-# Add the admin menu last
-MENUS.append(ADMIN_MENU)
+    return menus

+ 6 - 4
netbox/netbox/search/backends.py

@@ -187,7 +187,6 @@ class CachedValueSearchBackend(SearchBackend):
         return ret
         return ret
 
 
     def cache(self, instances, indexer=None, remove_existing=True):
     def cache(self, instances, indexer=None, remove_existing=True):
-        object_type = None
         custom_fields = None
         custom_fields = None
 
 
         # Convert a single instance to an iterable
         # Convert a single instance to an iterable
@@ -208,15 +207,18 @@ class CachedValueSearchBackend(SearchBackend):
                     except KeyError:
                     except KeyError:
                         break
                         break
 
 
-                # Prefetch any associated custom fields
-                object_type = ObjectType.objects.get_for_model(indexer.model)
-                custom_fields = CustomField.objects.filter(object_types=object_type).exclude(search_weight=0)
+                # Prefetch any associated custom fields (excluding those with a zero search weight)
+                custom_fields = [
+                    cf for cf in CustomField.objects.get_for_model(indexer.model)
+                    if cf.search_weight > 0
+                ]
 
 
             # Wipe out any previously cached values for the object
             # Wipe out any previously cached values for the object
             if remove_existing:
             if remove_existing:
                 self.remove(instance)
                 self.remove(instance)
 
 
             # Generate cache data
             # Generate cache data
+            object_type = ObjectType.objects.get_for_model(indexer.model)
             for field in indexer.to_cache(instance, custom_fields=custom_fields):
             for field in indexer.to_cache(instance, custom_fields=custom_fields):
                 buffer.append(
                 buffer.append(
                     CachedValue(
                     CachedValue(

+ 9 - 0
netbox/netbox/settings.py

@@ -12,10 +12,13 @@ from django.core.validators import URLValidator
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from rest_framework.utils import field_mapping
 from rest_framework.utils import field_mapping
+from strawberry_django import pagination
+from strawberry_django.fields.field import StrawberryDjangoField
 
 
 from core.exceptions import IncompatiblePluginError
 from core.exceptions import IncompatiblePluginError
 from netbox.config import PARAMS as CONFIG_PARAMS
 from netbox.config import PARAMS as CONFIG_PARAMS
 from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
 from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
+from netbox.graphql.pagination import OffsetPaginationInput, apply_pagination
 from netbox.plugins import PluginConfig
 from netbox.plugins import PluginConfig
 from netbox.registry import registry
 from netbox.registry import registry
 import storages.utils  # type: ignore
 import storages.utils  # type: ignore
@@ -33,6 +36,12 @@ from .monkey import get_unique_validators
 # Override DRF's get_unique_validators() function with our own (see bug #19302)
 # Override DRF's get_unique_validators() function with our own (see bug #19302)
 field_mapping.get_unique_validators = get_unique_validators
 field_mapping.get_unique_validators = get_unique_validators
 
 
+# Override strawberry-django's OffsetPaginationInput class to add the `start` parameter
+pagination.OffsetPaginationInput = OffsetPaginationInput
+
+# Patch StrawberryDjangoField to use our custom `apply_pagination()` method with support for cursor-based pagination
+StrawberryDjangoField.apply_pagination = apply_pagination
+
 
 
 #
 #
 # Environment setup
 # Environment setup

+ 26 - 8
netbox/netbox/tables/tables.py

@@ -242,14 +242,17 @@ class NetBoxTable(BaseTable):
                 (name, deepcopy(column)) for name, column in registered_columns.items()
                 (name, deepcopy(column)) for name, column in registered_columns.items()
             ])
             ])
 
 
-        # Add custom field & custom link columns
-        object_type = ObjectType.objects.get_for_model(self._meta.model)
-        custom_fields = CustomField.objects.filter(
-            object_types=object_type
-        ).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN)
+        # Add columns for custom fields
+        custom_fields = [
+            cf for cf in CustomField.objects.get_for_model(self._meta.model)
+            if cf.ui_visible != CustomFieldUIVisibleChoices.HIDDEN
+        ]
         extra_columns.extend([
         extra_columns.extend([
             (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
             (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
         ])
         ])
+
+        # Add columns for custom links
+        object_type = ObjectType.objects.get_for_model(self._meta.model)
         custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True)
         custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True)
         extra_columns.extend([
         extra_columns.extend([
             (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links
             (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links
@@ -271,9 +274,14 @@ class NetBoxTable(BaseTable):
 
 
 
 
 class PrimaryModelTable(NetBoxTable):
 class PrimaryModelTable(NetBoxTable):
+    owner_group = tables.Column(
+        accessor='owner__group',
+        linkify=True,
+        verbose_name=_('Owner Group'),
+    )
     owner = tables.Column(
     owner = tables.Column(
         linkify=True,
         linkify=True,
-        verbose_name=_('Owner')
+        verbose_name=_('Owner'),
     )
     )
     comments = columns.MarkdownColumn(
     comments = columns.MarkdownColumn(
         verbose_name=_('Comments'),
         verbose_name=_('Comments'),
@@ -281,9 +289,14 @@ class PrimaryModelTable(NetBoxTable):
 
 
 
 
 class OrganizationalModelTable(NetBoxTable):
 class OrganizationalModelTable(NetBoxTable):
+    owner_group = tables.Column(
+        accessor='owner__group',
+        linkify=True,
+        verbose_name=_('Owner Group'),
+    )
     owner = tables.Column(
     owner = tables.Column(
         linkify=True,
         linkify=True,
-        verbose_name=_('Owner')
+        verbose_name=_('Owner'),
     )
     )
     comments = columns.MarkdownColumn(
     comments = columns.MarkdownColumn(
         verbose_name=_('Comments'),
         verbose_name=_('Comments'),
@@ -291,9 +304,14 @@ class OrganizationalModelTable(NetBoxTable):
 
 
 
 
 class NestedGroupModelTable(NetBoxTable):
 class NestedGroupModelTable(NetBoxTable):
+    owner_group = tables.Column(
+        accessor='owner__group',
+        linkify=True,
+        verbose_name=_('Owner Group'),
+    )
     owner = tables.Column(
     owner = tables.Column(
         linkify=True,
         linkify=True,
-        verbose_name=_('Owner')
+        verbose_name=_('Owner'),
     )
     )
     name = columns.MPTTColumn(
     name = columns.MPTTColumn(
         verbose_name=_('Name'),
         verbose_name=_('Name'),

+ 143 - 20
netbox/netbox/tests/test_graphql.py

@@ -4,10 +4,8 @@ from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 
 
-from core.models import ObjectType
 from dcim.choices import LocationStatusChoices
 from dcim.choices import LocationStatusChoices
 from dcim.models import Site, Location
 from dcim.models import Site, Location
-from users.models import ObjectPermission
 from utilities.testing import disable_warnings, APITestCase, TestCase
 from utilities.testing import disable_warnings, APITestCase, TestCase
 
 
 
 
@@ -45,17 +43,28 @@ class GraphQLTestCase(TestCase):
 
 
 class GraphQLAPITestCase(APITestCase):
 class GraphQLAPITestCase(APITestCase):
 
 
-    @override_settings(LOGIN_REQUIRED=True)
-    def test_graphql_filter_objects(self):
-        """
-        Test the operation of filters for GraphQL API requests.
-        """
+    @classmethod
+    def setUpTestData(cls):
         sites = (
         sites = (
             Site(name='Site 1', slug='site-1'),
             Site(name='Site 1', slug='site-1'),
             Site(name='Site 2', slug='site-2'),
             Site(name='Site 2', slug='site-2'),
             Site(name='Site 3', slug='site-3'),
             Site(name='Site 3', slug='site-3'),
+            Site(name='Site 4', slug='site-4'),
+            Site(name='Site 5', slug='site-5'),
+            Site(name='Site 6', slug='site-6'),
+            Site(name='Site 7', slug='site-7'),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
+
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_graphql_filter_objects(self):
+        """
+        Test the operation of filters for GraphQL API requests.
+        """
+        self.add_permissions('dcim.view_site', 'dcim.view_location')
+        url = reverse('graphql')
+
+        sites = Site.objects.all()[:3]
         Location.objects.create(
         Location.objects.create(
             site=sites[0],
             site=sites[0],
             name='Location 1',
             name='Location 1',
@@ -75,18 +84,6 @@ class GraphQLAPITestCase(APITestCase):
             status=LocationStatusChoices.STATUS_ACTIVE
             status=LocationStatusChoices.STATUS_ACTIVE
         ),
         ),
 
 
-        # Add object-level permission
-        obj_perm = ObjectPermission(
-            name='Test permission',
-            actions=['view']
-        )
-        obj_perm.save()
-        obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ObjectType.objects.get_for_model(Location))
-        obj_perm.object_types.add(ObjectType.objects.get_for_model(Site))
-
-        url = reverse('graphql')
-
         # A valid request should return the filtered list
         # A valid request should return the filtered list
         query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}'
         query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}'
         response = self.client.post(url, data={'query': query}, format="json", **self.header)
         response = self.client.post(url, data={'query': query}, format="json", **self.header)
@@ -133,10 +130,136 @@ class GraphQLAPITestCase(APITestCase):
         self.assertEqual(len(data['data']['location_list']), 0)
         self.assertEqual(len(data['data']['location_list']), 0)
 
 
         # Removing the permissions from location should result in an empty locations list
         # Removing the permissions from location should result in an empty locations list
-        obj_perm.object_types.remove(ObjectType.objects.get_for_model(Location))
+        self.remove_permissions('dcim.view_location')
         query = '{site(id: ' + str(sites[0].pk) + ') {id locations {id}}}'
         query = '{site(id: ' + str(sites[0].pk) + ') {id locations {id}}}'
         response = self.client.post(url, data={'query': query}, format="json", **self.header)
         response = self.client.post(url, data={'query': query}, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         data = json.loads(response.content)
         data = json.loads(response.content)
         self.assertNotIn('errors', data)
         self.assertNotIn('errors', data)
         self.assertEqual(len(data['data']['site']['locations']), 0)
         self.assertEqual(len(data['data']['site']['locations']), 0)
+
+    def test_offset_pagination(self):
+        self.add_permissions('dcim.view_site')
+        url = reverse('graphql')
+
+        # Test `limit` only
+        query = """
+        {
+            site_list(pagination: {limit: 3}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 3)
+        self.assertEqual(data['data']['site_list'][0]['name'], 'Site 1')
+        self.assertEqual(data['data']['site_list'][1]['name'], 'Site 2')
+        self.assertEqual(data['data']['site_list'][2]['name'], 'Site 3')
+
+        # Test `offset` only
+        query = """
+        {
+            site_list(pagination: {offset: 3}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 4)
+        self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
+        self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
+        self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
+        self.assertEqual(data['data']['site_list'][3]['name'], 'Site 7')
+
+        # Test `offset` & `limit`
+        query = """
+        {
+            site_list(pagination: {offset: 3, limit: 3}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 3)
+        self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
+        self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
+        self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
+
+    def test_cursor_pagination(self):
+        self.add_permissions('dcim.view_site')
+        url = reverse('graphql')
+
+        # Page 1
+        query = """
+        {
+            site_list(pagination: {start: 0, limit: 3}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 3)
+        self.assertEqual(data['data']['site_list'][0]['name'], 'Site 1')
+        self.assertEqual(data['data']['site_list'][1]['name'], 'Site 2')
+        self.assertEqual(data['data']['site_list'][2]['name'], 'Site 3')
+
+        # Page 2
+        start_id = int(data['data']['site_list'][-1]['id']) + 1
+        query = """
+        {
+            site_list(pagination: {start: """ + str(start_id) + """, limit: 3}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 3)
+        self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
+        self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
+        self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
+
+        # Page 3
+        start_id = int(data['data']['site_list'][-1]['id']) + 1
+        query = """
+        {
+            site_list(pagination: {start: """ + str(start_id) + """, limit: 3}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 1)
+        self.assertEqual(data['data']['site_list'][0]['name'], 'Site 7')
+
+    def test_pagination_conflict(self):
+        url = reverse('graphql')
+        query = """
+        {
+            site_list(pagination: {start: 1, offset: 1}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertIn('errors', data)
+        self.assertEqual(data['errors'][0]['message'], 'Cannot specify both `start` and `offset` in pagination.')

+ 61 - 1
netbox/netbox/tests/test_model_features.py

@@ -1,18 +1,28 @@
+from unittest import skipIf
+
+from django.conf import settings
 from django.test import TestCase
 from django.test import TestCase
 
 
 from core.models import AutoSyncRecord, DataSource
 from core.models import AutoSyncRecord, DataSource
+from dcim.models import Site
 from extras.models import CustomLink
 from extras.models import CustomLink
+from ipam.models import Prefix
 from netbox.models.features import get_model_features, has_feature, model_is_public
 from netbox.models.features import get_model_features, has_feature, model_is_public
-from netbox.tests.dummy_plugin.models import DummyModel
 from taggit.models import Tag
 from taggit.models import Tag
 
 
 
 
 class ModelFeaturesTestCase(TestCase):
 class ModelFeaturesTestCase(TestCase):
+    """
+    A test case class for verifying model features and utility functions.
+    """
 
 
+    @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
     def test_model_is_public(self):
     def test_model_is_public(self):
         """
         """
         Test that the is_public() utility function returns True for public models only.
         Test that the is_public() utility function returns True for public models only.
         """
         """
+        from netbox.tests.dummy_plugin.models import DummyModel
+
         # Public model
         # Public model
         self.assertFalse(hasattr(DataSource, '_netbox_private'))
         self.assertFalse(hasattr(DataSource, '_netbox_private'))
         self.assertTrue(model_is_public(DataSource))
         self.assertTrue(model_is_public(DataSource))
@@ -51,3 +61,53 @@ class ModelFeaturesTestCase(TestCase):
         features = get_model_features(CustomLink)
         features = get_model_features(CustomLink)
         self.assertIn('cloning', features)
         self.assertIn('cloning', features)
         self.assertNotIn('bookmarks', features)
         self.assertNotIn('bookmarks', features)
+
+    def test_cloningmixin_injects_gfk_attribute(self):
+        """
+        Tests the cloning mixin with GFK attribute injection in the `clone` method.
+
+        This test validates that the `clone` method correctly handles
+        and retains the General Foreign Key (GFK) attributes on an
+        object when the cloning fields are explicitly defined.
+        """
+        site = Site.objects.create(name='Test Site', slug='test-site')
+        prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=site)
+
+        original_clone_fields = getattr(Prefix, 'clone_fields', None)
+        try:
+            Prefix.clone_fields = ('scope_type', 'scope_id')
+            attrs = prefix.clone()
+
+            self.assertEqual(attrs['scope_type'], prefix.scope_type_id)
+            self.assertEqual(attrs['scope_id'], prefix.scope_id)
+            self.assertEqual(attrs['scope'], prefix.scope_id)
+        finally:
+            if original_clone_fields is None:
+                delattr(Prefix, 'clone_fields')
+            else:
+                Prefix.clone_fields = original_clone_fields
+
+    def test_cloningmixin_does_not_inject_gfk_attribute_if_incomplete(self):
+        """
+        Tests the cloning mixin with incomplete cloning fields does not inject the GFK attribute.
+
+        This test validates that the `clone` method correctly handles
+        the case where the cloning fields are incomplete, ensuring that
+        the generic foreign key (GFK) attribute is not injected during
+        the cloning process.
+        """
+        site = Site.objects.create(name='Test Site', slug='test-site')
+        prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=site)
+
+        original_clone_fields = getattr(Prefix, 'clone_fields', None)
+        try:
+            Prefix.clone_fields = ('scope_type',)
+            attrs = prefix.clone()
+
+            self.assertIn('scope_type', attrs)
+            self.assertNotIn('scope', attrs)
+        finally:
+            if original_clone_fields is None:
+                delattr(Prefix, 'clone_fields')
+            else:
+                Prefix.clone_fields = original_clone_fields

+ 5 - 7
netbox/netbox/views/generic/bulk_views.py

@@ -5,7 +5,6 @@ from copy import deepcopy
 
 
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
 from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
 from django.db import IntegrityError, router, transaction
 from django.db import IntegrityError, router, transaction
 from django.db.models import ManyToManyField, ProtectedError, RestrictedError
 from django.db.models import ManyToManyField, ProtectedError, RestrictedError
@@ -466,12 +465,11 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
             else:
             else:
                 instance = self.queryset.model()
                 instance = self.queryset.model()
 
 
-                # For newly created objects, apply any default custom field values
-                custom_fields = CustomField.objects.filter(
-                    object_types=ContentType.objects.get_for_model(self.queryset.model),
-                    ui_editable=CustomFieldUIEditableChoices.YES
-                )
-                for cf in custom_fields:
+                # For newly created objects, apply any default values for custom fields
+                for cf in CustomField.objects.get_for_model(self.queryset.model):
+                    if cf.ui_editable != CustomFieldUIEditableChoices.YES:
+                        # Skip custom fields which are not editable via the UI
+                        continue
                     field_name = f'cf_{cf.name}'
                     field_name = f'cf_{cf.name}'
                     if field_name not in record:
                     if field_name not in record:
                         record[field_name] = cf.default
                         record[field_name] = cf.default

+ 11 - 1
netbox/netbox/views/misc.py

@@ -1,5 +1,6 @@
 import re
 import re
 from collections import namedtuple
 from collections import namedtuple
+import logging
 
 
 from django.conf import settings
 from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
@@ -28,6 +29,8 @@ __all__ = (
     'SearchView',
     'SearchView',
 )
 )
 
 
+logger = logging.getLogger(f'netbox.{__name__}')
+
 Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
 Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
 
 
 
 
@@ -50,7 +53,14 @@ class HomeView(ConditionalLoginRequiredMixin, View):
         # Check whether a new release is available. (Only for superusers.)
         # Check whether a new release is available. (Only for superusers.)
         new_release = None
         new_release = None
         if request.user.is_superuser:
         if request.user.is_superuser:
-            latest_release = cache.get('latest_release')
+            # cache.get() can raise an exception if the cached value can't be unpickled after dependency upgrades
+            try:
+                latest_release = cache.get('latest_release')
+            except Exception:
+                logger.debug("Failed to read 'latest_release' from cache; deleting key", exc_info=True)
+                cache.delete('latest_release')
+                latest_release = None
+
             if latest_release:
             if latest_release:
                 release_version, release_url = latest_release
                 release_version, release_url = latest_release
                 if release_version > version.parse(settings.RELEASE.version):
                 if release_version > version.parse(settings.RELEASE.version):

+ 6 - 6
netbox/project-static/package.json

@@ -31,20 +31,20 @@
     "gridstack": "12.4.2",
     "gridstack": "12.4.2",
     "htmx.org": "2.0.8",
     "htmx.org": "2.0.8",
     "query-string": "9.3.1",
     "query-string": "9.3.1",
-    "sass": "1.97.2",
+    "sass": "1.97.3",
     "tom-select": "2.4.3",
     "tom-select": "2.4.3",
     "typeface-inter": "3.18.1",
     "typeface-inter": "3.18.1",
     "typeface-roboto-mono": "1.1.13"
     "typeface-roboto-mono": "1.1.13"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@eslint/compat": "^2.0.1",
+    "@eslint/compat": "^2.0.2",
     "@eslint/eslintrc": "^3.3.3",
     "@eslint/eslintrc": "^3.3.3",
     "@eslint/js": "^9.39.2",
     "@eslint/js": "^9.39.2",
     "@types/bootstrap": "5.2.10",
     "@types/bootstrap": "5.2.10",
     "@types/cookie": "^1.0.0",
     "@types/cookie": "^1.0.0",
     "@types/node": "^24.10.1",
     "@types/node": "^24.10.1",
-    "@typescript-eslint/eslint-plugin": "^8.53.1",
-    "@typescript-eslint/parser": "^8.53.1",
+    "@typescript-eslint/eslint-plugin": "^8.54.0",
+    "@typescript-eslint/parser": "^8.54.0",
     "esbuild": "^0.27.2",
     "esbuild": "^0.27.2",
     "esbuild-sass-plugin": "^3.6.0",
     "esbuild-sass-plugin": "^3.6.0",
     "eslint": "^9.39.2",
     "eslint": "^9.39.2",
@@ -52,8 +52,8 @@
     "eslint-import-resolver-typescript": "^4.4.4",
     "eslint-import-resolver-typescript": "^4.4.4",
     "eslint-plugin-import": "^2.32.0",
     "eslint-plugin-import": "^2.32.0",
     "eslint-plugin-prettier": "^5.5.5",
     "eslint-plugin-prettier": "^5.5.5",
-    "globals": "^17.0.0",
-    "prettier": "^3.8.0",
+    "globals": "^17.3.0",
+    "prettier": "^3.8.1",
     "typescript": "^5.9.3"
     "typescript": "^5.9.3"
   },
   },
   "resolutions": {
   "resolutions": {

+ 93 - 82
netbox/project-static/yarn.lock

@@ -173,12 +173,12 @@
   resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
   resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
   integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
   integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
 
 
-"@eslint/compat@^2.0.1":
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/@eslint/compat/-/compat-2.0.1.tgz#5894516f8ce9ba884f4d4ba5ecb6b6459b231144"
-  integrity sha512-yl/JsgplclzuvGFNqwNYV4XNPhP3l62ZOP9w/47atNAdmDtIFCx6X7CSk/SlWUuBGkT4Et/5+UD+WyvX2iiIWA==
+"@eslint/compat@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@eslint/compat/-/compat-2.0.2.tgz#fc1495688664861870f5e7ee56999dc252b6dd52"
+  integrity sha512-pR1DoD0h3HfF675QZx0xsyrsU8q70Z/plx7880NOhS02NuWLgBCOMDL787nUeQ7EWLkxv3bPQJaarjcPQb2Dwg==
   dependencies:
   dependencies:
-    "@eslint/core" "^1.0.1"
+    "@eslint/core" "^1.1.0"
 
 
 "@eslint/config-array@^0.21.1":
 "@eslint/config-array@^0.21.1":
   version "0.21.1"
   version "0.21.1"
@@ -203,10 +203,10 @@
   dependencies:
   dependencies:
     "@types/json-schema" "^7.0.15"
     "@types/json-schema" "^7.0.15"
 
 
-"@eslint/core@^1.0.1":
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.0.1.tgz#701ff760cbd279f9490bef0ce54095f4088d4def"
-  integrity sha512-r18fEAj9uCk+VjzGt2thsbOmychS+4kxI14spVNibUO2vqKX7obOG+ymZljAwuPZl+S3clPGwCwTDtrdqTiY6Q==
+"@eslint/core@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.0.tgz#51f5cd970e216fbdae6721ac84491f57f965836d"
+  integrity sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==
   dependencies:
   dependencies:
     "@types/json-schema" "^7.0.15"
     "@types/json-schema" "^7.0.15"
 
 
@@ -935,100 +935,100 @@
   dependencies:
   dependencies:
     "@types/estree" "*"
     "@types/estree" "*"
 
 
-"@typescript-eslint/eslint-plugin@^8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz#f6640f6f8749b71d9ab457263939e8932a3c6b46"
-  integrity sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==
+"@typescript-eslint/eslint-plugin@^8.54.0":
+  version "8.54.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz#d8899e5c2eccf5c4a20d01c036a193753748454d"
+  integrity sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==
   dependencies:
   dependencies:
     "@eslint-community/regexpp" "^4.12.2"
     "@eslint-community/regexpp" "^4.12.2"
-    "@typescript-eslint/scope-manager" "8.53.1"
-    "@typescript-eslint/type-utils" "8.53.1"
-    "@typescript-eslint/utils" "8.53.1"
-    "@typescript-eslint/visitor-keys" "8.53.1"
+    "@typescript-eslint/scope-manager" "8.54.0"
+    "@typescript-eslint/type-utils" "8.54.0"
+    "@typescript-eslint/utils" "8.54.0"
+    "@typescript-eslint/visitor-keys" "8.54.0"
     ignore "^7.0.5"
     ignore "^7.0.5"
     natural-compare "^1.4.0"
     natural-compare "^1.4.0"
     ts-api-utils "^2.4.0"
     ts-api-utils "^2.4.0"
 
 
-"@typescript-eslint/parser@^8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.53.1.tgz#58d4a70cc2daee2becf7d4521d65ea1782d6ec68"
-  integrity sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==
+"@typescript-eslint/parser@^8.54.0":
+  version "8.54.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.54.0.tgz#3d01a6f54ed247deb9982621f70e7abf1810bd97"
+  integrity sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==
   dependencies:
   dependencies:
-    "@typescript-eslint/scope-manager" "8.53.1"
-    "@typescript-eslint/types" "8.53.1"
-    "@typescript-eslint/typescript-estree" "8.53.1"
-    "@typescript-eslint/visitor-keys" "8.53.1"
+    "@typescript-eslint/scope-manager" "8.54.0"
+    "@typescript-eslint/types" "8.54.0"
+    "@typescript-eslint/typescript-estree" "8.54.0"
+    "@typescript-eslint/visitor-keys" "8.54.0"
     debug "^4.4.3"
     debug "^4.4.3"
 
 
-"@typescript-eslint/project-service@8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.53.1.tgz#4e47856a0b14a1ceb28b0294b4badef3be1e9734"
-  integrity sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==
+"@typescript-eslint/project-service@8.54.0":
+  version "8.54.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.54.0.tgz#f582aceb3d752544c8e1b11fea8d95d00cf9adc6"
+  integrity sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==
   dependencies:
   dependencies:
-    "@typescript-eslint/tsconfig-utils" "^8.53.1"
-    "@typescript-eslint/types" "^8.53.1"
+    "@typescript-eslint/tsconfig-utils" "^8.54.0"
+    "@typescript-eslint/types" "^8.54.0"
     debug "^4.4.3"
     debug "^4.4.3"
 
 
-"@typescript-eslint/scope-manager@8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.53.1.tgz#6c4b8c82cd45ae3b365afc2373636e166743a8fa"
-  integrity sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==
+"@typescript-eslint/scope-manager@8.54.0":
+  version "8.54.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz#307dc8cbd80157e2772c2d36216857415a71ab33"
+  integrity sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==
   dependencies:
   dependencies:
-    "@typescript-eslint/types" "8.53.1"
-    "@typescript-eslint/visitor-keys" "8.53.1"
+    "@typescript-eslint/types" "8.54.0"
+    "@typescript-eslint/visitor-keys" "8.54.0"
 
 
-"@typescript-eslint/tsconfig-utils@8.53.1", "@typescript-eslint/tsconfig-utils@^8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.1.tgz#efe80b8d019cd49e5a1cf46c2eb0cd2733076424"
-  integrity sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==
+"@typescript-eslint/tsconfig-utils@8.54.0", "@typescript-eslint/tsconfig-utils@^8.54.0":
+  version "8.54.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz#71dd7ba1674bd48b172fc4c85b2f734b0eae3dbc"
+  integrity sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==
 
 
-"@typescript-eslint/type-utils@8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.53.1.tgz#95de2651a96d580bf5c6c6089ddd694284d558ad"
-  integrity sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==
+"@typescript-eslint/type-utils@8.54.0":
+  version "8.54.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz#64965317dd4118346c2fa5ee94492892200e9fb9"
+  integrity sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==
   dependencies:
   dependencies:
-    "@typescript-eslint/types" "8.53.1"
-    "@typescript-eslint/typescript-estree" "8.53.1"
-    "@typescript-eslint/utils" "8.53.1"
+    "@typescript-eslint/types" "8.54.0"
+    "@typescript-eslint/typescript-estree" "8.54.0"
+    "@typescript-eslint/utils" "8.54.0"
     debug "^4.4.3"
     debug "^4.4.3"
     ts-api-utils "^2.4.0"
     ts-api-utils "^2.4.0"
 
 
-"@typescript-eslint/types@8.53.1", "@typescript-eslint/types@^8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.53.1.tgz#101f203f0807a63216cceceedb815fabe21d5793"
-  integrity sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==
+"@typescript-eslint/types@8.54.0", "@typescript-eslint/types@^8.54.0":
+  version "8.54.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.54.0.tgz#c12d41f67a2e15a8a96fbc5f2d07b17331130889"
+  integrity sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==
 
 
-"@typescript-eslint/typescript-estree@8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.1.tgz#b6dce2303c9e27e95b8dcd8c325868fff53e488f"
-  integrity sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==
+"@typescript-eslint/typescript-estree@8.54.0":
+  version "8.54.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz#3c7716905b2b811fadbd2114804047d1bfc86527"
+  integrity sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==
   dependencies:
   dependencies:
-    "@typescript-eslint/project-service" "8.53.1"
-    "@typescript-eslint/tsconfig-utils" "8.53.1"
-    "@typescript-eslint/types" "8.53.1"
-    "@typescript-eslint/visitor-keys" "8.53.1"
+    "@typescript-eslint/project-service" "8.54.0"
+    "@typescript-eslint/tsconfig-utils" "8.54.0"
+    "@typescript-eslint/types" "8.54.0"
+    "@typescript-eslint/visitor-keys" "8.54.0"
     debug "^4.4.3"
     debug "^4.4.3"
     minimatch "^9.0.5"
     minimatch "^9.0.5"
     semver "^7.7.3"
     semver "^7.7.3"
     tinyglobby "^0.2.15"
     tinyglobby "^0.2.15"
     ts-api-utils "^2.4.0"
     ts-api-utils "^2.4.0"
 
 
-"@typescript-eslint/utils@8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.53.1.tgz#81fe6c343de288701b774f4d078382f567e6edaa"
-  integrity sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==
+"@typescript-eslint/utils@8.54.0":
+  version "8.54.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.54.0.tgz#c79a4bcbeebb4f571278c0183ed1cb601d84c6c8"
+  integrity sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==
   dependencies:
   dependencies:
     "@eslint-community/eslint-utils" "^4.9.1"
     "@eslint-community/eslint-utils" "^4.9.1"
-    "@typescript-eslint/scope-manager" "8.53.1"
-    "@typescript-eslint/types" "8.53.1"
-    "@typescript-eslint/typescript-estree" "8.53.1"
+    "@typescript-eslint/scope-manager" "8.54.0"
+    "@typescript-eslint/types" "8.54.0"
+    "@typescript-eslint/typescript-estree" "8.54.0"
 
 
-"@typescript-eslint/visitor-keys@8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.1.tgz#405f04959be22b9be364939af8ac19c3649b6eb7"
-  integrity sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==
+"@typescript-eslint/visitor-keys@8.54.0":
+  version "8.54.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz#0e4b50124b210b8600b245dd66cbad52deb15590"
+  integrity sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==
   dependencies:
   dependencies:
-    "@typescript-eslint/types" "8.53.1"
+    "@typescript-eslint/types" "8.54.0"
     eslint-visitor-keys "^4.2.1"
     eslint-visitor-keys "^4.2.1"
 
 
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
@@ -2184,10 +2184,10 @@ globals@^14.0.0:
   resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
   resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
   integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
   integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
 
 
-globals@^17.0.0:
-  version "17.0.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-17.0.0.tgz#a4196d9cfeb4d627ba165b4647b1f5853bf90a30"
-  integrity sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==
+globals@^17.3.0:
+  version "17.3.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-17.3.0.tgz#8b96544c2fa91afada02747cc9731c002a96f3b9"
+  integrity sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==
 
 
 globalthis@^1.0.3, globalthis@^1.0.4:
 globalthis@^1.0.3, globalthis@^1.0.4:
   version "1.0.4"
   version "1.0.4"
@@ -2985,10 +2985,10 @@ prettier-linter-helpers@^1.0.1:
   dependencies:
   dependencies:
     fast-diff "^1.1.2"
     fast-diff "^1.1.2"
 
 
-prettier@^3.8.0:
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.0.tgz#f72cf71505133f40cfa2ef77a2668cdc558fcd69"
-  integrity sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==
+prettier@^3.8.1:
+  version "3.8.1"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
+  integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
 
 
 punycode.js@^2.3.1:
 punycode.js@^2.3.1:
   version "2.3.1"
   version "2.3.1"
@@ -3172,7 +3172,18 @@ safe-regex-test@^1.1.0:
     es-errors "^1.3.0"
     es-errors "^1.3.0"
     is-regex "^1.2.1"
     is-regex "^1.2.1"
 
 
-sass@1.97.2, sass@^1.97.2:
+sass@1.97.3:
+  version "1.97.3"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.3.tgz#9cb59339514fa7e2aec592b9700953ac6e331ab2"
+  integrity sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==
+  dependencies:
+    chokidar "^4.0.0"
+    immutable "^5.0.2"
+    source-map-js ">=0.6.2 <2.0.0"
+  optionalDependencies:
+    "@parcel/watcher" "^2.4.1"
+
+sass@^1.97.2:
   version "1.97.2"
   version "1.97.2"
   resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.2.tgz#e515a319092fd2c3b015228e3094b40198bff0da"
   resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.2.tgz#e515a319092fd2c3b015228e3094b40198bff0da"
   integrity sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==
   integrity sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==
@@ -3441,7 +3452,7 @@ toggle-selection@^1.0.6:
 
 
 tom-select@2.4.3:
 tom-select@2.4.3:
   version "2.4.3"
   version "2.4.3"
-  resolved "https://registry.npmjs.org/tom-select/-/tom-select-2.4.3.tgz"
+  resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.3.tgz#1daa4131cd317de691f39eb5bf41148265986c1f"
   integrity sha512-MFFrMxP1bpnAMPbdvPCZk0KwYxLqhYZso39torcdoefeV/NThNyDu8dV96/INJ5XQVTL3O55+GqQ78Pkj5oCfw==
   integrity sha512-MFFrMxP1bpnAMPbdvPCZk0KwYxLqhYZso39torcdoefeV/NThNyDu8dV96/INJ5XQVTL3O55+GqQ78Pkj5oCfw==
   dependencies:
   dependencies:
     "@orchidjs/sifter" "^1.1.0"
     "@orchidjs/sifter" "^1.1.0"

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.5.1"
+version: "4.5.2"
 edition: "Community"
 edition: "Community"
-published: "2026-01-20"
+published: "2026-02-03"

+ 1 - 1
netbox/templates/base/layout.html

@@ -53,7 +53,7 @@ Blocks:
           {% nav %}
           {% nav %}
 
 
           {# Release info #}
           {# Release info #}
-          <div class="text-muted text-center fs-5 my-3">
+          <div class="text-muted text-center fs-5 my-3 px-3">
             {{ settings.RELEASE.name }}
             {{ settings.RELEASE.name }}
             {% if not settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %}
             {% if not settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %}
               <div>
               <div>

+ 4 - 0
netbox/templates/core/job.html

@@ -59,6 +59,10 @@
             <th scope="row">{% trans "Completed" %}</th>
             <th scope="row">{% trans "Completed" %}</th>
             <td>{{ object.completed|isodatetime|placeholder }}</td>
             <td>{{ object.completed|isodatetime|placeholder }}</td>
           </tr>
           </tr>
+          <tr>
+            <th scope="row">{% trans "Queue" %}</th>
+            <td>{{ object.queue_name|placeholder }}</td>
+          </tr>
         </table>
         </table>
       </div>
       </div>
     </div>
     </div>

+ 2 - 1
netbox/templates/dcim/device_edit.html

@@ -101,8 +101,9 @@
 
 
     <div class="field-group mb-5">
     <div class="field-group mb-5">
       <div class="row">
       <div class="row">
-        <h2 class="col-9 offset-3">{% trans "Owner" %}</h2>
+        <h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
       </div>
       </div>
+      {% render_field form.owner_group %}
       {% render_field form.owner %}
       {% render_field form.owner %}
     </div>
     </div>
 
 

+ 2 - 1
netbox/templates/dcim/htmx/cable_edit.html

@@ -80,8 +80,9 @@
 
 
 <div class="field-group mb-5">
 <div class="field-group mb-5">
   <div class="row">
   <div class="row">
-    <h2 class="col-9 offset-3">{% trans "Owner" %}</h2>
+    <h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
   </div>
   </div>
+  {% render_field form.owner_group %}
   {% render_field form.owner %}
   {% render_field form.owner %}
 </div>
 </div>
 
 

+ 7 - 27
netbox/templates/dcim/interface.html

@@ -370,33 +370,6 @@
           </table>
           </table>
         </div>
         </div>
       {% endif %}
       {% endif %}
-      {% if object.is_lag %}
-        <div class="card">
-          <h2 class="card-header">{% trans "LAG Members" %}</h2>
-          <table class="table table-hover">
-            <thead>
-              <tr>
-                <th>{% trans "Parent" %}</th>
-                <th>{% trans "Interface" %}</th>
-                <th>{% trans "Type" %}</th>
-              </tr>
-            </thead>
-            <tbody>
-              {% for member in object.member_interfaces.all %}
-                <tr>
-                  <td>{{ member.device|linkify }}</td>
-                  <td>{{ member|linkify }}</td>
-                  <td>{{ member.get_type_display }}</td>
-                </tr>
-              {% empty %}
-                <tr>
-                  <td colspan="3" class="text-muted">{% trans "No member interfaces" %}</td>
-                </tr>
-              {% endfor %}
-            </tbody>
-          </table>
-        </div>
-      {% endif %}
       {% include 'ipam/inc/panels/fhrp_groups.html' %}
       {% include 'ipam/inc/panels/fhrp_groups.html' %}
       {% include 'dcim/inc/panels/inventory_items.html' %}
       {% include 'dcim/inc/panels/inventory_items.html' %}
       {% plugin_right_page object %}
       {% plugin_right_page object %}
@@ -441,6 +414,13 @@
       {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
       {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
     </div>
     </div>
   </div>
   </div>
+  {% if object.is_lag %}
+    <div class="row mb-3">
+      <div class="col col-md-12">
+        {% include 'inc/panel_table.html' with table=lag_interfaces_table heading="LAG Members" %}
+      </div>
+    </div>
+  {% endif %}
   {% if object.vlan_translation_policy %}
   {% if object.vlan_translation_policy %}
     <div class="row mb-3">
     <div class="row mb-3">
       <div class="col col-md-12">
       <div class="col col-md-12">

+ 2 - 1
netbox/templates/dcim/virtualchassis_edit.html

@@ -36,8 +36,9 @@
 
 
       <div class="field-group mb-5">
       <div class="field-group mb-5">
         <div class="row">
         <div class="row">
-          <h2 class="col-9 offset-3">{% trans "Owner" %}</h2>
+          <h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
         </div>
         </div>
+        {% render_field vc_form.owner_group %}
         {% render_field vc_form.owner %}
         {% render_field vc_form.owner %}
       </div>
       </div>
 
 

+ 1 - 0
netbox/templates/extras/inc/script_list_content.html

@@ -121,6 +121,7 @@
           <div class="alert alert-warning" role="alert">
           <div class="alert alert-warning" role="alert">
             <i class="mdi mdi-alert"></i>
             <i class="mdi mdi-alert"></i>
             {% blocktrans with module=module.name %}Could not load scripts from module {{ module }}{% endblocktrans %}
             {% blocktrans with module=module.name %}Could not load scripts from module {{ module }}{% endblocktrans %}
+            {% if module.error %}<code>{{ module.error }}</code>{% endif %}
           </div>
           </div>
         </div>
         </div>
       {% endif %}
       {% endif %}

+ 4 - 1
netbox/templates/generic/bulk_edit.html

@@ -62,8 +62,11 @@ Context:
           {% if form.owner %}
           {% if form.owner %}
             <div class="field-group mb-5">
             <div class="field-group mb-5">
               <div class="row">
               <div class="row">
-                <h2 class="col-9 offset-3">{% trans "Owner" %}</h2>
+                <h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
               </div>
               </div>
+              {% if form.owner_group %}
+                {% render_field form.owner_group %}
+              {% endif %}
               {% render_field form.owner bulk_nullable=True %}
               {% render_field form.owner bulk_nullable=True %}
             </div>
             </div>
           {% endif %}
           {% endif %}

+ 3 - 0
netbox/templates/htmx/form.html

@@ -27,6 +27,9 @@
       <div class="row">
       <div class="row">
         <h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
         <h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
       </div>
       </div>
+      {% if form.owner_group %}
+        {% render_field form.owner_group %}
+      {% endif %}
       {% render_field form.owner %}
       {% render_field form.owner %}
     </div>
     </div>
   {% endif %}
   {% endif %}

+ 1 - 1
netbox/templates/ipam/prefix/prefixes.html

@@ -6,7 +6,7 @@
   {% include 'ipam/inc/max_depth.html' %}
   {% include 'ipam/inc/max_depth.html' %}
   {% include 'ipam/inc/max_length.html' %}  
   {% include 'ipam/inc/max_length.html' %}  
   {% if perms.ipam.add_prefix and first_available_prefix %}
   {% if perms.ipam.add_prefix and first_available_prefix %}
-    <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-primary">
+    <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.scope %}&scope_type={{ object.scope_type.pk }}&scope={{ object.scope.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-primary">
       <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Prefix" %}
       <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Prefix" %}
     </a>
     </a>
   {% endif %}
   {% endif %}

+ 2 - 1
netbox/templates/ipam/vlan_edit.html

@@ -67,8 +67,9 @@
 
 
   <div class="field-group mb-5">
   <div class="field-group mb-5">
     <div class="row">
     <div class="row">
-      <h2 class="col-9 offset-3">{% trans "Owner" %}</h2>
+      <h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
     </div>
     </div>
+    {% render_field form.owner_group %}
     {% render_field form.owner %}
     {% render_field form.owner %}
   </div>
   </div>
 
 

+ 10 - 5
netbox/tenancy/forms/filtersets.py

@@ -31,8 +31,9 @@ __all__ = (
 class TenantGroupFilterForm(NestedGroupModelFilterSetForm):
 class TenantGroupFilterForm(NestedGroupModelFilterSetForm):
     model = TenantGroup
     model = TenantGroup
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('parent_id', name=_('Tenant Group')),
         FieldSet('parent_id', name=_('Tenant Group')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
@@ -45,8 +46,9 @@ class TenantGroupFilterForm(NestedGroupModelFilterSetForm):
 class TenantFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
 class TenantFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
     model = Tenant
     model = Tenant
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('group_id', name=_('Tenant')),
         FieldSet('group_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
     )
     )
     group_id = DynamicModelMultipleChoiceField(
     group_id = DynamicModelMultipleChoiceField(
@@ -65,8 +67,9 @@ class TenantFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
 class ContactGroupFilterForm(NestedGroupModelFilterSetForm):
 class ContactGroupFilterForm(NestedGroupModelFilterSetForm):
     model = ContactGroup
     model = ContactGroup
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('parent_id', name=_('Contact Group')),
         FieldSet('parent_id', name=_('Contact Group')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
@@ -79,7 +82,8 @@ class ContactGroupFilterForm(NestedGroupModelFilterSetForm):
 class ContactRoleFilterForm(OrganizationalModelFilterSetForm):
 class ContactRoleFilterForm(OrganizationalModelFilterSetForm):
     model = ContactRole
     model = ContactRole
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
@@ -87,8 +91,9 @@ class ContactRoleFilterForm(OrganizationalModelFilterSetForm):
 class ContactFilterForm(PrimaryModelFilterSetForm):
 class ContactFilterForm(PrimaryModelFilterSetForm):
     model = Contact
     model = Contact
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('group_id', name=_('Contact')),
         FieldSet('group_id', name=_('Contact')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     group_id = DynamicModelMultipleChoiceField(
     group_id = DynamicModelMultipleChoiceField(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),

BIN
netbox/translations/cs/LC_MESSAGES/django.mo


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 336 - 274
netbox/translations/cs/LC_MESSAGES/django.po


BIN
netbox/translations/da/LC_MESSAGES/django.mo


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 336 - 274
netbox/translations/da/LC_MESSAGES/django.po


BIN
netbox/translations/de/LC_MESSAGES/django.mo


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است