Kaynağa Gözat

Merge branch 'main' into 20490-restrict-script-permissions

Brian Tiemann 2 hafta önce
ebeveyn
işleme
03853c3120
100 değiştirilmiş dosya ile 1982 ekleme ve 3629 silme
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 43 0
      .github/ISSUE_TEMPLATE/03-performance.yaml
  4. 0 0
      .github/ISSUE_TEMPLATE/04-documentation_change.yaml
  5. 0 0
      .github/ISSUE_TEMPLATE/05-translation.yaml
  6. 0 0
      .github/ISSUE_TEMPLATE/06-housekeeping.yaml
  7. 0 0
      .github/ISSUE_TEMPLATE/07-deprecation.yaml
  8. 0 0
      .github/ISSUE_TEMPLATE/08-feature_removal.yaml
  9. 298 2734
      contrib/openapi.json
  10. 23 11
      docs/administration/netbox-shell.md
  11. 1 1
      docs/configuration/index.md
  12. 4 4
      docs/installation/1-postgresql.md
  13. 14 14
      docs/installation/3-netbox.md
  14. 14 8
      docs/installation/4a-gunicorn.md
  15. 1 1
      docs/installation/5-http-server.md
  16. 2 2
      docs/installation/index.md
  17. 4 4
      docs/installation/upgrading.md
  18. 46 4
      docs/integrations/rest-api.md
  19. 1 0
      docs/release-notes/version-4.4.md
  20. 38 1
      docs/release-notes/version-4.5.md
  21. 16 8
      netbox/circuits/forms/filtersets.py
  22. 2 1
      netbox/core/api/serializers_/jobs.py
  23. 15 5
      netbox/core/api/views.py
  24. 5 1
      netbox/core/filtersets.py
  25. 7 2
      netbox/core/forms/filtersets.py
  26. 18 0
      netbox/core/migrations/0021_job_queue_name.py
  27. 14 3
      netbox/core/models/jobs.py
  28. 21 4
      netbox/core/models/object_types.py
  29. 4 1
      netbox/core/tables/jobs.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. 11 1
      netbox/dcim/forms/model_forms.py
  34. 2 1
      netbox/dcim/forms/object_create.py
  35. 15 0
      netbox/dcim/graphql/filter_mixins.py
  36. 5 1
      netbox/dcim/graphql/filters.py
  37. 1 1
      netbox/dcim/graphql/types.py
  38. 5 1
      netbox/dcim/models/modules.py
  39. 1 1
      netbox/dcim/models/racks.py
  40. 28 0
      netbox/dcim/tables/devices.py
  41. 18 0
      netbox/dcim/tables/template_code.py
  42. 136 0
      netbox/dcim/tests/test_models.py
  43. 1 1
      netbox/dcim/ui/panels.py
  44. 3 3
      netbox/dcim/utils.py
  45. 10 0
      netbox/dcim/views.py
  46. 11 0
      netbox/extras/constants.py
  47. 54 42
      netbox/extras/events.py
  48. 0 4
      netbox/extras/forms/bulk_import.py
  49. 33 69
      netbox/extras/forms/filtersets.py
  50. 13 6
      netbox/extras/forms/model_forms.py
  51. 1 1
      netbox/extras/models/scripts.py
  52. 1 1
      netbox/extras/scripts.py
  53. 7 11
      netbox/extras/signals.py
  54. 1 1
      netbox/extras/tables/tables.py
  55. 51 1
      netbox/extras/tests/test_models.py
  56. 41 5
      netbox/extras/tests/test_views.py
  57. 2 1
      netbox/extras/utils.py
  58. 7 1
      netbox/extras/views.py
  59. 38 0
      netbox/ipam/api/serializers_/ip.py
  60. 4 3
      netbox/ipam/api/views.py
  61. 33 17
      netbox/ipam/forms/filtersets.py
  62. 4 4
      netbox/ipam/graphql/filters.py
  63. 5 0
      netbox/ipam/tables/ip.py
  64. 1 1
      netbox/ipam/tables/template_code.py
  65. 18 2
      netbox/ipam/tables/vlans.py
  66. 25 0
      netbox/ipam/tests/test_api.py
  67. 41 0
      netbox/ipam/tests/test_tables.py
  68. 3 0
      netbox/ipam/utils.py
  69. 19 15
      netbox/netbox/api/serializers/base.py
  70. 18 13
      netbox/netbox/api/viewsets/__init__.py
  71. 2 0
      netbox/netbox/context.py
  72. 4 1
      netbox/netbox/context_managers.py
  73. 2 11
      netbox/netbox/forms/filtersets.py
  74. 57 4
      netbox/netbox/forms/mixins.py
  75. 26 0
      netbox/netbox/graphql/filter_lookups.py
  76. 0 2
      netbox/netbox/models/features.py
  77. 48 87
      netbox/netbox/navigation/menu.py
  78. 18 3
      netbox/netbox/tables/tables.py
  79. 11 1
      netbox/netbox/views/misc.py
  80. 0 0
      netbox/project-static/dist/netbox.css
  81. 0 0
      netbox/project-static/dist/netbox.js
  82. 0 0
      netbox/project-static/dist/netbox.js.map
  83. 1 1
      netbox/project-static/dist/rack_elevation.css
  84. 8 8
      netbox/project-static/package.json
  85. 40 0
      netbox/project-static/src/forms/clearField.ts
  86. 2 1
      netbox/project-static/src/forms/index.ts
  87. 314 407
      netbox/project-static/yarn.lock
  88. 2 2
      netbox/release.yaml
  89. 4 0
      netbox/templates/core/job.html
  90. 2 1
      netbox/templates/dcim/device_edit.html
  91. 2 1
      netbox/templates/dcim/htmx/cable_edit.html
  92. 7 27
      netbox/templates/dcim/interface.html
  93. 2 1
      netbox/templates/dcim/virtualchassis_edit.html
  94. 1 0
      netbox/templates/extras/inc/script_list_content.html
  95. 4 1
      netbox/templates/generic/bulk_edit.html
  96. 3 0
      netbox/templates/htmx/form.html
  97. 2 0
      netbox/templates/ipam/aggregate/prefixes.html
  98. 20 0
      netbox/templates/ipam/inc/max_depth.html
  99. 20 0
      netbox/templates/ipam/inc/max_length.html
  100. 3 1
      netbox/templates/ipam/prefix/prefixes.html

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

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

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

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

+ 43 - 0
.github/ISSUE_TEMPLATE/03-performance.yaml

@@ -0,0 +1,43 @@
+---
+name: 🏁 Performance
+type: Performance
+description: An opportunity to improve application performance
+labels: ["netbox", "type: performance", "status: needs triage"]
+body:
+  - type: input
+    attributes:
+      label: NetBox Version
+      description: What version of NetBox are you currently running?
+      placeholder: v4.5.1
+    validations:
+      required: true
+  - type: dropdown
+    attributes:
+      label: Python Version
+      description: What version of Python are you currently running?
+      options:
+        - "3.12"
+        - "3.13"
+        - "3.14"
+    validations:
+      required: true
+  - type: checkboxes
+    attributes:
+      label: Area(s) of Concern
+      description: Which application interface(s) are affected?
+      options:
+        - label: User Interface
+        - label: REST API
+        - label: GraphQL API
+        - label: Python ORM
+        - label: Other
+    validations:
+      required: true
+  - type: textarea
+    attributes:
+      label: Details
+      description: >
+        Describe in detail the operations being performed and the indications of a performance issue.
+        Include any relevant testing parameters, benchmarks, and expected results.
+    validations:
+      required: true

+ 0 - 0
.github/ISSUE_TEMPLATE/03-documentation_change.yaml → .github/ISSUE_TEMPLATE/04-documentation_change.yaml


+ 0 - 0
.github/ISSUE_TEMPLATE/04-translation.yaml → .github/ISSUE_TEMPLATE/05-translation.yaml


+ 0 - 0
.github/ISSUE_TEMPLATE/05-housekeeping.yaml → .github/ISSUE_TEMPLATE/06-housekeeping.yaml


+ 0 - 0
.github/ISSUE_TEMPLATE/06-deprecation.yaml → .github/ISSUE_TEMPLATE/07-deprecation.yaml


+ 0 - 0
.github/ISSUE_TEMPLATE/07-feature_removal.yaml → .github/ISSUE_TEMPLATE/08-feature_removal.yaml


Dosya farkı çok büyük olduğundan ihmal edildi
+ 298 - 2734
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:
 
 ```
-./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)
-### 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:
 
 ```
 >>> lsmodels()
+  ...
 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
     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")
 ```
 
-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")

+ 1 - 1
docs/configuration/index.md

@@ -15,7 +15,7 @@ Some configuration parameters may alternatively be defined either in `configurat
 
 ## 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)
 * [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom)

+ 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
 $ 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.
 
 netbox=> \conninfo
 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
 ```
 

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

@@ -36,7 +36,7 @@ sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
 ```
 
 !!! 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
 
@@ -63,12 +63,12 @@ This command should generate output similar to the following:
 
 ```
 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.
@@ -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:
 
 * `ALLOWED_HOSTS`
-* `DATABASES` (or `DATABASE`)
+* `API_TOKEN_PEPPERS`
+* `DATABASES`
 * `REDIS`
 * `SECRET_KEY`
 
@@ -158,7 +159,7 @@ DATABASES = {
 
 ### 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.
 
@@ -252,7 +253,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
 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
 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:
 
 ```no-highlight
-Watching for file changes with StatReloader
 Performing system checks...
 
 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.
 ```
 

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

@@ -43,16 +43,22 @@ You should see output similar to the following:
 
 ```no-highlight
 ● 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/
-   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
-             ├─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.
 
 !!! 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
 

+ 2 - 2
docs/installation/index.md

@@ -12,12 +12,12 @@
 
 </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:
 
 1. [PostgreSQL database](1-postgresql.md)
-1. [Redis](2-redis.md)
+2. [Redis](2-redis.md)
 3. [NetBox components](3-netbox.md)
 4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.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
 # 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
 sudo tar -xzf v$NEWVER.tar.gz -C /opt
 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
 # 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/netbox/netbox/configuration.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 && \
 sudo git fetch --tags && \
-sudo git checkout v4.2.7
+sudo git checkout v4.5.0
 ```
 
 ## 4. Run the Upgrade Script
@@ -128,7 +128,7 @@ sudo ./upgrade.sh
 ```
 
 !!! 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
     sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh

+ 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.
 
-### 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
 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
-GET /api/ipam/prefixes/13980/?brief=1
+GET /api/ipam/prefixes/13980/?brief=true
 ```
 
 ```json

+ 1 - 0
docs/release-notes/version-4.4.md

@@ -40,6 +40,7 @@
 * [#20912](https://github.com/netbox-community/netbox/issues/20912) - Fix inconsistent clearing of `module` field on ModuleBay
 * [#20944](https://github.com/netbox-community/netbox/issues/20944) - Ensure cached scope is updated on child objects when a parent region/site/location is changed
 * [#20948](https://github.com/netbox-community/netbox/issues/20948) - Handle the deletion of related objects with `on_delete=RESTRICT` the same as `CASCADE`
+* [#20966](https://github.com/netbox-community/netbox/issues/20966) - Fix UI rendering issue when scrolling list of object types in permissions form
 * [#20969](https://github.com/netbox-community/netbox/issues/20969) - Fix querying of front port templates by `rear_port_id`
 * [#21011](https://github.com/netbox-community/netbox/issues/21011) - Avoid writing to the database when loading active ConfigRevision
 * [#21032](https://github.com/netbox-community/netbox/issues/21032) - Avoid SQL subquery in RestrictedQuerySet where unnecessary

+ 38 - 1
docs/release-notes/version-4.5.md

@@ -1,4 +1,41 @@
-## v4.5.0 (FUTURE)
+# NetBox v4.5
+
+## v4.5.1 (2026-01-20)
+
+### Enhancements
+
+* [#21018](https://github.com/netbox-community/netbox/issues/21018) - Enable filtering prefixes by location/site/site group/region directly via GraphQL API
+* [#21142](https://github.com/netbox-community/netbox/issues/21142) - Enable filtering device components by site/location/rack directly via GraphQL API
+* [#21144](https://github.com/netbox-community/netbox/issues/21144) - Enable specifying a prefix length for IP addresses when utilizing the `/api/ipam/prefixes/<id>/available-ips/` REST API endpoint
+* [#21165](https://github.com/netbox-community/netbox/issues/21165) - VLAN selector should default to group (instead of site)
+* [#21178](https://github.com/netbox-community/netbox/issues/21178) - Improve consistency of rack measurements in UI
+
+### Bug Fixes
+
+* [#19901](https://github.com/netbox-community/netbox/issues/19901) - Fix `RelatedObjectDoesNotExist` exception when importing modules into unnamed devices
+* [#20239](https://github.com/netbox-community/netbox/issues/20239) - Prevent shared mutable state in PluginMenuItem & PluginMenuButton
+* [#20933](https://github.com/netbox-community/netbox/issues/20933) - Fix writable `data_file` assignment for ConfigContext and ConfigContextProfile via the REST API
+* [#21039](https://github.com/netbox-community/netbox/issues/21039) - Fix support for AVIF image uploads
+* [#21050](https://github.com/netbox-community/netbox/issues/21050) - Clear device OOB IP assignments when reassigning IP addresses
+* [#21051](https://github.com/netbox-community/netbox/issues/21051) - Remove irrelevant object types from permissions form
+* [#21097](https://github.com/netbox-community/netbox/issues/21097) - Fix comparison lookups for ID filters in GraphQL API
+* [#21102](https://github.com/netbox-community/netbox/issues/21102) - Fix GraphiQL explorer UI
+* [#21117](https://github.com/netbox-community/netbox/issues/21117) - Avoid `ValueError` exception when `API_TOKEN_PEPPERS` is not defined
+* [#21118](https://github.com/netbox-community/netbox/issues/21118) - Address performance issue when saving sites with many assigned objects
+* [#21124](https://github.com/netbox-community/netbox/issues/21124) - Fix front/rear port mapping for module types
+* [#21134](https://github.com/netbox-community/netbox/issues/21134) - Fix bulk renaming for module types
+* [#21139](https://github.com/netbox-community/netbox/issues/21139) - Support `fields` parameter for job, object change, and object type REST API endpoints
+* [#21140](https://github.com/netbox-community/netbox/issues/21140) - Restore translation for object attribute labels on several UI views
+* [#21160](https://github.com/netbox-community/netbox/issues/21160) - Fix performance issue loading UI views caused by unintended `APISelect` choices resolution
+* [#21166](https://github.com/netbox-community/netbox/issues/21166) - Fix support for 32-bit ASN filtering in GraphQL API
+* [#21175](https://github.com/netbox-community/netbox/issues/21175) - Fix pending migrations warning when `DEFAULT_LANGUAGE` is set
+* [#21181](https://github.com/netbox-community/netbox/issues/21181) - Handle `AuthenticationFailed` exception when using an invalid API token to fetch media files
+* [#21213](https://github.com/netbox-community/netbox/issues/21213) - Tag weight field should be marked as required in UI forms
+* [#21231](https://github.com/netbox-community/netbox/issues/21231) - Presence of object types table should be checked only during migration (performance improvement)
+
+---
+
+## v4.5.0 (2026-01-06)
 
 ### Breaking Changes
 

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

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

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

@@ -31,7 +31,8 @@ class JobSerializer(BaseModelSerializer):
         model = Job
         fields = [
             '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')
 

+ 15 - 5
netbox/core/api/views.py

@@ -11,7 +11,6 @@ from rest_framework.decorators import action
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
-from rest_framework.viewsets import ReadOnlyModelViewSet
 from rq.job import Job as RQ_Job
 from rq.worker import Worker
 
@@ -64,7 +63,7 @@ class DataFileViewSet(NetBoxReadOnlyModelViewSet):
     filterset_class = filtersets.DataFileFilterSet
 
 
-class JobViewSet(ReadOnlyModelViewSet):
+class JobViewSet(NetBoxReadOnlyModelViewSet):
     """
     Retrieve a list of job results
     """
@@ -73,19 +72,20 @@ class JobViewSet(ReadOnlyModelViewSet):
     filterset_class = filtersets.JobFilterSet
 
 
-class ObjectChangeViewSet(ReadOnlyModelViewSet):
+class ObjectChangeViewSet(NetBoxReadOnlyModelViewSet):
     """
     Retrieve a list of recent changes.
     """
     metadata_class = ContentTypeMetadata
+    queryset = ObjectChange.objects.all()
     serializer_class = serializers.ObjectChangeSerializer
     filterset_class = filtersets.ObjectChangeFilterSet
 
     def get_queryset(self):
-        return ObjectChange.objects.valid_models()
+        return super().get_queryset().valid_models()
 
 
-class ObjectTypeViewSet(ReadOnlyModelViewSet):
+class ObjectTypeViewSet(NetBoxReadOnlyModelViewSet):
     """
     Read-only list of ObjectTypes.
     """
@@ -94,6 +94,16 @@ class ObjectTypeViewSet(ReadOnlyModelViewSet):
     serializer_class = serializers.ObjectTypeSerializer
     filterset_class = filtersets.ObjectTypeFilterSet
 
+    def initial(self, request, *args, **kwargs):
+        """
+        Override initial() to skip the restrict() call since ObjectType (a ContentType proxy)
+        doesn't use RestrictedQuerySet and is publicly accessible metadata.
+        """
+        # Call GenericViewSet.initial() directly, skipping BaseViewSet.initial()
+        # which would try to call restrict() on the queryset
+        from rest_framework.viewsets import GenericViewSet
+        GenericViewSet.initial(self, request, *args, **kwargs)
+
 
 class BaseRQViewSet(viewsets.ViewSet):
     """

+ 5 - 1
netbox/core/filtersets.py

@@ -129,10 +129,14 @@ class JobFilterSet(BaseFilterSet):
         choices=JobStatusChoices,
         null_value=None
     )
+    queue_name = django_filters.CharFilter()
 
     class Meta:
         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):
         if not value.strip():

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

@@ -26,8 +26,9 @@ __all__ = (
 class DataSourceFilterForm(PrimaryModelFilterSetForm):
     model = DataSource
     fieldsets = (
-        FieldSet('q', 'filter_id', 'tag', 'owner_id'),
+        FieldSet('q', 'filter_id', 'tag'),
         FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     type = forms.MultipleChoiceField(
         label=_('Type'),
@@ -71,7 +72,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
     model = Job
     fieldsets = (
         FieldSet('q', 'filter_id'),
-        FieldSet('object_type_id', 'status', name=_('Attributes')),
+        FieldSet('object_type_id', 'status', 'queue_name', name=_('Attributes')),
         FieldSet(
             'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
             'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
@@ -87,6 +88,10 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
         choices=JobStatusChoices,
         required=False
     )
+    queue_name = forms.CharField(
+        label=_('Queue'),
+        required=False
+    )
     created__after = forms.DateTimeField(
         label=_('Created after'),
         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'),
         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(
         verbose_name=_('log entries'),
         base_field=models.JSONField(
@@ -179,11 +185,15 @@ class Job(models.Model):
         return f"{int(minutes)} minutes, {seconds:.2f} seconds"
 
     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)
 
-        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)
-        job = queue.fetch_job(str(self.job_id))
+        job = queue.fetch_job(rq_job_id)
 
         if job:
             try:
@@ -288,7 +298,8 @@ class Job(models.Model):
             scheduled=schedule_at,
             interval=interval,
             user=user,
-            job_id=uuid.uuid4()
+            job_id=uuid.uuid4(),
+            queue_name=rq_queue_name
         )
         job.full_clean()
         job.save()

+ 21 - 4
netbox/core/models/object_types.py

@@ -9,6 +9,7 @@ from django.db import connection, models
 from django.db.models import Q
 from django.utils.translation import gettext as _
 
+from netbox.context import query_cache
 from netbox.plugins import PluginConfig
 from netbox.registry import registry
 from utilities.string import title
@@ -35,6 +36,10 @@ class ObjectTypeQuerySet(models.QuerySet):
 
 class ObjectTypeManager(models.Manager):
 
+    # TODO: Remove this in NetBox v5.0
+    # Cache the result of introspection to avoid repeated queries.
+    _table_exists = False
+
     def get_queryset(self):
         return ObjectTypeQuerySet(self.model, using=self._db)
 
@@ -66,13 +71,21 @@ class ObjectTypeManager(models.Manager):
         """
         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
         # If the ObjectType table has not yet been provisioned (e.g. because we're in a pre-v4.4 migration),
         # fall back to ContentType.
-        if 'core_objecttype' not in connection.introspection.table_names():
-            ct = ContentType.objects.get_for_model(model, for_concrete_model=for_concrete_model)
-            ct.features = get_model_features(ct.model_class())
-            return ct
+        if not ObjectTypeManager._table_exists:
+            if 'core_objecttype' not in connection.introspection.table_names():
+                ct = ContentType.objects.get_for_model(model, for_concrete_model=for_concrete_model)
+                ct.features = get_model_features(ct.model_class())
+                return ct
+            ObjectTypeManager._table_exists = True
 
         if not inspect.isclass(model):
             model = model.__class__
@@ -90,6 +103,10 @@ class ObjectTypeManager(models.Manager):
                 features=get_model_features(model),
             )[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
 
     def get_for_models(self, *models, for_concrete_models=True):

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

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

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

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

@@ -75,7 +75,7 @@ class ScopedForm(forms.Form):
             except ObjectDoesNotExist:
                 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
 
         else:

+ 11 - 1
netbox/dcim/forms/model_forms.py

@@ -20,7 +20,9 @@ from utilities.forms.fields import (
     DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
 )
 from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
-from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
+from utilities.forms.widgets import (
+    APISelect, ClearableFileInput, ClearableSelect, HTMXSelect, NumberWithOptions, SelectWithPK,
+)
 from utilities.jsonschema import JSONSchemaProperty
 from virtualization.models import Cluster, VMInterface
 from wireless.models import WirelessLAN, WirelessLANGroup
@@ -592,6 +594,14 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
             },
         )
     )
+    face = forms.ChoiceField(
+        label=_('Face'),
+        choices=add_blank_choice(DeviceFaceChoices),
+        required=False,
+        widget=ClearableSelect(
+            requires_fields=['rack']
+        )
+    )
     device_type = DynamicModelChoiceField(
         label=_('Device type'),
         queryset=DeviceType.objects.all(),

+ 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 netbox.forms import NetBoxModelForm
+from netbox.forms.mixins import OwnerMixin
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
 from utilities.forms.rendering import FieldSet, TabbedGroups
 from utilities.forms.widgets import APISelect
@@ -271,7 +272,7 @@ class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm
 # Virtual chassis
 #
 
-class VirtualChassisCreateForm(NetBoxModelForm):
+class VirtualChassisCreateForm(OwnerMixin, NetBoxModelForm):
     region = DynamicModelChoiceField(
         label=_('Region'),
         queryset=Region.objects.all(),

+ 15 - 0
netbox/dcim/graphql/filter_mixins.py

@@ -13,6 +13,7 @@ if TYPE_CHECKING:
     from netbox.graphql.filter_lookups import IntegerLookup
     from extras.graphql.filters import ConfigTemplateFilter
     from ipam.graphql.filters import VLANFilter, VLANTranslationPolicyFilter
+    from dcim.graphql.filters import LocationFilter, RegionFilter, SiteFilter, SiteGroupFilter
     from .filters import *
 
 __all__ = (
@@ -35,6 +36,20 @@ class ScopedFilterMixin:
     )
     scope_id: ID | None = strawberry_django.filter_field()
 
+    # Cached relations
+    _location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='location')
+    )
+    _region: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='region')
+    )
+    _site_group: Annotated['SiteGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='site_group')
+    )
+    _site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='site')
+    )
+
 
 @dataclass
 class ComponentModelFilterMixin:

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

@@ -550,6 +550,10 @@ class InterfaceFilter(
         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
     def connected(self, queryset, value: bool, prefix: str):
         if value is True:
@@ -889,7 +893,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
 
 
 @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 = (
         strawberry_django.filter_field()
     )

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

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

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

@@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
 from jsonschema.exceptions import ValidationError as JSONValidationError
 
 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 netbox.models import PrimaryModel
 from netbox.models.features import ImageAttachmentsMixin
@@ -155,6 +155,8 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             'description': self.description,
             'weight': float(self.weight) if self.weight is not None else None,
             'weight_unit': self.weight_unit,
+            'airflow': self.airflow,
+            'attribute_data': self.attribute_data,
             'comments': self.comments,
         }
 
@@ -359,5 +361,7 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
                     update_fields=update_fields
                 )
 
+        # 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
         update_interface_bridges(self.device, self.module_type.interfacetemplates, self)

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

@@ -122,7 +122,7 @@ class RackBase(WeightMixin, PrimaryModel):
         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.
     Each Rack is assigned to a Site and (optionally) a Location.

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

@@ -27,6 +27,7 @@ __all__ = (
     'DeviceTable',
     'FrontPortTable',
     'InterfaceTable',
+    'InterfaceLAGMemberTable',
     'InventoryItemRoleTable',
     'InventoryItemTable',
     'MACAddressTable',
@@ -689,6 +690,33 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
         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):
     name = tables.TemplateColumn(
         verbose_name=_('Name'),

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

@@ -24,6 +24,24 @@ INTERFACE_LINKTERMINATION = """
 {% 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 = """
 {% load helpers %}
 {% 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.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):
 

+ 1 - 1
netbox/dcim/ui/panels.py

@@ -31,7 +31,7 @@ class RackDimensionsPanel(panels.ObjectAttributesPanel):
     outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display')
     outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display')
     outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display')
-    mounting_depth = attrs.TextAttr('mounting_depth', format_string='{} mm')
+    mounting_depth = attrs.TextAttr('mounting_depth', format_string=_('{} millimeters'))
 
 
 class RackNumberingPanel(panels.ObjectAttributesPanel):

+ 3 - 3
netbox/dcim/utils.py

@@ -85,13 +85,13 @@ def update_interface_bridges(device, interface_templates, module=None):
             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
 
-    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
     front_ports = {

+ 10 - 0
netbox/dcim/views.py

@@ -880,6 +880,7 @@ class RackTypeView(GetRelatedModelsMixin, generic.ObjectView):
             panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']),
             CustomFieldsPanel(),
             RelatedObjectsPanel(),
+            ImageAttachmentsPanel(),
         ],
     )
 
@@ -3135,6 +3136,14 @@ class InterfaceView(generic.ObjectView):
         )
         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
         vlans = []
         if instance.untagged_vlan is not None:
@@ -3164,6 +3173,7 @@ class InterfaceView(generic.ObjectView):
             'bridge_interfaces': bridge_interfaces,
             'bridge_interfaces_table': bridge_interfaces_table,
             'child_interfaces_table': child_interfaces_table,
+            'lag_interfaces_table': lag_interfaces_table,
             'vlan_table': vlan_table,
             'vlan_translation_table': vlan_translation_table,
         }

+ 11 - 0
netbox/extras/constants.py

@@ -4,6 +4,17 @@ from extras.choices import LogLevelChoices
 # Custom fields
 CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
 
+# ImageAttachment
+IMAGE_ATTACHMENT_IMAGE_FORMATS = {
+    'avif': 'image/avif',
+    'bmp': 'image/bmp',
+    'gif': 'image/gif',
+    'jpeg': 'image/jpeg',
+    'jpg': 'image/jpeg',
+    'png': 'image/png',
+    'webp': 'image/webp',
+}
+
 # Template Export
 DEFAULT_MIME_TYPE = 'text/plain; charset=utf-8'
 

+ 54 - 42
netbox/extras/events.py

@@ -1,5 +1,5 @@
 import logging
-from collections import defaultdict
+from collections import UserDict, defaultdict
 
 from django.conf import settings
 from django.utils import timezone
@@ -12,7 +12,6 @@ from core.models import ObjectType
 from netbox.config import get_config
 from netbox.constants import RQ_QUEUE_DEFAULT
 from netbox.models.features import has_feature
-from users.models import User
 from utilities.api import get_serializer_for_model
 from utilities.request import copy_safe_request
 from utilities.rqworker import get_rq_retry
@@ -23,6 +22,21 @@ from .models import EventRule
 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):
     """
     Return a serialized representation of the given instance suitable for use in a queued event.
@@ -66,37 +80,42 @@ def enqueue_event(queue, instance, request, event_type):
     assert instance.pk is not None
     key = f'{app_label}.{model_name}:{instance.pk}'
     if key in queue:
-        queue[key]['data'] = serialize_for_event(instance)
         queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
         # If the object is being deleted, update any prior "update" event to "delete"
         if event_type == OBJECT_DELETED:
             queue[key]['event_type'] = event_type
     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
-            '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 = User.objects.get(username=username) if username else None
+def process_event_rules(event_rules, object_type, event):
+    """
+    Process a list of EventRules against an event.
+    """
 
     for event_rule in event_rules:
 
         # Evaluate event rule conditions (if any)
-        if not event_rule.eval_conditions(data):
+        if not event_rule.eval_conditions(event['data']):
             continue
 
         # Compile event data
         event_data = event_rule.action_data or {}
-        event_data.update(data)
+        event_data.update(event['data'])
 
         # Webhooks
         if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
@@ -109,25 +128,20 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
             params = {
                 "event_rule": event_rule,
                 "object_type": object_type,
-                "event_type": event_type,
+                "event_type": event['event_type'],
                 "data": event_data,
-                "snapshots": snapshots,
+                "snapshots": event.get('snapshots'),
                 "timestamp": timezone.now().isoformat(),
-                "username": username,
+                "username": event['username'],
                 "retry": get_rq_retry()
             }
-            if snapshots:
-                params["snapshots"] = snapshots
-            if request:
+            if 'request' in event:
                 # Exclude FILES - webhooks don't need uploaded files,
                 # 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
-            rq_queue.enqueue(
-                "extras.webhooks.send_webhook",
-                **params
-            )
+            rq_queue.enqueue('extras.webhooks.send_webhook', **params)
 
         # Scripts
         elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
@@ -139,16 +153,16 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
             params = {
                 "instance": event_rule.action_object,
                 "name": script.name,
-                "user": user,
+                "user": event['user'],
                 "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
         elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION:
@@ -157,7 +171,7 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
                 object_type=object_type,
                 object_id=event_data['id'],
                 object_repr=event_data.get('display'),
-                event_type=event_type
+                event_type=event['event_type']
             )
 
         else:
@@ -169,6 +183,8 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
 def process_event_queue(events):
     """
     Flush a list of object representation to RQ for EventRule processing.
+
+    This is the default processor listed in EVENTS_PIPELINE.
     """
     events_cache = defaultdict(dict)
 
@@ -188,11 +204,7 @@ def process_event_queue(events):
         process_event_rules(
             event_rules=event_rules,
             object_type=object_type,
-            event_type=event['event_type'],
-            data=event['data'],
-            username=event['username'],
-            snapshots=event['snapshots'],
-            request=event['request'],
+            event=event,
         )
 
 

+ 0 - 4
netbox/extras/forms/bulk_import.py

@@ -271,10 +271,6 @@ class EventRuleImportForm(OwnerCSVMixin, NetBoxModelImportForm):
 
 class TagImportForm(OwnerCSVMixin, CSVModelForm):
     slug = SlugField()
-    weight = forms.IntegerField(
-        label=_('Weight'),
-        required=False
-    )
     object_types = CSVMultipleContentTypeField(
         label=_('Object types'),
         queryset=ObjectType.objects.with_feature('tags'),

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

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

+ 13 - 6
netbox/extras/forms/model_forms.py

@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
 from core.forms.mixins import SyncedDataMixin
 from core.models import ObjectType
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
+from extras.constants import IMAGE_ATTACHMENT_IMAGE_FORMATS
 from extras.choices import *
 from extras.models import *
 from netbox.events import get_event_type_choices
@@ -177,6 +178,13 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
         ) + ' <code>choice1:First Choice</code>')
     )
 
+    fieldsets = (
+        FieldSet(
+            'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
+            name=_('Custom Field Choice Set')
+        ),
+    )
+
     class Meta:
         model = CustomFieldChoiceSet
         fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'owner')
@@ -570,10 +578,6 @@ class TagForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
         queryset=ObjectType.objects.with_feature('tags'),
         required=False
     )
-    weight = forms.IntegerField(
-        label=_('Weight'),
-        required=False
-    )
 
     fieldsets = (
         FieldSet('name', 'slug', 'color', 'weight', 'description', 'object_types', name=_('Tag')),
@@ -784,8 +788,11 @@ class ImageAttachmentForm(forms.ModelForm):
         fields = [
             'image', 'name', 'description',
         ]
-        help_texts = {
-            'name': _("If no name is specified, the file name will be used.")
+        # Explicitly set 'image/avif' to support AVIF selection in Firefox
+        widgets = {
+            'image': forms.ClearableFileInput(
+                attrs={'accept': ','.join(sorted(set(IMAGE_ATTACHMENT_IMAGE_FORMATS.values())))}
+            ),
         }
 
 

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

@@ -137,7 +137,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
             module = self.get_module()
         except Exception as 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
 
         scripts = {}

+ 1 - 1
netbox/extras/scripts.py

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

+ 7 - 11
netbox/extras/signals.py

@@ -4,7 +4,7 @@ from django.dispatch import receiver
 
 from core.events import *
 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 netbox.config import get_config
 from netbox.models.features import has_feature
@@ -102,14 +102,12 @@ def process_job_start_event_rules(sender, **kwargs):
         enabled=True,
         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,
         data=sender.data,
-        username=username
+        user=sender.user,
     )
+    process_event_rules(event_rules, sender.object_type, event)
 
 
 @receiver(job_end)
@@ -122,14 +120,12 @@ def process_job_end_event_rules(sender, **kwargs):
         enabled=True,
         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,
         data=sender.data,
-        username=username
+        user=sender.user,
     )
+    process_event_rules(event_rules, sender.object_type, event)
 
 
 #

+ 1 - 1
netbox/extras/tables/tables.py

@@ -43,7 +43,7 @@ IMAGEATTACHMENT_IMAGE = """
   <a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top">
     <i class="mdi mdi-image"></i></a>
 {% endif %}
-<a href="{{ record.get_absolute_url }}">{{ record }}</a>
+<a href="{{ record.get_absolute_url }}">{{ record.filename|truncate_middle:16 }}</a>
 """
 
 NOTIFICATION_ICON = """

+ 51 - 1
netbox/extras/tests/test_models.py

@@ -6,7 +6,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
 from django.forms import ValidationError
 from django.test import tag, TestCase
 
-from core.models import DataSource, ObjectType
+from core.models import AutoSyncRecord, DataSource, ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
 from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, ImageAttachment, Tag, TaggedItem
 from tenancy.models import Tenant, TenantGroup
@@ -754,3 +754,53 @@ class ConfigTemplateTest(TestCase):
     @tag('regression')
     def test_config_template_with_data_source_nested_templates(self):
         self.assertEqual(self.BASE_TEMPLATE, self.main_config_template.render({}))
+
+    @tag('regression')
+    def test_autosyncrecord_cleanup_on_detach(self):
+        """Test that AutoSyncRecord is deleted when detaching from DataSource."""
+        with tempfile.TemporaryDirectory() as temp_dir:
+            templates_dir = Path(temp_dir) / "templates"
+            templates_dir.mkdir(parents=True, exist_ok=True)
+
+            self._create_template_file(templates_dir, 'test.j2', 'Test content')
+
+            data_source = DataSource(
+                name="Test DataSource for Detach",
+                type="local",
+                source_url=str(templates_dir),
+            )
+            data_source.save()
+            data_source.sync()
+
+            data_file = data_source.datafiles.filter(path__endswith='test.j2').first()
+
+            # Create a ConfigTemplate with data_file and auto_sync_enabled
+            config_template = ConfigTemplate(
+                name="TestTemplateForDetach",
+                data_file=data_file,
+                auto_sync_enabled=True
+            )
+            config_template.clean()
+            config_template.save()
+
+            # Verify AutoSyncRecord was created
+            object_type = ObjectType.objects.get_for_model(ConfigTemplate)
+            autosync_records = AutoSyncRecord.objects.filter(
+                object_type=object_type,
+                object_id=config_template.pk
+            )
+            self.assertEqual(autosync_records.count(), 1, "AutoSyncRecord should be created")
+
+            # Detach from DataSource
+            config_template.data_file = None
+            config_template.data_source = None
+            config_template.auto_sync_enabled = False
+            config_template.clean()
+            config_template.save()
+
+            # Verify AutoSyncRecord was deleted
+            autosync_records = AutoSyncRecord.objects.filter(
+                object_type=object_type,
+                object_id=config_template.pk
+            )
+            self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")

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

@@ -1,6 +1,7 @@
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.test import tag
+from unittest.mock import patch, PropertyMock
 
 from core.choices import ManagedFileRootPathChoices
 from core.events import *
@@ -906,7 +907,7 @@ class ScriptValidationErrorTest(TestCase):
     user_permissions = ['extras.view_script', 'extras.run_script']
 
     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 Meta:
@@ -930,8 +931,6 @@ class ScriptValidationErrorTest(TestCase):
 
     @tag('regression')
     def test_script_validation_error_displays_message(self):
-        from unittest.mock import patch
-
         url = reverse('extras:script', kwargs={'pk': self.script.pk})
 
         with patch('extras.views.get_workers_for_queue', return_value=['worker']):
@@ -944,8 +943,6 @@ class ScriptValidationErrorTest(TestCase):
 
     @tag('regression')
     def test_script_validation_error_no_toast_for_fieldset_fields(self):
-        from unittest.mock import patch, PropertyMock
-
         class FieldsetScript(PythonClass):
             class Meta:
                 name = 'Fieldset test'
@@ -967,3 +964,42 @@ class ScriptValidationErrorTest(TestCase):
         self.assertEqual(response.status_code, 200)
         messages = list(response.context['messages'])
         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'])

+ 2 - 1
netbox/extras/utils.py

@@ -10,6 +10,7 @@ from taggit.managers import _TaggableManager
 
 from netbox.context import current_request
 
+from .constants import IMAGE_ATTACHMENT_IMAGE_FORMATS
 from .validators import CustomValidator
 
 __all__ = (
@@ -78,7 +79,7 @@ def image_upload(instance, filename):
     """
     upload_dir = 'image-attachments'
     default_filename = 'unnamed'
-    allowed_img_extensions = ('bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp')
+    allowed_img_extensions = IMAGE_ATTACHMENT_IMAGE_FORMATS.keys()
 
     # Normalize Windows paths and create a Path object.
     normalized_filename = str(filename).replace('\\', '/')

+ 7 - 1
netbox/extras/views.py

@@ -1525,7 +1525,13 @@ class ScriptView(BaseScriptView):
                 '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
         if not get_workers_for_queue('default'):

+ 38 - 0
netbox/ipam/api/serializers_/ip.py

@@ -19,6 +19,7 @@ from ..field_serializers import IPAddressField, IPNetworkField
 __all__ = (
     'AggregateSerializer',
     'AvailableIPSerializer',
+    'AvailableIPRequestSerializer',
     'AvailablePrefixSerializer',
     'IPAddressSerializer',
     'IPRangeSerializer',
@@ -147,6 +148,43 @@ class IPRangeSerializer(PrimaryModelSerializer):
 # IP addresses
 #
 
+class AvailableIPRequestSerializer(serializers.Serializer):
+    """
+    Request payload for creating IP addresses from the available-ips endpoint.
+    """
+    prefix_length = serializers.IntegerField(required=False)
+
+    def to_internal_value(self, data):
+        data = super().to_internal_value(data)
+
+        prefix_length = data.get('prefix_length')
+        if prefix_length is None:
+            # No override requested; the parent prefix/range mask length will be used.
+            return data
+
+        parent = self.context.get('parent')
+        if parent is None:
+            return data
+
+        # Validate the requested prefix length
+        if prefix_length < parent.mask_length:
+            raise serializers.ValidationError({
+                'prefix_length': 'Prefix length must be greater than or equal to the parent mask length ({})'.format(
+                    parent.mask_length
+                )
+            })
+        elif parent.family == 4 and prefix_length > 32:
+            raise serializers.ValidationError({
+                'prefix_length': 'Invalid prefix length ({}) for IPv6'.format(prefix_length)
+            })
+        elif parent.family == 6 and prefix_length > 128:
+            raise serializers.ValidationError({
+                'prefix_length': 'Invalid prefix length ({}) for IPv4'.format(prefix_length)
+            })
+
+        return data
+
+
 class IPAddressSerializer(PrimaryModelSerializer):
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     address = IPAddressField()

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

@@ -400,7 +400,7 @@ class AvailablePrefixesView(AvailableObjectsView):
 class AvailableIPAddressesView(AvailableObjectsView):
     queryset = IPAddress.objects.all()
     read_serializer_class = serializers.AvailableIPSerializer
-    write_serializer_class = serializers.AvailableIPSerializer
+    write_serializer_class = serializers.AvailableIPRequestSerializer
     advisory_lock_key = 'available-ips'
 
     def get_available_objects(self, parent, limit=None):
@@ -421,8 +421,9 @@ class AvailableIPAddressesView(AvailableObjectsView):
     def prep_object_data(self, requested_objects, available_objects, parent):
         available_ips = iter(available_objects)
         for i, request_data in enumerate(requested_objects):
+            prefix_length = request_data.pop('prefix_length', None) or parent.mask_length
             request_data.update({
-                'address': f'{next(available_ips)}/{parent.mask_length}',
+                'address': f'{next(available_ips)}/{prefix_length}',
                 'vrf': parent.vrf.pk if parent.vrf else None,
             })
 
@@ -435,7 +436,7 @@ class AvailableIPAddressesView(AvailableObjectsView):
     @extend_schema(
         methods=["post"],
         responses={201: serializers.IPAddressSerializer(many=True)},
-        request=serializers.IPAddressSerializer(many=True),
+        request=serializers.AvailableIPRequestSerializer(many=True),
     )
     def post(self, request, pk):
         return super().post(request, pk)

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

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

+ 4 - 4
netbox/ipam/graphql/filters.py

@@ -20,7 +20,7 @@ from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 from virtualization.models import VMInterface
 
 if TYPE_CHECKING:
-    from netbox.graphql.filter_lookups import IntegerLookup, IntegerRangeArrayLookup
+    from netbox.graphql.filter_lookups import BigIntegerLookup, IntegerLookup, IntegerRangeArrayLookup
     from circuits.graphql.filters import ProviderFilter
     from core.graphql.filters import ContentTypeFilter
     from dcim.graphql.filters import SiteFilter
@@ -53,7 +53,7 @@ __all__ = (
 class ASNFilter(TenancyFilterMixin, PrimaryModelFilter):
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     rir_id: ID | None = strawberry_django.filter_field()
-    asn: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+    asn: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
     sites: (
@@ -70,10 +70,10 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
     slug: FilterLookup[str] | None = strawberry_django.filter_field()
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     rir_id: ID | None = strawberry_django.filter_field()
-    start: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+    start: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    end: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+    end: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
 

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

@@ -370,6 +370,11 @@ class AnnotatedIPAddressTable(IPAddressTable):
         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):
         pass
 

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

@@ -6,7 +6,7 @@ PREFIX_LINK = """
 {% if record.pk %}
   <a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
 {% 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 %}
 """
 

+ 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 dcim.models import Interface
+from dcim.tables.template_code import INTERFACE_LINKTERMINATION, LINKTERMINATION
 from ipam.models import *
 from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
 from tenancy.tables import TenancyColumnsMixin, TenantColumn
@@ -159,11 +160,26 @@ class VLANDevicesTable(VLANMembersTable):
     actions = columns.ActionsColumn(
         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):
         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):

+ 25 - 0
netbox/ipam/tests/test_api.py

@@ -595,6 +595,31 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(len(response.data), 8)
 
+    def test_create_available_ip_with_mask(self):
+        """
+        Test the creation of an available IP address with a specific prefix length.
+        """
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
+        url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
+        self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress')
+
+        # Create an available IP with a specific prefix length
+        data = {
+            'prefix_length': 32,
+            'description': 'Test IP 1',
+        }
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(response.data['address'], '192.0.2.1/32')
+        self.assertEqual(response.data['description'], data['description'])
+
+        # Attempt to create an available IP with a prefix length less than its parent prefix
+        data = {
+            'prefix_length': 23,  # Prefix is a /24
+        }
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
     @tag('regression')
     def test_graphql_tenant_prefixes_contains_nested_skips_invalid(self):
         """

+ 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:
 
         # 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 = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
         child_prefixes = child_prefixes + available_prefixes

+ 19 - 15
netbox/netbox/api/serializers/base.py

@@ -1,9 +1,8 @@
 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.utils import extend_schema_field
+from rest_framework import serializers
 
 from utilities.api import get_related_object_by_attrs
 from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField
@@ -19,16 +18,18 @@ class BaseModelSerializer(serializers.ModelSerializer):
     display_url = NetBoxURLHyperlinkedIdentityField()
     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.
 
         :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
             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._requested_fields = fields
+        self._include_fields = fields or []
+        self._omit_fields = omit or []
 
         # Disable validators for nested objects (which already exist)
         if self.nested:
@@ -36,8 +37,8 @@ class BaseModelSerializer(serializers.ModelSerializer):
 
         # If this serializer is nested but no fields have been specified,
         # 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)
 
@@ -54,16 +55,19 @@ class BaseModelSerializer(serializers.ModelSerializer):
     @cached_property
     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
 
     @extend_schema_field(OpenApiTypes.STR)

+ 18 - 13
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.models import ProtectedError, RestrictedError
 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 status
 from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet
 
 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.exceptions import AbortRequest
 from utilities.query import reapply_model_ordering
@@ -59,33 +59,38 @@ class BaseViewSet(GenericViewSet):
         serializer_class = self.get_serializer_class()
 
         # 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)
 
         # 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)
 
         return qs
 
     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)
 
     @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
         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
-        elif self.brief:
+        if self.brief:
             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(

+ 2 - 0
netbox/netbox/context.py

@@ -3,8 +3,10 @@ from contextvars import ContextVar
 __all__ = (
     'current_request',
     'events_queue',
+    'query_cache',
 )
 
 
 current_request = ContextVar('current_request', default=None)
 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 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 extras.events import flush_events
 
@@ -16,6 +17,7 @@ def event_tracking(request):
     """
     current_request.set(request)
     events_queue.set({})
+    query_cache.set(defaultdict(dict))
 
     yield
 
@@ -26,3 +28,4 @@ def event_tracking(request):
     # Clear context vars
     current_request.set(None)
     events_queue.set({})
+    query_cache.set(None)

+ 2 - 11
netbox/netbox/forms/filtersets.py

@@ -3,10 +3,9 @@ from django.db.models import Q
 from django.utils.translation import gettext_lazy as _
 
 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 .mixins import CustomFieldsMixin, SavedFiltersMixin
+from .mixins import CustomFieldsMixin, OwnerFilterMixin, SavedFiltersMixin
 
 __all__ = (
     'NestedGroupModelFilterSetForm',
@@ -47,14 +46,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):
     """
     FilterSet form for models which inherit from PrimaryModel.

+ 57 - 4
netbox/netbox/forms/mixins.py

@@ -4,13 +4,14 @@ from django.utils.translation import gettext as _
 from core.models import ObjectType
 from extras.choices import *
 from extras.models import *
-from users.models import Owner
+from users.models import OwnerGroup, Owner
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 
 __all__ = (
     'ChangelogMessageMixin',
     'CustomFieldsMixin',
     'OwnerMixin',
+    'OwnerFilterMixin',
     'SavedFiltersMixin',
     'TagsMixin',
 )
@@ -22,7 +23,7 @@ class ChangelogMessageMixin(forms.Form):
     """
     changelog_message = forms.CharField(
         required=False,
-        max_length=200
+        max_length=200,
     )
 
     def __init__(self, *args, **kwargs):
@@ -42,6 +43,7 @@ class CustomFieldsMixin:
     Attributes:
         model: The model class
     """
+
     model = None
 
     def __init__(self, *args, **kwargs):
@@ -86,13 +88,20 @@ class CustomFieldsMixin:
 
 
 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(
         queryset=SavedFilter.objects.all(),
         required=False,
         label=_('Saved Filter'),
         query_params={
             'usable': True,
-        }
+        },
     )
 
     def __init__(self, *args, **kwargs):
@@ -107,6 +116,13 @@ class SavedFiltersMixin(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(
         queryset=Tag.objects.all(),
         required=False,
@@ -124,10 +140,47 @@ class TagsMixin(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(
         queryset=Owner.objects.all(),
         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'),
     )

+ 26 - 0
netbox/netbox/graphql/filter_lookups.py

@@ -19,8 +19,11 @@ from strawberry_django import (
     process_filters,
 )
 
+from netbox.graphql.scalars import BigInt
+
 __all__ = (
     'ArrayLookup',
+    'BigIntegerLookup',
     'FloatArrayLookup',
     'FloatLookup',
     'IntegerArrayLookup',
@@ -78,6 +81,29 @@ class IntegerLookup:
         return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
 
 
+@strawberry.input(one_of=True, description='Lookup for BigInteger fields. Only one of the lookup fields can be set.')
+class BigIntegerLookup:
+    filter_lookup: FilterLookup[BigInt] | None = strawberry_django.filter_field()
+    range_lookup: RangeLookup[BigInt] | None = strawberry_django.filter_field()
+    comparison_lookup: ComparisonFilterLookup[BigInt] | None = strawberry_django.filter_field()
+
+    def get_filter(self):
+        for field in self.__strawberry_definition__.fields:
+            value = getattr(self, field.name, None)
+            if value is not strawberry.UNSET:
+                return value
+        return None
+
+    @strawberry_django.filter_field
+    def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
+        filters = self.get_filter()
+
+        if not filters:
+            return queryset, Q()
+
+        return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
+
+
 @strawberry.input(one_of=True, description='Lookup for Float fields. Only one of the lookup fields can be set.')
 class FloatLookup:
     filter_lookup: FilterLookup[float] | None = strawberry_django.filter_field()

+ 0 - 2
netbox/netbox/models/features.py

@@ -569,7 +569,6 @@ class SyncedDataMixin(models.Model):
             )
         else:
             AutoSyncRecord.objects.filter(
-                datafile=self.data_file,
                 object_type=object_type,
                 object_id=self.pk
             ).delete()
@@ -582,7 +581,6 @@ class SyncedDataMixin(models.Model):
         # Delete AutoSyncRecord
         object_type = ObjectType.objects.get_for_model(self)
         AutoSyncRecord.objects.filter(
-            datafile=self.data_file,
             object_type=object_type,
             object_id=self.pk
         ).delete()

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

@@ -1,3 +1,5 @@
+from functools import cache
+
 from django.utils.translation import gettext_lazy as _
 
 from netbox.registry import registry
@@ -409,60 +411,10 @@ ADMIN_MENU = Menu(
         MenuGroup(
             label=_('Authentication'),
             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(
@@ -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

+ 18 - 3
netbox/netbox/tables/tables.py

@@ -271,9 +271,14 @@ class NetBoxTable(BaseTable):
 
 
 class PrimaryModelTable(NetBoxTable):
+    owner_group = tables.Column(
+        accessor='owner__group',
+        linkify=True,
+        verbose_name=_('Owner Group'),
+    )
     owner = tables.Column(
         linkify=True,
-        verbose_name=_('Owner')
+        verbose_name=_('Owner'),
     )
     comments = columns.MarkdownColumn(
         verbose_name=_('Comments'),
@@ -281,9 +286,14 @@ class PrimaryModelTable(NetBoxTable):
 
 
 class OrganizationalModelTable(NetBoxTable):
+    owner_group = tables.Column(
+        accessor='owner__group',
+        linkify=True,
+        verbose_name=_('Owner Group'),
+    )
     owner = tables.Column(
         linkify=True,
-        verbose_name=_('Owner')
+        verbose_name=_('Owner'),
     )
     comments = columns.MarkdownColumn(
         verbose_name=_('Comments'),
@@ -291,9 +301,14 @@ class OrganizationalModelTable(NetBoxTable):
 
 
 class NestedGroupModelTable(NetBoxTable):
+    owner_group = tables.Column(
+        accessor='owner__group',
+        linkify=True,
+        verbose_name=_('Owner Group'),
+    )
     owner = tables.Column(
         linkify=True,
-        verbose_name=_('Owner')
+        verbose_name=_('Owner'),
     )
     name = columns.MPTTColumn(
         verbose_name=_('Name'),

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

@@ -1,5 +1,6 @@
 import re
 from collections import namedtuple
+import logging
 
 from django.conf import settings
 from django.contrib import messages
@@ -28,6 +29,8 @@ __all__ = (
     'SearchView',
 )
 
+logger = logging.getLogger(f'netbox.{__name__}')
+
 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.)
         new_release = None
         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:
                 release_version, release_url = latest_release
                 if release_version > version.parse(settings.RELEASE.version):

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/netbox.css


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/netbox.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 1 - 1
netbox/project-static/dist/rack_elevation.css

@@ -1 +1 @@
-svg{--nbx-rack-bg: var(--tblr-bg-surface-secondary);--nbx-rack-border: #000;--nbx-rack-slot-bg: #e9ecef;--nbx-rack-slot-border: #adb5bd;--nbx-rack-slot-hover-bg: #ced4da;--nbx-rack-link-color: #0d6efd;--nbx-rack-unit-color: #6c757d}svg[data-bs-theme=dark]{--nbx-rack-bg: rgb(27, 41, 58);--nbx-rack-border: #6c757d;--nbx-rack-slot-bg: #343a40;--nbx-rack-slot-border: #495057;--nbx-rack-slot-hover-bg: #212529;--nbx-rack-link-color: #9ec5fe;--nbx-rack-unit-color: #adb5bd}rect{box-sizing:border-box}text{text-anchor:middle;dominant-baseline:middle}svg{background-color:var(--nbx-rack-bg);font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Noto Sans,Liberation Sans,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-size:.875rem}svg .unit{margin:0;padding:5px 0;fill:var(--nbx-rack-unit-color)}svg .hidden{visibility:hidden}svg rect.shaded,svg image.shaded{opacity:25%}svg text.shaded{opacity:50%}svg .rack{fill:none;stroke-width:2px;stroke:var(--nbx-rack-border)}svg .slot{fill:var(--nbx-rack-slot-bg);stroke:var(--nbx-rack-slot-border)}svg .slot:hover{fill:var(--nbx-rack-slot-hover-bg)}svg .slot+.add-device{fill:var(--nbx-rack-link-color);opacity:0;pointer-events:none}svg .slot:hover+.add-device{opacity:1}svg .slot.occupied[class],svg .slot.occupied:hover[class]{fill:url(#occupied)}svg .slot.blocked[class],svg .slot.blocked:hover[class]{fill:url(#blocked)}svg .slot.blocked:hover+.add-device{opacity:0}svg .reservation[class]{fill:url(#reserved)}
+svg{--nbx-rack-bg: var(--tblr-bg-surface-secondary);--nbx-rack-border: #000;--nbx-rack-slot-bg: #e9ecef;--nbx-rack-slot-border: #adb5bd;--nbx-rack-slot-hover-bg: #ced4da;--nbx-rack-link-color: #0d6efd;--nbx-rack-unit-color: #6c757d}svg[data-bs-theme=dark]{--nbx-rack-bg: rgb(27, 41, 58);--nbx-rack-border: #6c757d;--nbx-rack-slot-bg: #343a40;--nbx-rack-slot-border: #495057;--nbx-rack-slot-hover-bg: #212529;--nbx-rack-link-color: rgb(158.2, 197, 254.2);--nbx-rack-unit-color: #adb5bd}rect{box-sizing:border-box}text{text-anchor:middle;dominant-baseline:middle}svg{background-color:var(--nbx-rack-bg);font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Noto Sans,Liberation Sans,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-size:.875rem}svg .unit{margin:0;padding:5px 0;fill:var(--nbx-rack-unit-color)}svg .hidden{visibility:hidden}svg rect.shaded,svg image.shaded{opacity:25%}svg text.shaded{opacity:50%}svg .rack{fill:none;stroke-width:2px;stroke:var(--nbx-rack-border)}svg .slot{fill:var(--nbx-rack-slot-bg);stroke:var(--nbx-rack-slot-border)}svg .slot:hover{fill:var(--nbx-rack-slot-hover-bg)}svg .slot+.add-device{fill:var(--nbx-rack-link-color);opacity:0;pointer-events:none}svg .slot:hover+.add-device{opacity:1}svg .slot.occupied[class],svg .slot.occupied:hover[class]{fill:url(#occupied)}svg .slot.blocked[class],svg .slot.blocked:hover[class]{fill:url(#blocked)}svg .slot.blocked:hover+.add-device{opacity:0}svg .reservation[class]{fill:url(#reserved)}

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

@@ -37,23 +37,23 @@
     "typeface-roboto-mono": "1.1.13"
   },
   "devDependencies": {
-    "@eslint/compat": "^2.0.0",
+    "@eslint/compat": "^2.0.1",
     "@eslint/eslintrc": "^3.3.3",
     "@eslint/js": "^9.39.2",
     "@types/bootstrap": "5.2.10",
     "@types/cookie": "^1.0.0",
     "@types/node": "^24.10.1",
-    "@typescript-eslint/eslint-plugin": "^8.48.1",
-    "@typescript-eslint/parser": "^8.48.1",
-    "esbuild": "^0.27.1",
-    "esbuild-sass-plugin": "^3.3.1",
+    "@typescript-eslint/eslint-plugin": "^8.53.1",
+    "@typescript-eslint/parser": "^8.53.1",
+    "esbuild": "^0.27.2",
+    "esbuild-sass-plugin": "^3.6.0",
     "eslint": "^9.39.2",
     "eslint-config-prettier": "^10.1.8",
     "eslint-import-resolver-typescript": "^4.4.4",
     "eslint-plugin-import": "^2.32.0",
-    "eslint-plugin-prettier": "^5.5.1",
-    "globals": "^16.5.0",
-    "prettier": "^3.7.4",
+    "eslint-plugin-prettier": "^5.5.5",
+    "globals": "^17.0.0",
+    "prettier": "^3.8.0",
     "typescript": "^5.9.3"
   },
   "resolutions": {

+ 40 - 0
netbox/project-static/src/forms/clearField.ts

@@ -0,0 +1,40 @@
+import TomSelect from 'tom-select';
+import { getElements } from '../util';
+
+/**
+ * Initialize clear-field dependencies.
+ * When a required field is cleared, dependent fields with data-requires-fields attribute will also be cleared.
+ */
+export function initClearField(): void {
+  // Find all fields with data-requires-fields attribute
+  for (const field of getElements<HTMLSelectElement>('[data-requires-fields]')) {
+    const requiredFieldsAttr = field.getAttribute('data-requires-fields');
+    if (!requiredFieldsAttr) continue;
+
+    // Parse the comma-separated list of required field names
+    const requiredFields = requiredFieldsAttr.split(',').map(name => name.trim());
+
+    // Set up listeners for each required field
+    for (const requiredFieldName of requiredFields) {
+      const requiredField = document.querySelector<HTMLSelectElement>(
+        `[name="${requiredFieldName}"]`,
+      );
+      if (!requiredField) continue;
+
+      // Listen for changes on the required field
+      requiredField.addEventListener('change', () => {
+        // If required field is cleared, also clear this dependent field
+        if (!requiredField.value || requiredField.value === '') {
+          // Check if this field uses TomSelect
+          const tomselect = (field as HTMLSelectElement & { tomselect?: TomSelect }).tomselect;
+          if (tomselect) {
+            tomselect.clear();
+          } else {
+            // Regular select field
+            field.value = '';
+          }
+        }
+      });
+    }
+  }
+}

+ 2 - 1
netbox/project-static/src/forms/index.ts

@@ -1,9 +1,10 @@
+import { initClearField } from './clearField';
 import { initFormElements } from './elements';
 import { initFilterModifiers } from './filterModifiers';
 import { initSpeedSelector } from './speedSelector';
 
 export function initForms(): void {
-  for (const func of [initFormElements, initSpeedSelector, initFilterModifiers]) {
+  for (const func of [initFormElements, initSpeedSelector, initFilterModifiers, initClearField]) {
     func();
   }
 }

+ 314 - 407
netbox/project-static/yarn.lock

@@ -24,142 +24,135 @@
   dependencies:
     tslib "^2.4.0"
 
-"@esbuild/aix-ppc64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz#116edcd62c639ed8ab551e57b38251bb28384de4"
-  integrity sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==
-
-"@esbuild/android-arm64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz#31c00d864c80f6de1900a11de8a506dbfbb27349"
-  integrity sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==
-
-"@esbuild/android-arm@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.1.tgz#d2b73ab0ba894923a1d1378fd4b15cc20985f436"
-  integrity sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==
-
-"@esbuild/android-x64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.1.tgz#d9f74d8278191317250cfe0c15a13f410540b122"
-  integrity sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==
-
-"@esbuild/darwin-arm64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz#baf6914b8c57ed9d41f9de54023aa3ff9b084680"
-  integrity sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==
-
-"@esbuild/darwin-x64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz#64e37400795f780a76c858a118ff19681a64b4e0"
-  integrity sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==
-
-"@esbuild/freebsd-arm64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz#6572f2f235933eee906e070dfaae54488ee60acd"
-  integrity sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==
-
-"@esbuild/freebsd-x64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz#83105dba9cf6ac4f44336799446d7f75c8c3a1e1"
-  integrity sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==
-
-"@esbuild/linux-arm64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz#035ff647d4498bdf16eb2d82801f73b366477dfa"
-  integrity sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==
-
-"@esbuild/linux-arm@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz#3516c74d2afbe305582dbb546d60f7978a8ece7f"
-  integrity sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==
-
-"@esbuild/linux-ia32@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz#788db5db8ecd3d75dd41c42de0fe8f1fd967a4a7"
-  integrity sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==
-
-"@esbuild/linux-loong64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz#8211f08b146916a6302ec2b8f87ec0cc4b62c49e"
-  integrity sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==
-
-"@esbuild/linux-mips64el@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz#cc58586ea83b3f171e727a624e7883a1c3eb4c04"
-  integrity sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==
-
-"@esbuild/linux-ppc64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz#632477bbd98175cf8e53a7c9952d17fb2d6d4115"
-  integrity sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==
-
-"@esbuild/linux-riscv64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz#35435a82435a8a750edf433b83ac0d10239ac3fe"
-  integrity sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==
-
-"@esbuild/linux-s390x@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz#172edd7086438edacd86c0e2ea25ac9dbb62aac5"
-  integrity sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==
-
-"@esbuild/linux-x64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz#09c771de9e2d8169d5969adf298ae21581f08c7f"
-  integrity sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==
-
-"@esbuild/netbsd-arm64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz#475ac0ce7edf109a358b1669f67759de4bcbb7c4"
-  integrity sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==
-
-"@esbuild/netbsd-x64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz#3c31603d592477dc43b63df1ae100000f7fb59d7"
-  integrity sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==
-
-"@esbuild/openbsd-arm64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz#482067c847665b10d66431e936d4bc5fa8025abf"
-  integrity sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==
-
-"@esbuild/openbsd-x64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz#687a188c2b184e5b671c5f74a6cd6247c0718c52"
-  integrity sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==
-
-"@esbuild/openharmony-arm64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz#9929ee7fa8c1db2f33ef4d86198018dac9c1744f"
-  integrity sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==
-
-"@esbuild/sunos-x64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz#94071a146f313e7394c6424af07b2b564f1f994d"
-  integrity sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==
-
-"@esbuild/win32-arm64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz#869fde72a3576fdf48824085d05493fceebe395d"
-  integrity sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==
-
-"@esbuild/win32-ia32@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz#31d7585893ed7b54483d0b8d87a4bfeba0ecfff5"
-  integrity sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==
-
-"@esbuild/win32-x64@0.27.1":
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz#5efe5a112938b1180e98c76685ff9185cfa4f16e"
-  integrity sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==
-
-"@eslint-community/eslint-utils@^4.7.0":
-  version "4.7.0"
-  resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz"
-  integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==
-  dependencies:
-    eslint-visitor-keys "^3.4.3"
+"@esbuild/aix-ppc64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c"
+  integrity sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==
+
+"@esbuild/android-arm64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz#61ea550962d8aa12a9b33194394e007657a6df57"
+  integrity sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==
+
+"@esbuild/android-arm@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz#554887821e009dd6d853f972fde6c5143f1de142"
+  integrity sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==
+
+"@esbuild/android-x64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz#a7ce9d0721825fc578f9292a76d9e53334480ba2"
+  integrity sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==
+
+"@esbuild/darwin-arm64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz#2cb7659bd5d109803c593cfc414450d5430c8256"
+  integrity sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==
+
+"@esbuild/darwin-x64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz#e741fa6b1abb0cd0364126ba34ca17fd5e7bf509"
+  integrity sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==
+
+"@esbuild/freebsd-arm64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz#2b64e7116865ca172d4ce034114c21f3c93e397c"
+  integrity sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==
+
+"@esbuild/freebsd-x64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz#e5252551e66f499e4934efb611812f3820e990bb"
+  integrity sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==
+
+"@esbuild/linux-arm64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz#dc4acf235531cd6984f5d6c3b13dbfb7ddb303cb"
+  integrity sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==
+
+"@esbuild/linux-arm@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz#56a900e39240d7d5d1d273bc053daa295c92e322"
+  integrity sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==
+
+"@esbuild/linux-ia32@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz#d4a36d473360f6870efcd19d52bbfff59a2ed1cc"
+  integrity sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==
+
+"@esbuild/linux-loong64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz#fcf0ab8c3eaaf45891d0195d4961cb18b579716a"
+  integrity sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==
+
+"@esbuild/linux-mips64el@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz#598b67d34048bb7ee1901cb12e2a0a434c381c10"
+  integrity sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==
+
+"@esbuild/linux-ppc64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz#3846c5df6b2016dab9bc95dde26c40f11e43b4c0"
+  integrity sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==
+
+"@esbuild/linux-riscv64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz#173d4475b37c8d2c3e1707e068c174bb3f53d07d"
+  integrity sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==
+
+"@esbuild/linux-s390x@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz#f7a4790105edcab8a5a31df26fbfac1aa3dacfab"
+  integrity sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==
+
+"@esbuild/linux-x64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz#2ecc1284b1904aeb41e54c9ddc7fcd349b18f650"
+  integrity sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==
+
+"@esbuild/netbsd-arm64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz#e2863c2cd1501845995cb11adf26f7fe4be527b0"
+  integrity sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==
+
+"@esbuild/netbsd-x64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz#93f7609e2885d1c0b5a1417885fba8d1fcc41272"
+  integrity sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==
+
+"@esbuild/openbsd-arm64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz#a1985604a203cdc325fd47542e106fafd698f02e"
+  integrity sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==
+
+"@esbuild/openbsd-x64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz#8209e46c42f1ffbe6e4ef77a32e1f47d404ad42a"
+  integrity sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==
+
+"@esbuild/openharmony-arm64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz#8fade4441893d9cc44cbd7dcf3776f508ab6fb2f"
+  integrity sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==
+
+"@esbuild/sunos-x64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz#980d4b9703a16f0f07016632424fc6d9a789dfc2"
+  integrity sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==
+
+"@esbuild/win32-arm64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz#1c09a3633c949ead3d808ba37276883e71f6111a"
+  integrity sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==
+
+"@esbuild/win32-ia32@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz#1b1e3a63ad4bef82200fef4e369e0fff7009eee5"
+  integrity sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==
+
+"@esbuild/win32-x64@0.27.2":
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b"
+  integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==
 
 "@eslint-community/eslint-utils@^4.8.0":
   version "4.9.0"
@@ -168,22 +161,24 @@
   dependencies:
     eslint-visitor-keys "^3.4.3"
 
-"@eslint-community/regexpp@^4.10.0":
-  version "4.12.1"
-  resolved "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz"
-  integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==
+"@eslint-community/eslint-utils@^4.9.1":
+  version "4.9.1"
+  resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595"
+  integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==
+  dependencies:
+    eslint-visitor-keys "^3.4.3"
 
-"@eslint-community/regexpp@^4.12.1":
+"@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.12.2":
   version "4.12.2"
   resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
   integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
 
-"@eslint/compat@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.0.tgz"
-  integrity sha512-T9AfE1G1uv4wwq94ozgTGio5EUQBqAVe1X9qsQtSNVEYW6j3hvtZVm8Smr4qL1qDPFg+lOB2cL5RxTRMzq4CTA==
+"@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==
   dependencies:
-    "@eslint/core" "^1.0.0"
+    "@eslint/core" "^1.0.1"
 
 "@eslint/config-array@^0.21.1":
   version "0.21.1"
@@ -208,10 +203,10 @@
   dependencies:
     "@types/json-schema" "^7.0.15"
 
-"@eslint/core@^1.0.0":
-  version "1.0.0"
-  resolved "https://registry.npmjs.org/@eslint/core/-/core-1.0.0.tgz"
-  integrity sha512-PRfWP+8FOldvbApr6xL7mNCw4cJcSTq4GA7tYbgq15mRb0kWKO/wEB2jr+uwjFH3sZvEZneZyCUGTxsv4Sahyw==
+"@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==
   dependencies:
     "@types/json-schema" "^7.0.15"
 
@@ -940,101 +935,100 @@
   dependencies:
     "@types/estree" "*"
 
-"@typescript-eslint/eslint-plugin@^8.48.1":
-  version "8.48.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz#c772d1dbdd97cfddf85f5a161a97783233643631"
-  integrity sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==
-  dependencies:
-    "@eslint-community/regexpp" "^4.10.0"
-    "@typescript-eslint/scope-manager" "8.48.1"
-    "@typescript-eslint/type-utils" "8.48.1"
-    "@typescript-eslint/utils" "8.48.1"
-    "@typescript-eslint/visitor-keys" "8.48.1"
-    graphemer "^1.4.0"
-    ignore "^7.0.0"
+"@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==
+  dependencies:
+    "@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"
+    ignore "^7.0.5"
     natural-compare "^1.4.0"
-    ts-api-utils "^2.1.0"
-
-"@typescript-eslint/parser@^8.48.1":
-  version "8.48.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.48.1.tgz#4e3c66d9ec20683ec142417fafeadab61c479c3f"
-  integrity sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==
-  dependencies:
-    "@typescript-eslint/scope-manager" "8.48.1"
-    "@typescript-eslint/types" "8.48.1"
-    "@typescript-eslint/typescript-estree" "8.48.1"
-    "@typescript-eslint/visitor-keys" "8.48.1"
-    debug "^4.3.4"
-
-"@typescript-eslint/project-service@8.48.1":
-  version "8.48.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.48.1.tgz#cfe1741613b9112d85ae766de9e09b27a7d3f2f1"
-  integrity sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==
-  dependencies:
-    "@typescript-eslint/tsconfig-utils" "^8.48.1"
-    "@typescript-eslint/types" "^8.48.1"
-    debug "^4.3.4"
-
-"@typescript-eslint/scope-manager@8.48.1":
-  version "8.48.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz#8bc70643e7cca57864b1ff95dd350fc27756bec0"
-  integrity sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==
-  dependencies:
-    "@typescript-eslint/types" "8.48.1"
-    "@typescript-eslint/visitor-keys" "8.48.1"
-
-"@typescript-eslint/tsconfig-utils@8.48.1", "@typescript-eslint/tsconfig-utils@^8.48.1":
-  version "8.48.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz#68139ce2d258f984e2b33a95389158f1212af646"
-  integrity sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==
-
-"@typescript-eslint/type-utils@8.48.1":
-  version "8.48.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz#955bd3ddd648450f0a627925ff12ade63fb7516d"
-  integrity sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==
-  dependencies:
-    "@typescript-eslint/types" "8.48.1"
-    "@typescript-eslint/typescript-estree" "8.48.1"
-    "@typescript-eslint/utils" "8.48.1"
-    debug "^4.3.4"
-    ts-api-utils "^2.1.0"
-
-"@typescript-eslint/types@8.48.1", "@typescript-eslint/types@^8.48.1":
-  version "8.48.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.48.1.tgz#a9ff808f5f798f28767d5c0b015a88fa7ce46bd7"
-  integrity sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==
-
-"@typescript-eslint/typescript-estree@8.48.1":
-  version "8.48.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz#0d0e31fc47c5796c6463ab50cde19e1718d465b1"
-  integrity sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==
-  dependencies:
-    "@typescript-eslint/project-service" "8.48.1"
-    "@typescript-eslint/tsconfig-utils" "8.48.1"
-    "@typescript-eslint/types" "8.48.1"
-    "@typescript-eslint/visitor-keys" "8.48.1"
-    debug "^4.3.4"
-    minimatch "^9.0.4"
-    semver "^7.6.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==
+  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"
+    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==
+  dependencies:
+    "@typescript-eslint/tsconfig-utils" "^8.53.1"
+    "@typescript-eslint/types" "^8.53.1"
+    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==
+  dependencies:
+    "@typescript-eslint/types" "8.53.1"
+    "@typescript-eslint/visitor-keys" "8.53.1"
+
+"@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/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==
+  dependencies:
+    "@typescript-eslint/types" "8.53.1"
+    "@typescript-eslint/typescript-estree" "8.53.1"
+    "@typescript-eslint/utils" "8.53.1"
+    debug "^4.4.3"
+    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/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==
+  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"
+    debug "^4.4.3"
+    minimatch "^9.0.5"
+    semver "^7.7.3"
     tinyglobby "^0.2.15"
-    ts-api-utils "^2.1.0"
+    ts-api-utils "^2.4.0"
 
-"@typescript-eslint/utils@8.48.1":
-  version "8.48.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.48.1.tgz#6cf7b99e0943b33a983ef687b9a86b65578b5c32"
-  integrity sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==
+"@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==
   dependencies:
-    "@eslint-community/eslint-utils" "^4.7.0"
-    "@typescript-eslint/scope-manager" "8.48.1"
-    "@typescript-eslint/types" "8.48.1"
-    "@typescript-eslint/typescript-estree" "8.48.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/visitor-keys@8.48.1":
-  version "8.48.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz#247d4fe6dcc044f45b7f1c15110bf95e5d73b334"
-  integrity sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==
+"@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==
   dependencies:
-    "@typescript-eslint/types" "8.48.1"
+    "@typescript-eslint/types" "8.53.1"
     eslint-visitor-keys "^4.2.1"
 
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
@@ -1161,14 +1155,6 @@ ansi-styles@^4.1.0:
   dependencies:
     color-convert "^2.0.1"
 
-anymatch@~3.1.2:
-  version "3.1.3"
-  resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz"
-  integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
-  dependencies:
-    normalize-path "^3.0.0"
-    picomatch "^2.0.4"
-
 argparse@^2.0.1:
   version "2.0.1"
   resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
@@ -1288,11 +1274,6 @@ balanced-match@^1.0.0:
   resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
   integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
 
-binary-extensions@^2.0.0:
-  version "2.3.0"
-  resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz"
-  integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==
-
 bootstrap@5.3.7:
   version "5.3.7"
   resolved "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz"
@@ -1318,7 +1299,7 @@ brace-expansion@^2.0.1:
   dependencies:
     balanced-match "^1.0.0"
 
-braces@^3.0.3, braces@~3.0.2:
+braces@^3.0.3:
   version "3.0.3"
   resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz"
   integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
@@ -1375,21 +1356,6 @@ chalk@^4.0.0:
     ansi-styles "^4.1.0"
     supports-color "^7.1.0"
 
-"chokidar@>=3.0.0 <4.0.0":
-  version "3.6.0"
-  resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz"
-  integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
-  dependencies:
-    anymatch "~3.1.2"
-    braces "~3.0.2"
-    glob-parent "~5.1.2"
-    is-binary-path "~2.1.0"
-    is-glob "~4.0.1"
-    normalize-path "~3.0.0"
-    readdirp "~3.6.0"
-  optionalDependencies:
-    fsevents "~2.3.2"
-
 chokidar@^4.0.0:
   version "4.0.1"
   resolved "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz"
@@ -1533,20 +1499,13 @@ debug@^3.2.7:
   dependencies:
     ms "^2.1.1"
 
-debug@^4.3.1, debug@^4.3.2, debug@^4.4.1:
+debug@^4.3.1, debug@^4.3.2, debug@^4.4.1, debug@^4.4.3:
   version "4.4.3"
   resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
   integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
   dependencies:
     ms "^2.1.3"
 
-debug@^4.3.4:
-  version "4.4.1"
-  resolved "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz"
-  integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==
-  dependencies:
-    ms "^2.1.3"
-
 decode-uri-component@^0.4.1:
   version "0.4.1"
   resolved "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz"
@@ -1805,46 +1764,45 @@ es-to-primitive@^1.3.0:
     is-date-object "^1.0.5"
     is-symbol "^1.0.4"
 
-esbuild-sass-plugin@^3.3.1:
-  version "3.3.1"
-  resolved "https://registry.npmjs.org/esbuild-sass-plugin/-/esbuild-sass-plugin-3.3.1.tgz"
-  integrity sha512-SnO1ls+d52n6j8gRRpjexXI8MsHEaumS0IdDHaYM29Y6gakzZYMls6i9ql9+AWMSQk/eryndmUpXEgT34QrX1A==
+esbuild-sass-plugin@^3.6.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/esbuild-sass-plugin/-/esbuild-sass-plugin-3.6.0.tgz#6e93d0aec87b6ab7bde2e459c5f1ab472088bd41"
+  integrity sha512-lzPJQSEXcnj5amBPPib5lBjsDNPzvdMnX+1Rf7eha9BIpLSM5Ad2pi+Rqg5CAlWMduCgLntS2hLAqG7v1fxWGw==
   dependencies:
-    resolve "^1.22.8"
-    safe-identifier "^0.4.2"
-    sass "^1.71.1"
+    resolve "^1.22.11"
+    sass "^1.97.2"
 
-esbuild@^0.27.1:
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.1.tgz#56bf43e6a4b4d2004642ec7c091b78de02b0831a"
-  integrity sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==
+esbuild@^0.27.2:
+  version "0.27.2"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.2.tgz#d83ed2154d5813a5367376bb2292a9296fc83717"
+  integrity sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==
   optionalDependencies:
-    "@esbuild/aix-ppc64" "0.27.1"
-    "@esbuild/android-arm" "0.27.1"
-    "@esbuild/android-arm64" "0.27.1"
-    "@esbuild/android-x64" "0.27.1"
-    "@esbuild/darwin-arm64" "0.27.1"
-    "@esbuild/darwin-x64" "0.27.1"
-    "@esbuild/freebsd-arm64" "0.27.1"
-    "@esbuild/freebsd-x64" "0.27.1"
-    "@esbuild/linux-arm" "0.27.1"
-    "@esbuild/linux-arm64" "0.27.1"
-    "@esbuild/linux-ia32" "0.27.1"
-    "@esbuild/linux-loong64" "0.27.1"
-    "@esbuild/linux-mips64el" "0.27.1"
-    "@esbuild/linux-ppc64" "0.27.1"
-    "@esbuild/linux-riscv64" "0.27.1"
-    "@esbuild/linux-s390x" "0.27.1"
-    "@esbuild/linux-x64" "0.27.1"
-    "@esbuild/netbsd-arm64" "0.27.1"
-    "@esbuild/netbsd-x64" "0.27.1"
-    "@esbuild/openbsd-arm64" "0.27.1"
-    "@esbuild/openbsd-x64" "0.27.1"
-    "@esbuild/openharmony-arm64" "0.27.1"
-    "@esbuild/sunos-x64" "0.27.1"
-    "@esbuild/win32-arm64" "0.27.1"
-    "@esbuild/win32-ia32" "0.27.1"
-    "@esbuild/win32-x64" "0.27.1"
+    "@esbuild/aix-ppc64" "0.27.2"
+    "@esbuild/android-arm" "0.27.2"
+    "@esbuild/android-arm64" "0.27.2"
+    "@esbuild/android-x64" "0.27.2"
+    "@esbuild/darwin-arm64" "0.27.2"
+    "@esbuild/darwin-x64" "0.27.2"
+    "@esbuild/freebsd-arm64" "0.27.2"
+    "@esbuild/freebsd-x64" "0.27.2"
+    "@esbuild/linux-arm" "0.27.2"
+    "@esbuild/linux-arm64" "0.27.2"
+    "@esbuild/linux-ia32" "0.27.2"
+    "@esbuild/linux-loong64" "0.27.2"
+    "@esbuild/linux-mips64el" "0.27.2"
+    "@esbuild/linux-ppc64" "0.27.2"
+    "@esbuild/linux-riscv64" "0.27.2"
+    "@esbuild/linux-s390x" "0.27.2"
+    "@esbuild/linux-x64" "0.27.2"
+    "@esbuild/netbsd-arm64" "0.27.2"
+    "@esbuild/netbsd-x64" "0.27.2"
+    "@esbuild/openbsd-arm64" "0.27.2"
+    "@esbuild/openbsd-x64" "0.27.2"
+    "@esbuild/openharmony-arm64" "0.27.2"
+    "@esbuild/sunos-x64" "0.27.2"
+    "@esbuild/win32-arm64" "0.27.2"
+    "@esbuild/win32-ia32" "0.27.2"
+    "@esbuild/win32-x64" "0.27.2"
 
 escape-string-regexp@^4.0.0:
   version "4.0.0"
@@ -1918,13 +1876,13 @@ eslint-plugin-import@^2.32.0:
     string.prototype.trimend "^1.0.9"
     tsconfig-paths "^3.15.0"
 
-eslint-plugin-prettier@^5.5.1:
-  version "5.5.4"
-  resolved "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz"
-  integrity sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==
+eslint-plugin-prettier@^5.5.5:
+  version "5.5.5"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz#9eae11593faa108859c26f9a9c367d619a0769c0"
+  integrity sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==
   dependencies:
-    prettier-linter-helpers "^1.0.0"
-    synckit "^0.11.7"
+    prettier-linter-helpers "^1.0.1"
+    synckit "^0.11.12"
 
 eslint-scope@^8.4.0:
   version "8.4.0"
@@ -2110,11 +2068,6 @@ framer-motion@^12:
     motion-utils "^12.23.6"
     tslib "^2.4.0"
 
-fsevents@~2.3.2:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
-  integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
-
 function-bind@^1.1.2:
   version "1.1.2"
   resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
@@ -2226,22 +2179,15 @@ glob-parent@^6.0.2:
   dependencies:
     is-glob "^4.0.3"
 
-glob-parent@~5.1.2:
-  version "5.1.2"
-  resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
-  integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
-  dependencies:
-    is-glob "^4.0.1"
-
 globals@^14.0.0:
   version "14.0.0"
   resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
   integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
 
-globals@^16.5.0:
-  version "16.5.0"
-  resolved "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz"
-  integrity sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==
+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==
 
 globalthis@^1.0.3, globalthis@^1.0.4:
   version "1.0.4"
@@ -2270,11 +2216,6 @@ gopd@^1.2.0:
   resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz"
   integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
 
-graphemer@^1.4.0:
-  version "1.4.0"
-  resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz"
-  integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
-
 graphiql-explorer@^0.9.0:
   version "0.9.0"
   resolved "https://registry.npmjs.org/graphiql-explorer/-/graphiql-explorer-0.9.0.tgz"
@@ -2372,16 +2313,11 @@ ignore@^5.2.0:
   resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz"
   integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
 
-ignore@^7.0.0:
+ignore@^7.0.5:
   version "7.0.5"
-  resolved "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9"
   integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==
 
-immutable@^4.0.0:
-  version "4.3.7"
-  resolved "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz"
-  integrity sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==
-
 immutable@^5.0.2:
   version "5.0.3"
   resolved "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz"
@@ -2460,13 +2396,6 @@ is-bigint@^1.1.0:
   dependencies:
     has-bigints "^1.0.2"
 
-is-binary-path@~2.1.0:
-  version "2.1.0"
-  resolved "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz"
-  integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
-  dependencies:
-    binary-extensions "^2.0.0"
-
 is-boolean-object@^1.1.0:
   version "1.1.2"
   resolved "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz"
@@ -2562,7 +2491,7 @@ is-generator-function@^1.0.10:
     has-tostringtag "^1.0.2"
     safe-regex-test "^1.1.0"
 
-is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
+is-glob@^4.0.0, is-glob@^4.0.3:
   version "4.0.3"
   resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
   integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
@@ -2857,7 +2786,7 @@ minimatch@^3.1.2:
   dependencies:
     brace-expansion "^1.1.7"
 
-minimatch@^9.0.4:
+minimatch@^9.0.5:
   version "9.0.5"
   resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz"
   integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
@@ -2901,11 +2830,6 @@ node-addon-api@^7.0.0:
   resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz"
   integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==
 
-normalize-path@^3.0.0, normalize-path@~3.0.0:
-  version "3.0.0"
-  resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
-  integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
-
 nullthrows@^1.0.0:
   version "1.1.1"
   resolved "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz"
@@ -3034,7 +2958,7 @@ path-parse@^1.0.7:
   resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz"
   integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
 
-picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
+picomatch@^2.3.1:
   version "2.3.1"
   resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
   integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
@@ -3054,17 +2978,17 @@ prelude-ls@^1.2.1:
   resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
   integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
 
-prettier-linter-helpers@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz"
-  integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==
+prettier-linter-helpers@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz#6a31f88a4bad6c7adda253de12ba4edaea80ebcd"
+  integrity sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==
   dependencies:
     fast-diff "^1.1.2"
 
-prettier@^3.7.4:
-  version "3.7.4"
-  resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.4.tgz#d2f8335d4b1cec47e1c8098645411b0c9dff9c0f"
-  integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==
+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==
 
 punycode.js@^2.3.1:
   version "2.3.1"
@@ -3137,13 +3061,6 @@ readdirp@^4.0.1:
   resolved "https://registry.npmjs.org/readdirp/-/readdirp-4.0.1.tgz"
   integrity sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==
 
-readdirp@~3.6.0:
-  version "3.6.0"
-  resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz"
-  integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
-  dependencies:
-    picomatch "^2.2.1"
-
 reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9:
   version "1.0.10"
   resolved "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz"
@@ -3190,7 +3107,16 @@ resolve-pkg-maps@^1.0.0:
   resolved "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz"
   integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==
 
-resolve@^1.22.4, resolve@^1.22.8:
+resolve@^1.22.11:
+  version "1.22.11"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262"
+  integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==
+  dependencies:
+    is-core-module "^2.16.1"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
+
+resolve@^1.22.4:
   version "1.22.8"
   resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz"
   integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==
@@ -3220,11 +3146,6 @@ safe-array-concat@^1.1.3:
     has-symbols "^1.1.0"
     isarray "^2.0.5"
 
-safe-identifier@^0.4.2:
-  version "0.4.2"
-  resolved "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz"
-  integrity sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==
-
 safe-push-apply@^1.0.0:
   version "1.0.0"
   resolved "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz"
@@ -3251,7 +3172,7 @@ safe-regex-test@^1.1.0:
     es-errors "^1.3.0"
     is-regex "^1.2.1"
 
-sass@1.97.2:
+sass@1.97.2, sass@^1.97.2:
   version "1.97.2"
   resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.2.tgz#e515a319092fd2c3b015228e3094b40198bff0da"
   integrity sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==
@@ -3262,15 +3183,6 @@ sass@1.97.2:
   optionalDependencies:
     "@parcel/watcher" "^2.4.1"
 
-sass@^1.71.1:
-  version "1.77.8"
-  resolved "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz"
-  integrity sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==
-  dependencies:
-    chokidar ">=3.0.0 <4.0.0"
-    immutable "^4.0.0"
-    source-map-js ">=0.6.2 <2.0.0"
-
 scheduler@^0.23.2:
   version "0.23.2"
   resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz"
@@ -3288,12 +3200,7 @@ semver@^6.3.1:
   resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
   integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
 
-semver@^7.6.0:
-  version "7.7.2"
-  resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz"
-  integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
-
-semver@^7.7.1:
+semver@^7.7.1, semver@^7.7.3:
   version "7.7.3"
   resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946"
   integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==
@@ -3495,10 +3402,10 @@ supports-preserve-symlinks-flag@^1.0.0:
   resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
   integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
 
-synckit@^0.11.7:
-  version "0.11.11"
-  resolved "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz"
-  integrity sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==
+synckit@^0.11.12:
+  version "0.11.12"
+  resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.12.tgz#abe74124264fbc00a48011b0d98bdc1cffb64a7b"
+  integrity sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==
   dependencies:
     "@pkgr/core" "^0.2.9"
 
@@ -3540,10 +3447,10 @@ tom-select@2.4.3:
     "@orchidjs/sifter" "^1.1.0"
     "@orchidjs/unicode-variants" "^1.1.2"
 
-ts-api-utils@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz"
-  integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==
+ts-api-utils@^2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.4.0.tgz#2690579f96d2790253bdcf1ca35d569ad78f9ad8"
+  integrity sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==
 
 tsconfig-paths@^3.15.0:
   version "3.15.0"

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.5.0"
+version: "4.5.1"
 edition: "Community"
-published: "2026-01-06"
+published: "2026-01-20"

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

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

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

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

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

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

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

@@ -370,33 +370,6 @@
           </table>
         </div>
       {% 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 'dcim/inc/panels/inventory_items.html' %}
       {% plugin_right_page object %}
@@ -441,6 +414,13 @@
       {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
     </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 %}
     <div class="row mb-3">
       <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="row">
-          <h2 class="col-9 offset-3">{% trans "Owner" %}</h2>
+          <h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
         </div>
+        {% render_field vc_form.owner_group %}
         {% render_field vc_form.owner %}
       </div>
 

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

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

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

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

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

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

+ 2 - 0
netbox/templates/ipam/aggregate/prefixes.html

@@ -3,6 +3,8 @@
 
 {% block extra_controls %}
   {% include 'ipam/inc/toggle_available.html' %}
+  {% include 'ipam/inc/max_depth.html' %}
+  {% include 'ipam/inc/max_length.html' %}  
   {% if perms.ipam.add_prefix and first_available_prefix %}
     <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}" class="btn btn-primary">
       <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Prefix" %}

+ 20 - 0
netbox/templates/ipam/inc/max_depth.html

@@ -0,0 +1,20 @@
+{% load i18n %}
+{% load helpers %}
+
+<div class="dropdown">
+    <button class="btn btn-outline-secondary dropdown-toggle" type="button" id="max_depth" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
+        {% trans "Max Depth" %}{% if "depth__lte" in request.GET %}: {{ request.GET.depth__lte }}{% endif %}
+    </button>
+    <ul class="dropdown-menu" aria-labelledby="max_depth">
+        {% if request.GET.depth__lte %}
+            <li>
+                <a class="dropdown-item" href="{{ request.path }}{% querystring request depth__lte=None page=1 %}">{% trans "Clear" %}</a>
+            </li>
+        {% endif %}
+        {% for i in 16|as_range %}
+            <li><a class="dropdown-item" href="{{ request.path }}{% querystring request depth__lte=i page=1 %}">
+                {{ i }} {% if request.GET.depth__lte == i %}<i class="mdi mdi-check-bold"></i>{% endif %}
+            </a></li>
+        {% endfor %}
+    </ul>
+</div>

+ 20 - 0
netbox/templates/ipam/inc/max_length.html

@@ -0,0 +1,20 @@
+{% load i18n %}
+{% load helpers %}
+
+<div class="dropdown">
+    <button class="btn btn-outline-secondary dropdown-toggle" type="button" id="max_length" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
+        {% trans "Max Length" %}{% if "mask_length__lte" in request.GET %}: {{ request.GET.mask_length__lte }}{% endif %}
+    </button>
+    <ul class="dropdown-menu" aria-labelledby="max_length">
+        {% if request.GET.mask_length__lte %}
+            <li>
+                <a class="dropdown-item" href="{{ request.path }}{% querystring request mask_length__lte=None page=1 %}">{% trans "Clear" %}</a>
+            </li>
+        {% endif %}
+        {% for i in "4,8,12,16,20,24,28,32,40,48,56,64"|split %}
+            <li><a class="dropdown-item" href="{{ request.path }}{% querystring request mask_length__lte=i page=1 %}">
+                {{ i }} {% if request.GET.mask_length__lte == i %}<i class="mdi mdi-check-bold"></i>{% endif %}
+            </a></li>
+        {% endfor %}
+    </ul>
+</div>

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

@@ -3,8 +3,10 @@
 
 {% block extra_controls %}
   {% include 'ipam/inc/toggle_available.html' %}
+  {% include 'ipam/inc/max_depth.html' %}
+  {% include 'ipam/inc/max_length.html' %}  
   {% 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" %}
     </a>
   {% endif %}

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor