Browse Source

Merge branch 'feature' into 19724-graphql

Arthur 3 months ago
parent
commit
b7b7b00885
100 changed files with 3625 additions and 2959 deletions
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 5 5
      base_requirements.txt
  4. 27 25
      contrib/openapi.json
  5. 10 0
      docs/configuration/graphql-api.md
  6. 0 11
      docs/configuration/security.md
  7. 5 2
      docs/integrations/rest-api.md
  8. 1 1
      docs/plugins/development/filtersets.md
  9. 39 0
      docs/release-notes/version-4.4.md
  10. 12 0
      netbox/circuits/apps.py
  11. 6 6
      netbox/core/api/schema.py
  12. 2 0
      netbox/core/filtersets.py
  13. 5 5
      netbox/core/graphql/mixins.py
  14. 48 0
      netbox/core/migrations/0019_configrevision_active.py
  15. 16 1
      netbox/core/models/config.py
  16. 9 1
      netbox/core/models/object_types.py
  17. 1 0
      netbox/dcim/filtersets.py
  18. 5 3
      netbox/dcim/graphql/gfk_mixins.py
  19. 2 8
      netbox/dcim/models/device_component_templates.py
  20. 10 26
      netbox/dcim/models/device_components.py
  21. 33 0
      netbox/dcim/models/mixins.py
  22. 1 1
      netbox/dcim/tables/devices.py
  23. 28 1
      netbox/dcim/tests/test_views.py
  24. 5 4
      netbox/extras/graphql/mixins.py
  25. 32 1
      netbox/extras/lookups.py
  26. 1 1
      netbox/extras/querysets.py
  27. 4 2
      netbox/extras/scripts.py
  28. 4 16
      netbox/ipam/filtersets.py
  29. 2 2
      netbox/ipam/graphql/filters.py
  30. 1 1
      netbox/ipam/graphql/types.py
  31. 12 2
      netbox/ipam/models/vlans.py
  32. 2 1
      netbox/ipam/tables/vlans.py
  33. 4 0
      netbox/ipam/tests/test_filtersets.py
  34. 66 0
      netbox/ipam/tests/test_lookups.py
  35. 1 1
      netbox/netbox/api/fields.py
  36. 13 7
      netbox/netbox/api/pagination.py
  37. 7 2
      netbox/netbox/config/__init__.py
  38. 0 3
      netbox/netbox/configuration_example.py
  39. 0 2
      netbox/netbox/configuration_testing.py
  40. 33 4
      netbox/netbox/graphql/filter_lookups.py
  41. 45 4
      netbox/netbox/graphql/schema.py
  42. 2 1
      netbox/netbox/graphql/types.py
  43. 16 0
      netbox/netbox/graphql/utils.py
  44. 26 15
      netbox/netbox/models/__init__.py
  45. 10 3
      netbox/netbox/models/features.py
  46. 39 0
      netbox/netbox/monkey.py
  47. 13 1
      netbox/netbox/settings.py
  48. 8 4
      netbox/netbox/urls.py
  49. 2 1
      netbox/netbox/views/generic/object_views.py
  50. 0 0
      netbox/project-static/dist/netbox.css
  51. 0 0
      netbox/project-static/dist/netbox.js
  52. 0 0
      netbox/project-static/dist/netbox.js.map
  53. 1 1
      netbox/project-static/package.json
  54. 1 1
      netbox/project-static/src/racks.ts
  55. 159 159
      netbox/project-static/yarn.lock
  56. 2 2
      netbox/release.yaml
  57. 1 1
      netbox/templates/account/base.html
  58. 4 4
      netbox/templates/base/base.html
  59. 4 3
      netbox/templates/extras/htmx/script_result.html
  60. 1 1
      netbox/templates/ipam/vlangroup.html
  61. 1 8
      netbox/templates/users/token.html
  62. 10 4
      netbox/tenancy/forms/bulk_import.py
  63. 5 0
      netbox/tenancy/forms/model_forms.py
  64. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  65. 176 170
      netbox/translations/cs/LC_MESSAGES/django.po
  66. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  67. 176 170
      netbox/translations/da/LC_MESSAGES/django.po
  68. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  69. 176 170
      netbox/translations/de/LC_MESSAGES/django.po
  70. 175 175
      netbox/translations/en/LC_MESSAGES/django.po
  71. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  72. 176 170
      netbox/translations/es/LC_MESSAGES/django.po
  73. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  74. 176 170
      netbox/translations/fr/LC_MESSAGES/django.po
  75. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  76. 176 170
      netbox/translations/it/LC_MESSAGES/django.po
  77. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  78. 176 170
      netbox/translations/ja/LC_MESSAGES/django.po
  79. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  80. 176 170
      netbox/translations/nl/LC_MESSAGES/django.po
  81. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  82. 176 170
      netbox/translations/pl/LC_MESSAGES/django.po
  83. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  84. 176 169
      netbox/translations/pt/LC_MESSAGES/django.po
  85. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  86. 176 170
      netbox/translations/ru/LC_MESSAGES/django.po
  87. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  88. 176 170
      netbox/translations/tr/LC_MESSAGES/django.po
  89. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  90. 176 170
      netbox/translations/uk/LC_MESSAGES/django.po
  91. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  92. 176 170
      netbox/translations/zh/LC_MESSAGES/django.po
  93. 9 0
      netbox/users/api/serializers_/tokens.py
  94. 10 7
      netbox/users/forms/model_forms.py
  95. 1 8
      netbox/users/tables.py
  96. 26 3
      netbox/users/tests/test_api.py
  97. 54 15
      netbox/utilities/data.py
  98. 55 0
      netbox/utilities/templatetags/builtins/tags.py
  99. 2 3
      netbox/utilities/tests/test_api.py
  100. 25 9
      netbox/utilities/tests/test_data.py

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

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

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

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

+ 5 - 5
base_requirements.txt

@@ -12,9 +12,7 @@ django-cors-headers
 
 
 # Runtime UI tool for debugging Django
 # Runtime UI tool for debugging Django
 # https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
 # https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
-# django-debug-toolbar v6.0.0 raises "Attribute Error at /: 'function' object has no attribute 'set'" 
-# see https://github.com/netbox-community/netbox/issues/19974
-django-debug-toolbar==5.2.0
+django-debug-toolbar
 
 
 # Library for writing reusable URL query filters
 # Library for writing reusable URL query filters
 # https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
 # https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
@@ -71,7 +69,8 @@ django-timezone-field
 
 
 # A REST API framework for Django projects
 # A REST API framework for Django projects
 # https://www.django-rest-framework.org/community/release-notes/
 # https://www.django-rest-framework.org/community/release-notes/
-djangorestframework
+# TODO: Re-evaluate the monkey-patch of get_unique_validators() before upgrading
+djangorestframework==3.16.1
 
 
 # Sane and flexible OpenAPI 3 schema generation for Django REST framework.
 # Sane and flexible OpenAPI 3 schema generation for Django REST framework.
 # https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst
 # https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst
@@ -167,7 +166,8 @@ strawberry-graphql-django
 svgwrite
 svgwrite
 
 
 # Tabular dataset library (for table-based exports)
 # Tabular dataset library (for table-based exports)
-# https://github.com/jazzband/tablib/blob/master/HISTORY.md
+# Current: https://github.com/jazzband/tablib/releases
+# Previous: https://github.com/jazzband/tablib/blob/master/HISTORY.md
 tablib
 tablib
 
 
 # Timezone data (required by django-timezone-field on Python 3.9+)
 # Timezone data (required by django-timezone-field on Python 3.9+)

+ 27 - 25
contrib/openapi.json

@@ -2,7 +2,7 @@
     "openapi": "3.0.3",
     "openapi": "3.0.3",
     "info": {
     "info": {
         "title": "NetBox REST API",
         "title": "NetBox REST API",
-        "version": "4.4.2",
+        "version": "4.4.4",
         "license": {
         "license": {
             "name": "Apache v2 License"
             "name": "Apache v2 License"
         }
         }
@@ -19678,14 +19678,14 @@
                         "in": "query",
                         "in": "query",
                         "name": "object_type",
                         "name": "object_type",
                         "schema": {
                         "schema": {
-                            "type": "integer"
+                            "type": "string"
                         }
                         }
                     },
                     },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "object_type__n",
                         "name": "object_type__n",
                         "schema": {
                         "schema": {
-                            "type": "integer"
+                            "type": "string"
                         }
                         }
                     },
                     },
                     {
                     {
@@ -20507,14 +20507,14 @@
                         "in": "query",
                         "in": "query",
                         "name": "related_object_type",
                         "name": "related_object_type",
                         "schema": {
                         "schema": {
-                            "type": "integer"
+                            "type": "string"
                         }
                         }
                     },
                     },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "related_object_type__n",
                         "name": "related_object_type__n",
                         "schema": {
                         "schema": {
-                            "type": "integer"
+                            "type": "string"
                         }
                         }
                     },
                     },
                     {
                     {
@@ -60413,14 +60413,14 @@
                         "in": "query",
                         "in": "query",
                         "name": "assigned_object_type",
                         "name": "assigned_object_type",
                         "schema": {
                         "schema": {
-                            "type": "integer"
+                            "type": "string"
                         }
                         }
                     },
                     },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "assigned_object_type__n",
                         "name": "assigned_object_type__n",
                         "schema": {
                         "schema": {
-                            "type": "integer"
+                            "type": "string"
                         }
                         }
                     },
                     },
                     {
                     {
@@ -135594,14 +135594,14 @@
                         "in": "query",
                         "in": "query",
                         "name": "assigned_object_type",
                         "name": "assigned_object_type",
                         "schema": {
                         "schema": {
-                            "type": "integer"
+                            "type": "string"
                         }
                         }
                     },
                     },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "assigned_object_type__n",
                         "name": "assigned_object_type__n",
                         "schema": {
                         "schema": {
-                            "type": "integer"
+                            "type": "string"
                         }
                         }
                     },
                     },
                     {
                     {
@@ -147446,14 +147446,14 @@
                         "in": "query",
                         "in": "query",
                         "name": "parent_object_type",
                         "name": "parent_object_type",
                         "schema": {
                         "schema": {
-                            "type": "integer"
+                            "type": "string"
                         }
                         }
                     },
                     },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "parent_object_type__n",
                         "name": "parent_object_type__n",
                         "schema": {
                         "schema": {
-                            "type": "integer"
+                            "type": "string"
                         }
                         }
                     },
                     },
                     {
                     {
@@ -214738,24 +214738,26 @@
             "IntegerRange": {
             "IntegerRange": {
                 "type": "array",
                 "type": "array",
                 "items": {
                 "items": {
-                    "type": "array",
-                    "items": {
-                        "type": "integer"
-                    },
-                    "minItems": 2,
-                    "maxItems": 2
-                }
+                    "type": "integer"
+                },
+                "minItems": 2,
+                "maxItems": 2,
+                "example": [
+                    10,
+                    20
+                ]
             },
             },
             "IntegerRangeRequest": {
             "IntegerRangeRequest": {
                 "type": "array",
                 "type": "array",
                 "items": {
                 "items": {
-                    "type": "array",
-                    "items": {
-                        "type": "integer"
-                    },
-                    "minItems": 2,
-                    "maxItems": 2
-                }
+                    "type": "integer"
+                },
+                "minItems": 2,
+                "maxItems": 2,
+                "example": [
+                    10,
+                    20
+                ]
             },
             },
             "Interface": {
             "Interface": {
                 "type": "object",
                 "type": "object",

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

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

+ 0 - 11
docs/configuration/security.md

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

+ 5 - 2
docs/integrations/rest-api.md

@@ -80,7 +80,7 @@ Likewise, the site, rack, and device objects are located under the "DCIM" applic
 
 
 The full hierarchy of available endpoints can be viewed by navigating to the API root in a web browser.
 The full hierarchy of available endpoints can be viewed by navigating to the API root in a web browser.
 
 
-Each model generally has two views associated with it: a list view and a detail view. The list view is used to retrieve a list of multiple objects and to create new objects. The detail view is used to retrieve, update, or delete an single existing object. All objects are referenced by their numeric primary key (`id`).
+Each model generally has two views associated with it: a list view and a detail view. The list view is used to retrieve a list of multiple objects and to create new objects. The detail view is used to retrieve, update, or delete a single existing object. All objects are referenced by their numeric primary key (`id`).
 
 
 * `/api/dcim/devices/` - List existing devices or create a new device
 * `/api/dcim/devices/` - List existing devices or create a new device
 * `/api/dcim/devices/123/` - Retrieve, update, or delete the device with ID 123
 * `/api/dcim/devices/123/` - Retrieve, update, or delete the device with ID 123
@@ -655,6 +655,9 @@ The NetBox REST API primarily employs token-based authentication. For convenienc
 
 
 A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically populate a randomly-generated token value.
 A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically populate a randomly-generated token value.
 
 
+!!! note "Tokens cannot be retrieved once created"
+    Once a token has been created, its plaintext value cannot be retrieved. For this reason, you must take care to securely record the token locally immediately upon its creation. If a token plaintext is lost, it cannot be recovered: A new token must be created.
+
 By default, all users can create and manage their own REST API tokens under the user control panel in the UI or via the REST API. This ability can be disabled by overriding the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter.
 By default, all users can create and manage their own REST API tokens under the user control panel in the UI or via the REST API. This ability can be disabled by overriding the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter.
 
 
 Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
 Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
@@ -663,7 +666,7 @@ Additionally, a token can be set to expire at a specific time. This can be usefu
 
 
 Beginning with NetBox v4.5, two versions of API token are supported, denoted as v1 and v2. Users are strongly encouraged to create only v2 tokens and to discontinue the use of v1 tokens. Support for v1 tokens will be removed in a future NetBox release.
 Beginning with NetBox v4.5, two versions of API token are supported, denoted as v1 and v2. Users are strongly encouraged to create only v2 tokens and to discontinue the use of v1 tokens. Support for v1 tokens will be removed in a future NetBox release.
 
 
-v2 API tokens offer much stronger security. The token plaintext given at creation time is hashed together with a configured [cryptographic pepper](../configuration/required-parameters.md#api_token_peppers) to generate a unique checksum. This checksum is irreversible; the token plaintext is never stored on the server and thus cannot be retrieved.
+v2 API tokens offer much stronger security. The token plaintext given at creation time is hashed together with a configured [cryptographic pepper](../configuration/required-parameters.md#api_token_peppers) to generate a unique checksum. This checksum is irreversible; the token plaintext is never stored on the server and thus cannot be retrieved even with database-level access.
 
 
 #### Restricting Write Operations
 #### Restricting Write Operations
 
 

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

@@ -1,6 +1,6 @@
 # Filters & Filter Sets
 # Filters & Filter Sets
 
 
-Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI or REST API. (Note that the GraphQL API uses a separate filter class.) NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets.
+Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI or REST API. (Note that the GraphQL API uses a separate filter class.) NetBox employs the [django-filter](https://django-filter.readthedocs.io/en/stable/) library to define filter sets.
 
 
 ## FilterSet Classes
 ## FilterSet Classes
 
 

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

@@ -1,5 +1,44 @@
 # NetBox v4.4
 # NetBox v4.4
 
 
+## v4.4.4 (2025-10-15)
+
+### Bug Fixes
+
+* [#20554](https://github.com/netbox-community/netbox/issues/20554) - Fix generic relation filters to accept `<app>.<model>` format matching POST requests
+* [#20574](https://github.com/netbox-community/netbox/issues/20574) - Fix excessive storage initialization overhead when listing scripts with remote backends
+* [#20584](https://github.com/netbox-community/netbox/issues/20584) - Enforce PoE mode requirement on interface templates when PoE type is set
+* [#20585](https://github.com/netbox-community/netbox/issues/20585) - Fix API schema generation crash for models with single-field UniqueConstraints
+* [#20587](https://github.com/netbox-community/netbox/issues/20587) - Fix upgrade.sh failure when removing stale content types
+
+---
+
+## v4.4.3 (2025-10-14)
+
+### Enhancements
+
+* [#20426](https://github.com/netbox-community/netbox/issues/20426) - Add a copy-to-clipboard button for custom script output
+* [#20516](https://github.com/netbox-community/netbox/issues/20516) - Improve rendering of VLAN ID ranges in VLAN group tables
+
+### Bug Fixes
+
+* [#19302](https://github.com/netbox-community/netbox/issues/19302) - Fix uniqueness validation in REST API for nullable fields
+* [#19615](https://github.com/netbox-community/netbox/issues/19615) - Fix support for static file parameters in templates when external storage is in use
+* [#19818](https://github.com/netbox-community/netbox/issues/19818) - Hide primary IP assignment fields when creating a new virtual machine in the UI
+* [#19825](https://github.com/netbox-community/netbox/issues/19825) - Prevent cache for config revisions from being erroneously overwritten when debugging is enabled
+* [#20140](https://github.com/netbox-community/netbox/issues/20140) - Changing a site's region or group should update any associated circuit terminations
+* [#20156](https://github.com/netbox-community/netbox/issues/20156) - Fix display of rack elevation labels
+* [#20290](https://github.com/netbox-community/netbox/issues/20290) - Fix migration error when upgrading to NetBox v4.4 from releases earlier than v4.3
+* [#20471](https://github.com/netbox-community/netbox/issues/20471) - Saving an unmodified VLAN group should not generate a change record
+* [#20475](https://github.com/netbox-community/netbox/issues/20475) - Collapse singleton VLAN IDs in VLAN group display
+* [#20494](https://github.com/netbox-community/netbox/issues/20494) - Correct OpenAPI schema definition for `IntegerRangeSerializer`
+* [#20496](https://github.com/netbox-community/netbox/issues/20496) - REST API should always honor `MAX_PAGE_SIZE` value
+* [#20497](https://github.com/netbox-community/netbox/issues/20497) - Fix filtering of VLAN groups by VLAN ID range in GraphQL API
+* [#20507](https://github.com/netbox-community/netbox/issues/20507) - Fix support for fetching ASN contacts via GraphQL API
+* [#20523](https://github.com/netbox-community/netbox/issues/20523) - Hide password change form for users authenticated via SSO
+* [#20542](https://github.com/netbox-community/netbox/issues/20542) - Fix the creation of MAC addresses using the "quick add" form
+
+---
+
 ## v4.4.2 (2025-09-30)
 ## v4.4.2 (2025-09-30)
 
 
 ### Enhancements
 ### Enhancements

+ 12 - 0
netbox/circuits/apps.py

@@ -1,5 +1,7 @@
 from django.apps import AppConfig
 from django.apps import AppConfig
 
 
+from netbox import denormalized
+
 
 
 class CircuitsConfig(AppConfig):
 class CircuitsConfig(AppConfig):
     name = "circuits"
     name = "circuits"
@@ -8,6 +10,16 @@ class CircuitsConfig(AppConfig):
     def ready(self):
     def ready(self):
         from netbox.models.features import register_models
         from netbox.models.features import register_models
         from . import signals, search  # noqa: F401
         from . import signals, search  # noqa: F401
+        from .models import CircuitTermination
 
 
         # Register models
         # Register models
         register_models(*self.get_models())
         register_models(*self.get_models())
+
+        denormalized.register(CircuitTermination, '_site', {
+            '_region': 'region',
+            '_site_group': 'group',
+        })
+
+        denormalized.register(CircuitTermination, '_location', {
+            '_site': 'site',
+        })

+ 6 - 6
netbox/core/api/schema.py

@@ -282,18 +282,18 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
 
 
 class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension):
 class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension):
     target_class = 'netbox.api.fields.IntegerRangeSerializer'
     target_class = 'netbox.api.fields.IntegerRangeSerializer'
+    match_subclasses = True
 
 
     def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType:
     def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType:
+        # One range = two integers; many=True will wrap this in an outer array
         return {
         return {
             'type': 'array',
             'type': 'array',
             'items': {
             'items': {
-                'type': 'array',
-                'items': {
-                    'type': 'integer',
-                },
-                'minItems': 2,
-                'maxItems': 2,
+                'type': 'integer',
             },
             },
+            'minItems': 2,
+            'maxItems': 2,
+            'example': [10, 20],
         }
         }
 
 
 
 

+ 2 - 0
netbox/core/filtersets.py

@@ -80,6 +80,7 @@ class JobFilterSet(BaseFilterSet):
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
+    object_type = ContentTypeFilter()
     created = django_filters.DateTimeFilter()
     created = django_filters.DateTimeFilter()
     created__before = django_filters.DateTimeFilter(
     created__before = django_filters.DateTimeFilter(
         field_name='created',
         field_name='created',
@@ -169,6 +170,7 @@ class ObjectChangeFilterSet(BaseFilterSet):
     changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
     changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ContentType.objects.all()
         queryset=ContentType.objects.all()
     )
     )
+    related_object_type = ContentTypeFilter()
     user_id = django_filters.ModelMultipleChoiceFilter(
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
         queryset=User.objects.all(),
         label=_('User (ID)'),
         label=_('User (ID)'),

+ 5 - 5
netbox/core/graphql/mixins.py

@@ -3,12 +3,12 @@ from typing import Annotated, List, TYPE_CHECKING
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from strawberry.types import Info
 
 
 from core.models import ObjectChange
 from core.models import ObjectChange
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
-    from core.graphql.types import DataFileType, DataSourceType
-    from netbox.core.graphql.types import ObjectChangeType
+    from core.graphql.types import DataFileType, DataSourceType, ObjectChangeType
 
 
 __all__ = (
 __all__ = (
     'ChangelogMixin',
     'ChangelogMixin',
@@ -20,7 +20,7 @@ __all__ = (
 class ChangelogMixin:
 class ChangelogMixin:
 
 
     @strawberry_django.field
     @strawberry_django.field
-    def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]:  # noqa: F821
+    def changelog(self, info: Info) -> List[Annotated['ObjectChangeType', strawberry.lazy('.types')]]:  # noqa: F821
         content_type = ContentType.objects.get_for_model(self)
         content_type = ContentType.objects.get_for_model(self)
         object_changes = ObjectChange.objects.filter(
         object_changes = ObjectChange.objects.filter(
             changed_object_type=content_type,
             changed_object_type=content_type,
@@ -31,5 +31,5 @@ class ChangelogMixin:
 
 
 @strawberry.type
 @strawberry.type
 class SyncedDataMixin:
 class SyncedDataMixin:
-    data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
-    data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
+    data_source: Annotated['DataSourceType', strawberry.lazy('core.graphql.types')] | None
+    data_file: Annotated['DataFileType', strawberry.lazy('core.graphql.types')] | None

+ 48 - 0
netbox/core/migrations/0019_configrevision_active.py

@@ -0,0 +1,48 @@
+# Generated by Django 5.2.5 on 2025-09-09 16:48
+
+from django.db import migrations, models
+
+
+def get_active(apps, schema_editor):
+    from django.core.cache import cache
+    ConfigRevision = apps.get_model('core', 'ConfigRevision')
+    version = None
+    revision = None
+
+    # Try and get the latest version from cache
+    try:
+        version = cache.get('config_version')
+    except Exception:
+        pass
+
+    # If there is a version in cache, attempt to set revision to the current version from cache
+    # If the version in cache does not exist or there is no version, try the lastest revision in the database
+    if not version or (version and not (revision := ConfigRevision.objects.filter(pk=version).first())):
+        revision = ConfigRevision.objects.order_by('-created').first()
+
+    # If there is a revision set, set the active revision
+    if revision:
+        revision.active = True
+        revision.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0018_concrete_objecttype'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='configrevision',
+            name='active',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.RunPython(code=get_active, reverse_code=migrations.RunPython.noop),
+        migrations.AddConstraint(
+            model_name='configrevision',
+            constraint=models.UniqueConstraint(
+                condition=models.Q(('active', True)), fields=('active',), name='unique_active_config_revision'
+            ),
+        ),
+    ]

+ 16 - 1
netbox/core/models/config.py

@@ -14,6 +14,9 @@ class ConfigRevision(models.Model):
     """
     """
     An atomic revision of NetBox's configuration.
     An atomic revision of NetBox's configuration.
     """
     """
+    active = models.BooleanField(
+        default=False
+    )
     created = models.DateTimeField(
     created = models.DateTimeField(
         verbose_name=_('created'),
         verbose_name=_('created'),
         auto_now_add=True
         auto_now_add=True
@@ -35,6 +38,13 @@ class ConfigRevision(models.Model):
         ordering = ['-created']
         ordering = ['-created']
         verbose_name = _('config revision')
         verbose_name = _('config revision')
         verbose_name_plural = _('config revisions')
         verbose_name_plural = _('config revisions')
+        constraints = [
+            models.UniqueConstraint(
+                fields=('active',),
+                condition=models.Q(active=True),
+                name='unique_active_config_revision',
+            )
+        ]
 
 
     def __str__(self):
     def __str__(self):
         if not self.pk:
         if not self.pk:
@@ -59,8 +69,13 @@ class ConfigRevision(models.Model):
         """
         """
         cache.set('config', self.data, None)
         cache.set('config', self.data, None)
         cache.set('config_version', self.pk, None)
         cache.set('config_version', self.pk, None)
+
+        # Set all instances of ConfigRevision to false and set this instance to true
+        ConfigRevision.objects.all().update(active=False)
+        ConfigRevision.objects.filter(pk=self.pk).update(active=True)
+
     activate.alters_data = True
     activate.alters_data = True
 
 
     @property
     @property
     def is_active(self):
     def is_active(self):
-        return cache.get('config_version') == self.pk
+        return self.active

+ 9 - 1
netbox/core/models/object_types.py

@@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.indexes import GinIndex
 from django.contrib.postgres.indexes import GinIndex
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
-from django.db import models
+from django.db import connection, models
 from django.db.models import Q
 from django.db.models import Q
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
@@ -66,6 +66,14 @@ class ObjectTypeManager(models.Manager):
         """
         """
         from netbox.models.features import get_model_features, model_is_public
         from netbox.models.features import get_model_features, model_is_public
 
 
+        # 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 inspect.isclass(model):
         if not inspect.isclass(model):
             model = model.__class__
             model = model.__class__
         opts = self._get_opts(model, for_concrete_model)
         opts = self._get_opts(model, for_concrete_model)

+ 1 - 0
netbox/dcim/filtersets.py

@@ -1764,6 +1764,7 @@ class PowerOutletFilterSet(
 
 
 class MACAddressFilterSet(NetBoxModelFilterSet):
 class MACAddressFilterSet(NetBoxModelFilterSet):
     mac_address = MultiValueMACAddressFilter()
     mac_address = MultiValueMACAddressFilter()
+    assigned_object_type = ContentTypeFilter()
     device = MultiValueCharFilter(
     device = MultiValueCharFilter(
         method='filter_device',
         method='filter_device',
         field_name='name',
         field_name='name',

+ 5 - 3
netbox/dcim/graphql/gfk_mixins.py

@@ -1,3 +1,5 @@
+from strawberry.types import Info
+
 from circuits.graphql.types import CircuitTerminationType, ProviderNetworkType
 from circuits.graphql.types import CircuitTerminationType, ProviderNetworkType
 from circuits.models import CircuitTermination, ProviderNetwork
 from circuits.models import CircuitTermination, ProviderNetwork
 from dcim.graphql.types import (
 from dcim.graphql.types import (
@@ -49,7 +51,7 @@ class InventoryItemTemplateComponentType:
         )
         )
 
 
     @classmethod
     @classmethod
-    def resolve_type(cls, instance, info):
+    def resolve_type(cls, instance, info: Info):
         if type(instance) is ConsolePortTemplate:
         if type(instance) is ConsolePortTemplate:
             return ConsolePortTemplateType
             return ConsolePortTemplateType
         if type(instance) is ConsoleServerPortTemplate:
         if type(instance) is ConsoleServerPortTemplate:
@@ -79,7 +81,7 @@ class InventoryItemComponentType:
         )
         )
 
 
     @classmethod
     @classmethod
-    def resolve_type(cls, instance, info):
+    def resolve_type(cls, instance, info: Info):
         if type(instance) is ConsolePort:
         if type(instance) is ConsolePort:
             return ConsolePortType
             return ConsolePortType
         if type(instance) is ConsoleServerPort:
         if type(instance) is ConsoleServerPort:
@@ -112,7 +114,7 @@ class ConnectedEndpointType:
         )
         )
 
 
     @classmethod
     @classmethod
-    def resolve_type(cls, instance, info):
+    def resolve_type(cls, instance, info: Info):
         if type(instance) is CircuitTermination:
         if type(instance) is CircuitTermination:
             return CircuitTerminationType
             return CircuitTerminationType
         if type(instance) is ConsolePortType:
         if type(instance) is ConsolePortType:

+ 2 - 8
netbox/dcim/models/device_component_templates.py

@@ -7,6 +7,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
+from dcim.models.mixins import InterfaceValidationMixin
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.mptt import TreeManager
 from utilities.mptt import TreeManager
@@ -405,7 +406,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
         }
         }
 
 
 
 
-class InterfaceTemplate(ModularComponentTemplateModel):
+class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel):
     """
     """
     A template for a physical data interface on a new Device.
     A template for a physical data interface on a new Device.
     """
     """
@@ -469,8 +470,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
         super().clean()
         super().clean()
 
 
         if self.bridge:
         if self.bridge:
-            if self.pk and self.bridge_id == self.pk:
-                raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
             if self.device_type and self.device_type != self.bridge.device_type:
             if self.device_type and self.device_type != self.bridge.device_type:
                 raise ValidationError({
                 raise ValidationError({
                     'bridge': _(
                     'bridge': _(
@@ -484,11 +483,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
                     ).format(bridge=self.bridge)
                     ).format(bridge=self.bridge)
                 })
                 })
 
 
-        if self.rf_role and self.type not in WIRELESS_IFACE_TYPES:
-            raise ValidationError({
-                'rf_role': "Wireless role may be set only on wireless interfaces."
-            })
-
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),

+ 10 - 26
netbox/dcim/models/device_components.py

@@ -11,6 +11,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import WWNField
 from dcim.fields import WWNField
+from dcim.models.mixins import InterfaceValidationMixin
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
 from netbox.models import OrganizationalModel, NetBoxModel
 from netbox.models import OrganizationalModel, NetBoxModel
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
@@ -676,7 +677,14 @@ class BaseInterface(models.Model):
             return self.primary_mac_address.mac_address
             return self.primary_mac_address.mac_address
 
 
 
 
-class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin):
+class Interface(
+    InterfaceValidationMixin,
+    ModularComponentModel,
+    BaseInterface,
+    CabledObjectModel,
+    PathEndpoint,
+    TrackingModelMixin,
+):
     """
     """
     A network interface within a Device. A physical Interface can connect to exactly one other Interface.
     A network interface within a Device. A physical Interface can connect to exactly one other Interface.
     """
     """
@@ -893,10 +901,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
 
 
         # Bridge validation
         # Bridge validation
 
 
-        # An interface cannot be bridged to itself
-        if self.pk and self.bridge_id == self.pk:
-            raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
-
         # A bridged interface belongs to the same device or virtual chassis
         # A bridged interface belongs to the same device or virtual chassis
         if self.bridge and self.bridge.device != self.device:
         if self.bridge and self.bridge.device != self.device:
             if self.device.virtual_chassis is None:
             if self.device.virtual_chassis is None:
@@ -942,29 +946,9 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
                     )
                     )
                 })
                 })
 
 
-        # PoE validation
-
-        # Only physical interfaces may have a PoE mode/type assigned
-        if self.poe_mode and self.is_virtual:
-            raise ValidationError({
-                'poe_mode': _("Virtual interfaces cannot have a PoE mode.")
-            })
-        if self.poe_type and self.is_virtual:
-            raise ValidationError({
-                'poe_type': _("Virtual interfaces cannot have a PoE type.")
-            })
-
-        # An interface with a PoE type set must also specify a mode
-        if self.poe_type and not self.poe_mode:
-            raise ValidationError({
-                'poe_type': _("Must specify PoE mode when designating a PoE type.")
-            })
-
         # Wireless validation
         # Wireless validation
 
 
-        # RF role & channel may only be set for wireless interfaces
-        if self.rf_role and not self.is_wireless:
-            raise ValidationError({'rf_role': _("Wireless role may be set only on wireless interfaces.")})
+        # RF channel may only be set for wireless interfaces
         if self.rf_channel and not self.is_wireless:
         if self.rf_channel and not self.is_wireless:
             raise ValidationError({'rf_channel': _("Channel may be set only on wireless interfaces.")})
             raise ValidationError({'rf_channel': _("Channel may be set only on wireless interfaces.")})
 
 

+ 33 - 0
netbox/dcim/models/mixins.py

@@ -4,8 +4,11 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
+from dcim.constants import VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
+
 __all__ = (
 __all__ = (
     'CachedScopeMixin',
     'CachedScopeMixin',
+    'InterfaceValidationMixin',
     'RenderConfigMixin',
     'RenderConfigMixin',
 )
 )
 
 
@@ -116,3 +119,33 @@ class CachedScopeMixin(models.Model):
                 self._site = self.scope.site
                 self._site = self.scope.site
                 self._location = self.scope
                 self._location = self.scope
     cache_related_objects.alters_data = True
     cache_related_objects.alters_data = True
+
+
+class InterfaceValidationMixin:
+
+    def clean(self):
+        super().clean()
+
+        # An interface cannot be bridged to itself
+        if self.pk and self.bridge_id == self.pk:
+            raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
+
+        # Only physical interfaces may have a PoE mode/type assigned
+        if self.poe_mode and self.type in VIRTUAL_IFACE_TYPES:
+            raise ValidationError({
+                'poe_mode': _("Virtual interfaces cannot have a PoE mode.")
+            })
+        if self.poe_type and self.type in VIRTUAL_IFACE_TYPES:
+            raise ValidationError({
+                'poe_type': _("Virtual interfaces cannot have a PoE type.")
+            })
+
+        # An interface with a PoE type set must also specify a mode
+        if self.poe_type and not self.poe_mode:
+            raise ValidationError({
+                'poe_type': _("Must specify PoE mode when designating a PoE type.")
+            })
+
+        # RF role may be set only for wireless interfaces
+        if self.rf_role and self.type not in WIRELESS_IFACE_TYPES:
+            raise ValidationError({'rf_role': _("Wireless role may be set only on wireless interfaces.")})

+ 1 - 1
netbox/dcim/tables/devices.py

@@ -196,7 +196,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         verbose_name=_('Type')
         verbose_name=_('Type')
     )
     )
     u_height = columns.TemplateColumn(
     u_height = columns.TemplateColumn(
-        accessor=tables.A('device_type.u_height'),
+        accessor=tables.A('device_type__u_height'),
         verbose_name=_('U Height'),
         verbose_name=_('U Height'),
         template_code='{{ value|floatformat }}'
         template_code='{{ value|floatformat }}'
     )
     )

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

@@ -7,13 +7,14 @@ from django.test import override_settings, tag
 from django.urls import reverse
 from django.urls import reverse
 from netaddr import EUI
 from netaddr import EUI
 
 
+from core.models import ObjectType
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
 from ipam.models import ASN, RIR, VLAN, VRF
 from ipam.models import ASN, RIR, VLAN, VRF
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from users.models import User
+from users.models import ObjectPermission, User
 from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
 from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
 from wireless.models import WirelessLAN
 from wireless.models import WirelessLAN
 
 
@@ -3728,3 +3729,29 @@ class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'description': 'New description',
             'description': 'New description',
         }
         }
+
+    @tag('regression')  # Issue #20542
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_create_macaddress_via_quickadd(self):
+        """
+        Test creating a MAC address via quick-add modal (e.g., from Interface form).
+        Regression test for issue #20542 where form prefix was missing in POST handler.
+        """
+        obj_perm = ObjectPermission(name='Test permission', actions=['add'])
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+
+        # Simulate quick-add form submission with 'quickadd-' prefix
+        formatted_data = post_data(self.form_data)
+        quickadd_data = {f'quickadd-{k}': v for k, v in formatted_data.items()}
+        quickadd_data['_quickadd'] = 'True'
+
+        initial_count = self._get_queryset().count()
+        url = f"{self._get_url('add')}?_quickadd=True&target=id_primary_mac_address"
+        response = self.client.post(url, data=quickadd_data)
+
+        # Should successfully create the MAC address and return the quick_add_created template
+        self.assertHttpStatus(response, 200)
+        self.assertIn(b'quick-add-object', response.content)
+        self.assertEqual(initial_count + 1, self._get_queryset().count())

+ 5 - 4
netbox/extras/graphql/mixins.py

@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Annotated, List
 
 
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
+from strawberry.types import Info
 
 
 __all__ = (
 __all__ = (
     'ConfigContextMixin',
     'ConfigContextMixin',
@@ -37,7 +38,7 @@ class CustomFieldsMixin:
 class ImageAttachmentsMixin:
 class ImageAttachmentsMixin:
 
 
     @strawberry_django.field
     @strawberry_django.field
-    def image_attachments(self, info) -> List[Annotated["ImageAttachmentType", strawberry.lazy('.types')]]:
+    def image_attachments(self, info: Info) -> List[Annotated['ImageAttachmentType', strawberry.lazy('.types')]]:
         return self.images.restrict(info.context.request.user, 'view')
         return self.images.restrict(info.context.request.user, 'view')
 
 
 
 
@@ -45,17 +46,17 @@ class ImageAttachmentsMixin:
 class JournalEntriesMixin:
 class JournalEntriesMixin:
 
 
     @strawberry_django.field
     @strawberry_django.field
-    def journal_entries(self, info) -> List[Annotated["JournalEntryType", strawberry.lazy('.types')]]:
+    def journal_entries(self, info: Info) -> List[Annotated['JournalEntryType', strawberry.lazy('.types')]]:
         return self.journal_entries.all()
         return self.journal_entries.all()
 
 
 
 
 @strawberry.type
 @strawberry.type
 class TagsMixin:
 class TagsMixin:
 
 
-    tags: List[Annotated["TagType", strawberry.lazy('.types')]]
+    tags: List[Annotated['TagType', strawberry.lazy('.types')]]
 
 
 
 
 @strawberry.type
 @strawberry.type
 class ContactsMixin:
 class ContactsMixin:
 
 
-    contacts: List[Annotated["ContactAssignmentType", strawberry.lazy('tenancy.graphql.types')]]
+    contacts: List[Annotated['ContactAssignmentType', strawberry.lazy('tenancy.graphql.types')]]

+ 32 - 1
netbox/extras/lookups.py

@@ -1,9 +1,39 @@
+from django.contrib.postgres.fields import ArrayField
+from django.contrib.postgres.fields.ranges import RangeField
 from django.db.models import CharField, JSONField, Lookup
 from django.db.models import CharField, JSONField, Lookup
 from django.db.models.fields.json import KeyTextTransform
 from django.db.models.fields.json import KeyTextTransform
 
 
 from .fields import CachedValueField
 from .fields import CachedValueField
 
 
 
 
+class RangeContains(Lookup):
+    """
+    Filter ArrayField(RangeField) columns where ANY element-range contains the scalar RHS.
+
+    Usage (ORM):
+        Model.objects.filter(<range_array_field>__range_contains=<scalar>)
+
+    Works with int4range[], int8range[], daterange[], tstzrange[], etc.
+    """
+
+    lookup_name = 'range_contains'
+
+    def as_sql(self, compiler, connection):
+        # Compile LHS (the array-of-ranges column/expression) and RHS (scalar)
+        lhs, lhs_params = self.process_lhs(compiler, connection)
+        rhs, rhs_params = self.process_rhs(compiler, connection)
+
+        # Guard: only allow ArrayField whose base_field is a PostgreSQL RangeField
+        field = getattr(self.lhs, 'output_field', None)
+        if not (isinstance(field, ArrayField) and isinstance(field.base_field, RangeField)):
+            raise TypeError('range_contains is only valid for ArrayField(RangeField) columns')
+
+        # Range-contains-element using EXISTS + UNNEST keeps the range on the LHS: r @> value
+        sql = f"EXISTS (SELECT 1 FROM unnest({lhs}) AS r WHERE r @> {rhs})"
+        params = lhs_params + rhs_params
+        return sql, params
+
+
 class Empty(Lookup):
 class Empty(Lookup):
     """
     """
     Filter on whether a string is empty.
     Filter on whether a string is empty.
@@ -25,7 +55,7 @@ class JSONEmpty(Lookup):
 
 
     A key is considered empty if it is "", null, or does not exist.
     A key is considered empty if it is "", null, or does not exist.
     """
     """
-    lookup_name = "empty"
+    lookup_name = 'empty'
 
 
     def as_sql(self, compiler, connection):
     def as_sql(self, compiler, connection):
         # self.lhs.lhs is the parent expression (could be a JSONField or another KeyTransform)
         # self.lhs.lhs is the parent expression (could be a JSONField or another KeyTransform)
@@ -69,6 +99,7 @@ class NetContainsOrEquals(Lookup):
         return 'CAST(%s AS INET) >>= %s' % (lhs, rhs), params
         return 'CAST(%s AS INET) >>= %s' % (lhs, rhs), params
 
 
 
 
+ArrayField.register_lookup(RangeContains)
 CharField.register_lookup(Empty)
 CharField.register_lookup(Empty)
 JSONField.register_lookup(JSONEmpty)
 JSONField.register_lookup(JSONEmpty)
 CachedValueField.register_lookup(NetHost)
 CachedValueField.register_lookup(NetHost)

+ 1 - 1
netbox/extras/querysets.py

@@ -90,7 +90,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
                 ConfigContext.objects.filter(
                 ConfigContext.objects.filter(
                     self._get_config_context_filters()
                     self._get_config_context_filters()
                 ).annotate(
                 ).annotate(
-                    _data=EmptyGroupByJSONBAgg('data', ordering=['weight', 'name'])
+                    _data=EmptyGroupByJSONBAgg('data', order_by=['weight', 'name'])
                 ).values("_data").order_by()
                 ).values("_data").order_by()
             )
             )
         )
         )

+ 4 - 2
netbox/extras/scripts.py

@@ -326,6 +326,9 @@ class BaseScript:
         # Declare the placeholder for the current request
         # Declare the placeholder for the current request
         self.request = None
         self.request = None
 
 
+        # Initiate the storage backend (local, S3, etc) as a class attr
+        self.storage = storages.create_storage(storages.backends["scripts"])
+
         # Compile test methods and initialize results skeleton
         # Compile test methods and initialize results skeleton
         for method in dir(self):
         for method in dir(self):
             if method.startswith('test_') and callable(getattr(self, method)):
             if method.startswith('test_') and callable(getattr(self, method)):
@@ -391,8 +394,7 @@ class BaseScript:
         return inspect.getfile(self.__class__)
         return inspect.getfile(self.__class__)
 
 
     def findsource(self, object):
     def findsource(self, object):
-        storage = storages.create_storage(storages.backends["scripts"])
-        with storage.open(os.path.basename(self.filename), 'r') as f:
+        with self.storage.open(os.path.basename(self.filename), 'r') as f:
             data = f.read()
             data = f.read()
 
 
         # Break the source code into lines
         # Break the source code into lines

+ 4 - 16
netbox/ipam/filtersets.py

@@ -595,6 +595,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFil
         to_field_name='rd',
         to_field_name='rd',
         label=_('VRF (RD)'),
         label=_('VRF (RD)'),
     )
     )
+    assigned_object_type = ContentTypeFilter()
     device = MultiValueCharFilter(
     device = MultiValueCharFilter(
         method='filter_device',
         method='filter_device',
         field_name='name',
         field_name='name',
@@ -908,7 +909,8 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
         method='filter_scope'
         method='filter_scope'
     )
     )
     contains_vid = django_filters.NumberFilter(
     contains_vid = django_filters.NumberFilter(
-        method='filter_contains_vid'
+        field_name='vid_ranges',
+        lookup_expr='range_contains',
     )
     )
 
 
     class Meta:
     class Meta:
@@ -931,21 +933,6 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
             scope_id=value
             scope_id=value
         )
         )
 
 
-    def filter_contains_vid(self, queryset, name, value):
-        """
-        Return all VLANGroups which contain the given VLAN ID.
-        """
-        table_name = VLANGroup._meta.db_table
-        # TODO: See if this can be optimized without compromising queryset integrity
-        # Expand VLAN ID ranges to query by integer
-        groups = VLANGroup.objects.raw(
-            f'SELECT id FROM {table_name}, unnest(vid_ranges) vid_range WHERE %s <@ vid_range',
-            params=(value,)
-        )
-        return queryset.filter(
-            pk__in=[g.id for g in groups]
-        )
-
 
 
 class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
@@ -1166,6 +1153,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
 
 
 
 
 class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
 class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
+    parent_object_type = ContentTypeFilter()
     device = MultiValueCharFilter(
     device = MultiValueCharFilter(
         method='filter_device',
         method='filter_device',
         field_name='name',
         field_name='name',

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

@@ -19,7 +19,7 @@ from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
-    from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup
+    from netbox.graphql.filter_lookups import IntegerLookup, IntegerRangeArrayLookup
     from circuits.graphql.filters import ProviderFilter
     from circuits.graphql.filters import ProviderFilter
     from core.graphql.filters import ContentTypeFilter
     from core.graphql.filters import ContentTypeFilter
     from dcim.graphql.filters import SiteFilter
     from dcim.graphql.filters import SiteFilter
@@ -340,7 +340,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
 
 
 @strawberry_django.filter_type(models.VLANGroup, lookups=True)
 @strawberry_django.filter_type(models.VLANGroup, lookups=True)
 class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilterMixin):
 class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilterMixin):
-    vid_ranges: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+    vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
 
 

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

@@ -74,7 +74,7 @@ class BaseIPAddressFamilyType:
     filters=ASNFilter,
     filters=ASNFilter,
     pagination=True
     pagination=True
 )
 )
-class ASNType(NetBoxObjectType):
+class ASNType(NetBoxObjectType, ContactsMixin):
     asn: BigInt
     asn: BigInt
     rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None
     rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None

+ 12 - 2
netbox/ipam/models/vlans.py

@@ -10,9 +10,9 @@ from django.utils.translation import gettext_lazy as _
 from dcim.models import Interface, Site, SiteGroup
 from dcim.models import Interface, Site, SiteGroup
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
-from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
+from ipam.querysets import VLANGroupQuerySet, VLANQuerySet
 from netbox.models import OrganizationalModel, PrimaryModel, NetBoxModel
 from netbox.models import OrganizationalModel, PrimaryModel, NetBoxModel
-from utilities.data import check_ranges_overlap, ranges_to_string
+from utilities.data import check_ranges_overlap, ranges_to_string, ranges_to_string_list
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
 
 
 __all__ = (
 __all__ = (
@@ -164,8 +164,18 @@ class VLANGroup(OrganizationalModel):
         """
         """
         return VLAN.objects.filter(group=self).order_by('vid')
         return VLAN.objects.filter(group=self).order_by('vid')
 
 
+    @property
+    def vid_ranges_items(self):
+        """
+        Property that converts VID ranges to a list of string representations.
+        """
+        return ranges_to_string_list(self.vid_ranges)
+
     @property
     @property
     def vid_ranges_list(self):
     def vid_ranges_list(self):
+        """
+        Property that converts VID ranges into a string representation.
+        """
         return ranges_to_string(self.vid_ranges)
         return ranges_to_string(self.vid_ranges)
 
 
 
 

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

@@ -41,7 +41,8 @@ class VLANGroupTable(TenancyColumnsMixin, NetBoxTable):
         linkify=True,
         linkify=True,
         orderable=False
         orderable=False
     )
     )
-    vid_ranges_list = tables.Column(
+    vid_ranges_list = columns.ArrayColumn(
+        accessor='vid_ranges_items',
         verbose_name=_('VID Ranges'),
         verbose_name=_('VID Ranges'),
         orderable=False
         orderable=False
     )
     )

+ 4 - 0
netbox/ipam/tests/test_filtersets.py

@@ -1723,6 +1723,10 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'contains_vid': 1}
         params = {'contains_vid': 1}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+        params = {'contains_vid': 12}  # 11 is NOT in [1,11)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'contains_vid': 4095}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
 
 
     def test_region(self):
     def test_region(self):
         params = {'region': Region.objects.first().pk}
         params = {'region': Region.objects.first().pk}

+ 66 - 0
netbox/ipam/tests/test_lookups.py

@@ -0,0 +1,66 @@
+from django.test import TestCase
+from django.db.backends.postgresql.psycopg_any import NumericRange
+from ipam.models import VLANGroup
+
+
+class VLANGroupRangeContainsLookupTests(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        # Two ranges: [1,11) and [20,31)
+        cls.g1 = VLANGroup.objects.create(
+            name='VlanGroup-A',
+            slug='VlanGroup-A',
+            vid_ranges=[NumericRange(1, 11), NumericRange(20, 31)],
+        )
+        # One range: [100,201)
+        cls.g2 = VLANGroup.objects.create(
+            name='VlanGroup-B',
+            slug='VlanGroup-B',
+            vid_ranges=[NumericRange(100, 201)],
+        )
+        cls.g_empty = VLANGroup.objects.create(
+            name='VlanGroup-empty',
+            slug='VlanGroup-empty',
+            vid_ranges=[],
+        )
+
+    def test_contains_value_in_first_range(self):
+        """
+        Tests whether a specific value is contained within the first range in a queried
+        set of VLANGroup objects.
+        """
+        names = list(
+            VLANGroup.objects.filter(vid_ranges__range_contains=10).values_list('name', flat=True).order_by('name')
+        )
+        self.assertEqual(names, ['VlanGroup-A'])
+
+    def test_contains_value_in_second_range(self):
+        """
+        Tests if a value exists in the second range of VLANGroup objects and
+        validates the result against the expected list of names.
+        """
+        names = list(
+            VLANGroup.objects.filter(vid_ranges__range_contains=25).values_list('name', flat=True).order_by('name')
+        )
+        self.assertEqual(names, ['VlanGroup-A'])
+
+    def test_upper_bound_is_exclusive(self):
+        """
+        Tests if the upper bound of the range is exclusive in the filter method.
+        """
+        # 11 is NOT in [1,11)
+        self.assertFalse(VLANGroup.objects.filter(vid_ranges__range_contains=11).exists())
+
+    def test_no_match_far_outside(self):
+        """
+        Tests that no VLANGroup contains a VID within a specified range far outside
+        common VID bounds and returns `False`.
+        """
+        self.assertFalse(VLANGroup.objects.filter(vid_ranges__range_contains=4095).exists())
+
+    def test_empty_array_never_matches(self):
+        """
+        Tests the behavior of VLANGroup objects when an empty array is used to match a
+        specific condition.
+        """
+        self.assertFalse(VLANGroup.objects.filter(pk=self.g_empty.pk, vid_ranges__range_contains=1).exists())

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

@@ -169,7 +169,7 @@ class IntegerRangeSerializer(serializers.Serializer):
         if type(data[0]) is not int or type(data[1]) is not int:
         if type(data[0]) is not int or type(data[1]) is not int:
             raise ValidationError(_("Range boundaries must be defined as integers."))
             raise ValidationError(_("Range boundaries must be defined as integers."))
 
 
-        return NumericRange(data[0], data[1], bounds='[]')
+        return NumericRange(data[0], data[1] + 1, bounds='[)')
 
 
     def to_representation(self, instance):
     def to_representation(self, instance):
         return instance.lower, instance.upper - 1
         return instance.lower, instance.upper - 1

+ 13 - 7
netbox/netbox/api/pagination.py

@@ -44,22 +44,28 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
             return list(queryset[self.offset:])
             return list(queryset[self.offset:])
 
 
     def get_limit(self, request):
     def get_limit(self, request):
+        max_limit = self.default_limit
+        MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
+        if MAX_PAGE_SIZE:
+            max_limit = min(max_limit, MAX_PAGE_SIZE)
+
         if self.limit_query_param:
         if self.limit_query_param:
-            MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
-            if MAX_PAGE_SIZE:
-                MAX_PAGE_SIZE = max(MAX_PAGE_SIZE, self.default_limit)
             try:
             try:
                 limit = int(request.query_params[self.limit_query_param])
                 limit = int(request.query_params[self.limit_query_param])
                 if limit < 0:
                 if limit < 0:
                     raise ValueError()
                     raise ValueError()
-                # Enforce maximum page size, if defined
+
                 if MAX_PAGE_SIZE:
                 if MAX_PAGE_SIZE:
-                    return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE)
-                return limit
+                    if limit == 0:
+                        max_limit = MAX_PAGE_SIZE
+                    else:
+                        max_limit = min(MAX_PAGE_SIZE, limit)
+                else:
+                    max_limit = limit
             except (KeyError, ValueError):
             except (KeyError, ValueError):
                 pass
                 pass
 
 
-        return self.default_limit
+        return max_limit
 
 
     def get_queryset_count(self, queryset):
     def get_queryset_count(self, queryset):
         return queryset.count()
         return queryset.count()

+ 7 - 2
netbox/netbox/config/__init__.py

@@ -78,11 +78,16 @@ class Config:
         from core.models import ConfigRevision
         from core.models import ConfigRevision
 
 
         try:
         try:
-            revision = ConfigRevision.objects.last()
+            # Enforce the creation date as the ordering parameter
+            revision = ConfigRevision.objects.get(active=True)
+            logger.debug(f"Loaded active configuration revision #{revision.pk}")
+        except (ConfigRevision.DoesNotExist, ConfigRevision.MultipleObjectsReturned):
+            logger.warning("No active configuration revision found - falling back to most recent")
+            revision = ConfigRevision.objects.order_by('-created').first()
             if revision is None:
             if revision is None:
                 logger.debug("No previous configuration found in database; proceeding with default values")
                 logger.debug("No previous configuration found in database; proceeding with default values")
                 return
                 return
-            logger.debug("Loaded configuration data from database")
+            logger.debug(f"Using fallback configuration revision #{revision.pk}")
         except DatabaseError:
         except DatabaseError:
             # The database may not be available yet (e.g. when running a management command)
             # The database may not be available yet (e.g. when running a management command)
             logger.warning("Skipping config initialization (database unavailable)")
             logger.warning("Skipping config initialization (database unavailable)")

+ 0 - 3
netbox/netbox/configuration_example.py

@@ -91,9 +91,6 @@ ADMINS = [
     # ('John Doe', 'jdoe@example.com'),
     # ('John Doe', 'jdoe@example.com'),
 ]
 ]
 
 
-# Permit the retrieval of API tokens after their creation.
-ALLOW_TOKEN_RETRIEVAL = False
-
 # Enable any desired validators for local account passwords below. For a list of included validators, please see the
 # Enable any desired validators for local account passwords below. For a list of included validators, please see the
 # Django documentation at https://docs.djangoproject.com/en/stable/topics/auth/passwords/#password-validation.
 # Django documentation at https://docs.djangoproject.com/en/stable/topics/auth/passwords/#password-validation.
 AUTH_PASSWORD_VALIDATORS = [
 AUTH_PASSWORD_VALIDATORS = [

+ 0 - 2
netbox/netbox/configuration_testing.py

@@ -43,8 +43,6 @@ SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
 
 
 DEFAULT_PERMISSIONS = {}
 DEFAULT_PERMISSIONS = {}
 
 
-ALLOW_TOKEN_RETRIEVAL = True
-
 API_TOKEN_PEPPERS = {
 API_TOKEN_PEPPERS = {
     1: 'TEST-VALUE-DO-NOT-USE-TEST-VALUE-DO-NOT-USE-TEST-VALUE-DO-NOT-USE',
     1: 'TEST-VALUE-DO-NOT-USE-TEST-VALUE-DO-NOT-USE-TEST-VALUE-DO-NOT-USE',
 }
 }

+ 33 - 4
netbox/netbox/graphql/filter_lookups.py

@@ -7,6 +7,7 @@ from django.core.exceptions import FieldDoesNotExist
 from django.db.models import Q, QuerySet
 from django.db.models import Q, QuerySet
 from django.db.models.fields.related import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel
 from django.db.models.fields.related import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel
 from strawberry import ID
 from strawberry import ID
+from strawberry.directive import DirectiveValue
 from strawberry.types import Info
 from strawberry.types import Info
 from strawberry_django import (
 from strawberry_django import (
     ComparisonFilterLookup,
     ComparisonFilterLookup,
@@ -24,6 +25,7 @@ __all__ = (
     'FloatLookup',
     'FloatLookup',
     'IntegerArrayLookup',
     'IntegerArrayLookup',
     'IntegerLookup',
     'IntegerLookup',
+    'IntegerRangeArrayLookup',
     'JSONFilter',
     'JSONFilter',
     'StringArrayLookup',
     'StringArrayLookup',
     'TreeNodeFilter',
     'TreeNodeFilter',
@@ -67,7 +69,7 @@ class IntegerLookup:
         return None
         return None
 
 
     @strawberry_django.filter_field
     @strawberry_django.filter_field
-    def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+    def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
         filters = self.get_filter()
         filters = self.get_filter()
 
 
         if not filters:
         if not filters:
@@ -90,7 +92,7 @@ class FloatLookup:
         return None
         return None
 
 
     @strawberry_django.filter_field
     @strawberry_django.filter_field
-    def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+    def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
         filters = self.get_filter()
         filters = self.get_filter()
 
 
         if not filters:
         if not filters:
@@ -109,7 +111,7 @@ class JSONFilter:
     lookup: JSONLookup
     lookup: JSONLookup
 
 
     @strawberry_django.filter_field
     @strawberry_django.filter_field
-    def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+    def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
         filters = self.lookup.get_filter()
         filters = self.lookup.get_filter()
 
 
         if not filters:
         if not filters:
@@ -136,7 +138,7 @@ class TreeNodeFilter:
     match_type: TreeNodeMatch
     match_type: TreeNodeMatch
 
 
     @strawberry_django.filter_field
     @strawberry_django.filter_field
-    def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+    def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
         model_field_name = prefix.removesuffix('__').removesuffix('_id')
         model_field_name = prefix.removesuffix('__').removesuffix('_id')
         model_field = None
         model_field = None
         try:
         try:
@@ -217,3 +219,30 @@ class FloatArrayLookup(ArrayLookup[float]):
 @strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')
 @strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')
 class StringArrayLookup(ArrayLookup[str]):
 class StringArrayLookup(ArrayLookup[str]):
     pass
     pass
+
+
+@strawberry.input(one_of=True, description='Lookups for an ArrayField(RangeField). Only one may be set.')
+class RangeArrayValueLookup(Generic[T]):
+    """
+    class for Array field of Range fields lookups
+    """
+
+    contains: T | None = strawberry.field(
+        default=strawberry.UNSET, description='Return rows where any stored range contains this value.'
+    )
+
+    @strawberry_django.filter_field
+    def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+        """
+        Map GraphQL: { <field>: { contains: <T> } } To Django ORM: <field>__range_contains=<T>
+        """
+        if self.contains is strawberry.UNSET or self.contains is None:
+            return queryset, Q()
+
+        # Build '<prefix>range_contains' so it works for nested paths too
+        return queryset, Q(**{f'{prefix}range_contains': self.contains})
+
+
+@strawberry.input(one_of=True, description='Lookups for an ArrayField(IntegerRangeField). Only one may be set.')
+class IntegerRangeArrayLookup(RangeArrayValueLookup[int]):
+    pass

+ 45 - 4
netbox/netbox/graphql/schema.py

@@ -1,7 +1,7 @@
 import strawberry
 import strawberry
 from django.conf import settings
 from django.conf import settings
 from strawberry_django.optimizer import DjangoOptimizerExtension
 from strawberry_django.optimizer import DjangoOptimizerExtension
-from strawberry.extensions import MaxAliasesLimiter  # , SchemaExtension
+from strawberry.extensions import MaxAliasesLimiter
 from strawberry.schema.config import StrawberryConfig
 from strawberry.schema.config import StrawberryConfig
 
 
 from circuits.graphql.schema import CircuitsQuery
 from circuits.graphql.schema import CircuitsQuery
@@ -16,9 +16,17 @@ from virtualization.graphql.schema import VirtualizationQuery
 from vpn.graphql.schema import VPNQuery
 from vpn.graphql.schema import VPNQuery
 from wireless.graphql.schema import WirelessQuery
 from wireless.graphql.schema import WirelessQuery
 
 
+__all__ = (
+    'Query',
+    'QueryV1',
+    'QueryV2',
+    'schema_v1',
+    'schema_v2',
+)
+
 
 
 @strawberry.type
 @strawberry.type
-class Query(
+class QueryV1(
     UsersQuery,
     UsersQuery,
     CircuitsQuery,
     CircuitsQuery,
     CoreQuery,
     CoreQuery,
@@ -31,11 +39,44 @@ class Query(
     WirelessQuery,
     WirelessQuery,
     *registry['plugins']['graphql_schemas'],  # Append plugin schemas
     *registry['plugins']['graphql_schemas'],  # Append plugin schemas
 ):
 ):
+    """Query class for GraphQL API v1"""
     pass
     pass
 
 
 
 
-schema = strawberry.Schema(
-    query=Query,
+@strawberry.type
+class QueryV2(
+    UsersQuery,
+    CircuitsQuery,
+    CoreQuery,
+    DCIMQuery,
+    ExtrasQuery,
+    IPAMQuery,
+    TenancyQuery,
+    VirtualizationQuery,
+    VPNQuery,
+    WirelessQuery,
+    *registry['plugins']['graphql_schemas'],  # Append plugin schemas
+):
+    """Query class for GraphQL API v2"""
+    pass
+
+
+# Expose a default Query class for the configured default GraphQL version
+class Query(QueryV2 if settings.GRAPHQL_DEFAULT_VERSION == 2 else QueryV1):
+    pass
+
+
+# Generate schemas for both versions of the GraphQL API
+schema_v1 = strawberry.Schema(
+    query=QueryV1,
+    config=StrawberryConfig(auto_camel_case=False),
+    extensions=[
+        DjangoOptimizerExtension(prefetch_custom_queryset=True),
+        MaxAliasesLimiter(max_alias_count=settings.GRAPHQL_MAX_ALIASES),
+    ]
+)
+schema_v2 = strawberry.Schema(
+    query=QueryV2,
     config=StrawberryConfig(auto_camel_case=False),
     config=StrawberryConfig(auto_camel_case=False),
     extensions=[
     extensions=[
         DjangoOptimizerExtension(prefetch_custom_queryset=True),
         DjangoOptimizerExtension(prefetch_custom_queryset=True),

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

@@ -1,5 +1,6 @@
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
+from strawberry.types import Info
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 
 
 from core.graphql.mixins import ChangelogMixin
 from core.graphql.mixins import ChangelogMixin
@@ -26,7 +27,7 @@ class BaseObjectType:
     """
     """
 
 
     @classmethod
     @classmethod
-    def get_queryset(cls, queryset, info, **kwargs):
+    def get_queryset(cls, queryset, info: Info, **kwargs):
         # Enforce object permissions on the queryset
         # Enforce object permissions on the queryset
         if hasattr(queryset, 'restrict'):
         if hasattr(queryset, 'restrict'):
             return queryset.restrict(info.context.request.user, 'view')
             return queryset.restrict(info.context.request.user, 'view')

+ 16 - 0
netbox/netbox/graphql/utils.py

@@ -0,0 +1,16 @@
+from django.conf import settings
+
+from netbox.graphql.schema import schema_v1, schema_v2
+
+__all__ = (
+    'get_default_schema',
+)
+
+
+def get_default_schema():
+    """
+    Returns the GraphQL schema corresponding to the value of the NETBOX_GRAPHQL_DEFAULT_SCHEMA setting.
+    """
+    if settings.GRAPHQL_DEFAULT_VERSION == 2:
+        return schema_v2
+    return schema_v1

+ 26 - 15
netbox/netbox/models/__init__.py

@@ -50,21 +50,15 @@ class NetBoxFeatureSet(
 # Base model classes
 # Base model classes
 #
 #
 
 
-class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, EventRulesMixin, models.Model):
+class BaseModel(models.Model):
     """
     """
-    Base model for ancillary models; provides limited functionality for models which don't
-    support NetBox's full feature set.
-    """
-    objects = RestrictedQuerySet.as_manager()
-
-    class Meta:
-        abstract = True
-
+    A global base model for all NetBox objects.
 
 
-class NetBoxModel(NetBoxFeatureSet, models.Model):
-    """
-    Base model for most object types. Suitable for use by plugins.
+    This class provides some important overrides to Django's default functionality, such as
+    - Overriding the default manager to use RestrictedQuerySet
+    - Extending `clean()` to validate GenericForeignKey fields
     """
     """
+
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
     class Meta:
     class Meta:
@@ -103,6 +97,25 @@ class NetBoxModel(NetBoxFeatureSet, models.Model):
                     setattr(self, field.name, obj)
                     setattr(self, field.name, obj)
 
 
 
 
+class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, EventRulesMixin, BaseModel):
+    """
+    Base model for ancillary models; provides limited functionality for models which don't
+    support NetBox's full feature set.
+    """
+
+    class Meta:
+        abstract = True
+
+
+class NetBoxModel(NetBoxFeatureSet, BaseModel):
+    """
+    Base model for most object types. Suitable for use by plugins.
+    """
+
+    class Meta:
+        abstract = True
+
+
 #
 #
 # NetBox internal base models
 # NetBox internal base models
 #
 #
@@ -177,7 +190,7 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
             })
             })
 
 
 
 
-class OrganizationalModel(NetBoxFeatureSet, models.Model):
+class OrganizationalModel(NetBoxModel):
     """
     """
     Organizational models are those which are used solely to categorize and qualify other objects, and do not convey
     Organizational models are those which are used solely to categorize and qualify other objects, and do not convey
     any real information about the infrastructure being modeled (for example, functional device roles). Organizational
     any real information about the infrastructure being modeled (for example, functional device roles). Organizational
@@ -202,8 +215,6 @@ class OrganizationalModel(NetBoxFeatureSet, models.Model):
         blank=True
         blank=True
     )
     )
 
 
-    objects = RestrictedQuerySet.as_manager()
-
     class Meta:
     class Meta:
         abstract = True
         abstract = True
         ordering = ('name',)
         ordering = ('name',)

+ 10 - 3
netbox/netbox/models/features.py

@@ -673,10 +673,17 @@ def has_feature(model_or_ct, feature):
     # If an ObjectType was passed, we can use it directly
     # If an ObjectType was passed, we can use it directly
     if type(model_or_ct) is ObjectType:
     if type(model_or_ct) is ObjectType:
         ot = model_or_ct
         ot = model_or_ct
-    # If a ContentType was passed, resolve its model class
+    # If a ContentType was passed, resolve its model class and run the associated feature test
     elif type(model_or_ct) is ContentType:
     elif type(model_or_ct) is ContentType:
-        model_class = model_or_ct.model_class()
-        ot = ObjectType.objects.get_for_model(model_class) if model_class else None
+        model = model_or_ct.model_class()
+        if model is None:  # Stale content type
+            return False
+        try:
+            test_func = registry['model_features'][feature]
+        except KeyError:
+            # Unknown feature
+            return False
+        return test_func(model)
     # For anything else, look up the ObjectType
     # For anything else, look up the ObjectType
     else:
     else:
         ot = ObjectType.objects.get_for_model(model_or_ct)
         ot = ObjectType.objects.get_for_model(model_or_ct)

+ 39 - 0
netbox/netbox/monkey.py

@@ -0,0 +1,39 @@
+from django.db.models import UniqueConstraint
+from rest_framework.utils.field_mapping import get_unique_error_message
+from rest_framework.validators import UniqueValidator
+
+__all__ = (
+    'get_unique_validators',
+)
+
+
+def get_unique_validators(field_name, model_field):
+    """
+    Extend Django REST Framework's get_unique_validators() function to attach a UniqueValidator to a field *only* if the
+     associated UniqueConstraint does NOT have a condition which references another field. See bug #19302.
+    """
+    field_set = {field_name}
+    conditions = {
+        c.condition
+        for c in model_field.model._meta.constraints
+        if isinstance(c, UniqueConstraint) and set(c.fields) == field_set
+    }
+
+    # START custom logic
+    conditions = {
+        cond for cond in conditions
+        if cond is None or cond.referenced_base_fields == field_set
+    }
+    # END custom logic
+
+    if getattr(model_field, 'unique', False):
+        conditions.add(None)
+    if not conditions:
+        return
+    unique_error_message = get_unique_error_message(model_field)
+    queryset = model_field.model._default_manager
+    for condition in conditions:
+        yield UniqueValidator(
+            queryset=queryset if condition is None else queryset.filter(condition),
+            message=unique_error_message
+        )

+ 13 - 1
netbox/netbox/settings.py

@@ -11,6 +11,7 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError
 from django.core.validators import URLValidator
 from django.core.validators import URLValidator
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
+from rest_framework.utils import field_mapping
 
 
 from core.exceptions import IncompatiblePluginError
 from core.exceptions import IncompatiblePluginError
 from netbox.config import PARAMS as CONFIG_PARAMS
 from netbox.config import PARAMS as CONFIG_PARAMS
@@ -21,6 +22,17 @@ import storages.utils  # type: ignore
 from utilities.release import load_release_data
 from utilities.release import load_release_data
 from utilities.security import validate_peppers
 from utilities.security import validate_peppers
 from utilities.string import trailing_slash
 from utilities.string import trailing_slash
+from .monkey import get_unique_validators
+
+
+#
+# Monkey-patching
+#
+
+# TODO: Remove this once #20547 has been implemented
+# Override DRF's get_unique_validators() function with our own (see bug #19302)
+field_mapping.get_unique_validators = get_unique_validators
+
 
 
 #
 #
 # Environment setup
 # Environment setup
@@ -64,7 +76,6 @@ elif hasattr(configuration, 'DATABASE') and hasattr(configuration, 'DATABASES'):
 
 
 # Set static config parameters
 # Set static config parameters
 ADMINS = getattr(configuration, 'ADMINS', [])
 ADMINS = getattr(configuration, 'ADMINS', [])
-ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', False)
 ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS')  # Required
 ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS')  # Required
 API_TOKEN_PEPPERS = getattr(configuration, 'API_TOKEN_PEPPERS', {})
 API_TOKEN_PEPPERS = getattr(configuration, 'API_TOKEN_PEPPERS', {})
 AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [
 AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [
@@ -126,6 +137,7 @@ EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', [
 EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
 EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
 FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
 FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
 FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
 FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
+GRAPHQL_DEFAULT_VERSION = getattr(configuration, 'GRAPHQL_DEFAULT_VERSION', 1)
 GRAPHQL_MAX_ALIASES = getattr(configuration, 'GRAPHQL_MAX_ALIASES', 10)
 GRAPHQL_MAX_ALIASES = getattr(configuration, 'GRAPHQL_MAX_ALIASES', 10)
 HOSTNAME = getattr(configuration, 'HOSTNAME', platform.node())
 HOSTNAME = getattr(configuration, 'HOSTNAME', platform.node())
 HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {})
 HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {})

+ 8 - 4
netbox/netbox/urls.py

@@ -6,7 +6,8 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, Spec
 
 
 from account.views import LoginView, LogoutView
 from account.views import LoginView, LogoutView
 from netbox.api.views import APIRootView, StatusView
 from netbox.api.views import APIRootView, StatusView
-from netbox.graphql.schema import schema
+from netbox.graphql.schema import schema_v1, schema_v2
+from netbox.graphql.utils import get_default_schema
 from netbox.graphql.views import NetBoxGraphQLView
 from netbox.graphql.views import NetBoxGraphQLView
 from netbox.plugins.urls import plugin_patterns, plugin_api_patterns
 from netbox.plugins.urls import plugin_patterns, plugin_api_patterns
 from netbox.views import HomeView, MediaView, StaticMediaFailureView, SearchView, htmx
 from netbox.views import HomeView, MediaView, StaticMediaFailureView, SearchView, htmx
@@ -40,7 +41,7 @@ _patterns = [
     # HTMX views
     # HTMX views
     path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'),
     path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'),
 
 
-    # API
+    # REST API
     path('api/', APIRootView.as_view(), name='api-root'),
     path('api/', APIRootView.as_view(), name='api-root'),
     path('api/circuits/', include('circuits.api.urls')),
     path('api/circuits/', include('circuits.api.urls')),
     path('api/core/', include('core.api.urls')),
     path('api/core/', include('core.api.urls')),
@@ -54,6 +55,7 @@ _patterns = [
     path('api/wireless/', include('wireless.api.urls')),
     path('api/wireless/', include('wireless.api.urls')),
     path('api/status/', StatusView.as_view(), name='api-status'),
     path('api/status/', StatusView.as_view(), name='api-status'),
 
 
+    # REST API schema
     path(
     path(
         "api/schema/",
         "api/schema/",
         cache_page(timeout=86400, key_prefix=f"api_schema_{settings.RELEASE.version}")(
         cache_page(timeout=86400, key_prefix=f"api_schema_{settings.RELEASE.version}")(
@@ -64,8 +66,10 @@ _patterns = [
     path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='api_docs'),
     path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='api_docs'),
     path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='api_redocs'),
     path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='api_redocs'),
 
 
-    # GraphQL
-    path('graphql/', NetBoxGraphQLView.as_view(schema=schema), name='graphql'),
+    # GraphQL API
+    path('graphql/', NetBoxGraphQLView.as_view(schema=get_default_schema()), name='graphql'),
+    path('graphql/v1/', NetBoxGraphQLView.as_view(schema=schema_v1), name='graphql_v1'),
+    path('graphql/v2/', NetBoxGraphQLView.as_view(schema=schema_v2), name='graphql_v2'),
 
 
     # Serving static media in Django to pipe it through LoginRequiredMiddleware
     # Serving static media in Django to pipe it through LoginRequiredMiddleware
     path('media/<path:path>', MediaView.as_view(), name='media'),
     path('media/<path:path>', MediaView.as_view(), name='media'),

+ 2 - 1
netbox/netbox/views/generic/object_views.py

@@ -281,7 +281,8 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
 
 
         obj = self.alter_object(obj, request, args, kwargs)
         obj = self.alter_object(obj, request, args, kwargs)
 
 
-        form = self.form(data=request.POST, files=request.FILES, instance=obj)
+        form_prefix = 'quickadd' if request.GET.get('_quickadd') else None
+        form = self.form(data=request.POST, files=request.FILES, instance=obj, prefix=form_prefix)
         restrict_form_fields(form, request.user)
         restrict_form_fields(form, request.user)
 
 
         if form.is_valid():
         if form.is_valid():

File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/netbox.css


File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/netbox.js


File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/netbox.js.map


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

@@ -41,7 +41,7 @@
     "@types/node": "^22.3.0",
     "@types/node": "^22.3.0",
     "@typescript-eslint/eslint-plugin": "^8.37.0",
     "@typescript-eslint/eslint-plugin": "^8.37.0",
     "@typescript-eslint/parser": "^8.37.0",
     "@typescript-eslint/parser": "^8.37.0",
-    "esbuild": "^0.25.6",
+    "esbuild": "^0.25.11",
     "esbuild-sass-plugin": "^3.3.1",
     "esbuild-sass-plugin": "^3.3.1",
     "eslint": "<9.0",
     "eslint": "<9.0",
     "eslint-config-prettier": "^9.1.0",
     "eslint-config-prettier": "^9.1.0",

+ 1 - 1
netbox/project-static/src/racks.ts

@@ -83,7 +83,7 @@ export function initRackElevation(): void {
   }
   }
 
 
   for (const element of getElements<HTMLObjectElement>('.rack_elevation')) {
   for (const element of getElements<HTMLObjectElement>('.rack_elevation')) {
-    element.addEventListener('load', () => {
+    element.addEventListener('htmx:afterSettle', () => {
       setRackView(initialView, element);
       setRackView(initialView, element);
     });
     });
   }
   }

+ 159 - 159
netbox/project-static/yarn.lock

@@ -19,135 +19,135 @@
   resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb"
   resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb"
   integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==
   integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==
 
 
-"@esbuild/aix-ppc64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz#a1414903bb38027382f85f03dda6065056757727"
-  integrity sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==
-
-"@esbuild/android-arm64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz#c859994089e9767224269884061f89dae6fb51c6"
-  integrity sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==
-
-"@esbuild/android-arm@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.8.tgz#96a8f2ca91c6cd29ea90b1af79d83761c8ba0059"
-  integrity sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==
-
-"@esbuild/android-x64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.8.tgz#a3a626c4fec4a024a9fa8c7679c39996e92916f0"
-  integrity sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==
-
-"@esbuild/darwin-arm64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz#a5e1252ca2983d566af1c0ea39aded65736fc66d"
-  integrity sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==
-
-"@esbuild/darwin-x64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz#5271b0df2bb12ce8df886704bfdd1c7cc01385d2"
-  integrity sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==
-
-"@esbuild/freebsd-arm64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz#d0a0e7fdf19733b8bb1566b81df1aa0bb7e46ada"
-  integrity sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==
-
-"@esbuild/freebsd-x64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz#2de8b2e0899d08f1cb1ef3128e159616e7e85343"
-  integrity sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==
-
-"@esbuild/linux-arm64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz#a4209efadc0c2975716458484a4e90c237c48ae9"
-  integrity sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==
-
-"@esbuild/linux-arm@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz#ccd9e291c24cd8d9142d819d463e2e7200d25b19"
-  integrity sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==
-
-"@esbuild/linux-ia32@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz#006ad1536d0c2b28fb3a1cf0b53bcb85aaf92c4d"
-  integrity sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==
-
-"@esbuild/linux-loong64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz#127b3fbfb2c2e08b1397e985932f718f09a8f5c4"
-  integrity sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==
-
-"@esbuild/linux-mips64el@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz#837d1449517791e3fa7d82675a2d06d9f56cb340"
-  integrity sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==
-
-"@esbuild/linux-ppc64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz#aa2e3bd93ab8df084212f1895ca4b03c42d9e0fe"
-  integrity sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==
-
-"@esbuild/linux-riscv64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz#a340620e31093fef72767dd28ab04214b3442083"
-  integrity sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==
-
-"@esbuild/linux-s390x@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz#ddfed266c8c13f5efb3105a0cd47f6dcd0e79e71"
-  integrity sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==
-
-"@esbuild/linux-x64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz#9a4f78c75c051e8c060183ebb39a269ba936a2ac"
-  integrity sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==
-
-"@esbuild/netbsd-arm64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz#902c80e1d678047926387230bc037e63e00697d0"
-  integrity sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==
-
-"@esbuild/netbsd-x64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz#2d9eb4692add2681ff05a14ce99de54fbed7079c"
-  integrity sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==
-
-"@esbuild/openbsd-arm64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz#89c3b998c6de739db38ab7fb71a8a76b3fa84a45"
-  integrity sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==
-
-"@esbuild/openbsd-x64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz#2f01615cf472b0e48c077045cfd96b5c149365cc"
-  integrity sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==
-
-"@esbuild/openharmony-arm64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz#a201f720cd2c3ebf9a6033fcc3feb069a54b509a"
-  integrity sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==
-
-"@esbuild/sunos-x64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz#07046c977985a3334667f19e6ab3a01a80862afb"
-  integrity sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==
-
-"@esbuild/win32-arm64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz#4a5470caf0d16127c05d4833d4934213c69392d1"
-  integrity sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==
-
-"@esbuild/win32-ia32@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz#3de3e8470b7b328d99dbc3e9ec1eace207e5bbc4"
-  integrity sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==
-
-"@esbuild/win32-x64@0.25.8":
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz#610d7ea539d2fcdbe39237b5cc175eb2c4451f9c"
-  integrity sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==
+"@esbuild/aix-ppc64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz#2ae33300598132cc4cf580dbbb28d30fed3c5c49"
+  integrity sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==
+
+"@esbuild/android-arm64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz#927708b3db5d739d6cb7709136924cc81bec9b03"
+  integrity sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==
+
+"@esbuild/android-arm@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.11.tgz#571f94e7f4068957ec4c2cfb907deae3d01b55ae"
+  integrity sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==
+
+"@esbuild/android-x64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.11.tgz#8a3bf5cae6c560c7ececa3150b2bde76e0fb81e6"
+  integrity sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==
+
+"@esbuild/darwin-arm64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz#0a678c4ac4bf8717e67481e1a797e6c152f93c84"
+  integrity sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==
+
+"@esbuild/darwin-x64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz#70f5e925a30c8309f1294d407a5e5e002e0315fe"
+  integrity sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==
+
+"@esbuild/freebsd-arm64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz#4ec1db687c5b2b78b44148025da9632397553e8a"
+  integrity sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==
+
+"@esbuild/freebsd-x64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz#4c81abd1b142f1e9acfef8c5153d438ca53f44bb"
+  integrity sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==
+
+"@esbuild/linux-arm64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz#69517a111acfc2b93aa0fb5eaeb834c0202ccda5"
+  integrity sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==
+
+"@esbuild/linux-arm@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz#58dac26eae2dba0fac5405052b9002dac088d38f"
+  integrity sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==
+
+"@esbuild/linux-ia32@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz#b89d4efe9bdad46ba944f0f3b8ddd40834268c2b"
+  integrity sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==
+
+"@esbuild/linux-loong64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz#11f603cb60ad14392c3f5c94d64b3cc8b630fbeb"
+  integrity sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==
+
+"@esbuild/linux-mips64el@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz#b7d447ff0676b8ab247d69dac40a5cf08e5eeaf5"
+  integrity sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==
+
+"@esbuild/linux-ppc64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz#b3a28ed7cc252a61b07ff7c8fd8a984ffd3a2f74"
+  integrity sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==
+
+"@esbuild/linux-riscv64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz#ce75b08f7d871a75edcf4d2125f50b21dc9dc273"
+  integrity sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==
+
+"@esbuild/linux-s390x@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz#cd08f6c73b6b6ff9ccdaabbd3ff6ad3dca99c263"
+  integrity sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==
+
+"@esbuild/linux-x64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz#3c3718af31a95d8946ebd3c32bb1e699bdf74910"
+  integrity sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==
+
+"@esbuild/netbsd-arm64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz#b4c767082401e3a4e8595fe53c47cd7f097c8077"
+  integrity sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==
+
+"@esbuild/netbsd-x64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz#f2a930458ed2941d1f11ebc34b9c7d61f7a4d034"
+  integrity sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==
+
+"@esbuild/openbsd-arm64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz#b4ae93c75aec48bc1e8a0154957a05f0641f2dad"
+  integrity sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==
+
+"@esbuild/openbsd-x64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz#b42863959c8dcf9b01581522e40012d2c70045e2"
+  integrity sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==
+
+"@esbuild/openharmony-arm64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz#b2e717141c8fdf6bddd4010f0912e6b39e1640f1"
+  integrity sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==
+
+"@esbuild/sunos-x64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz#9fbea1febe8778927804828883ec0f6dd80eb244"
+  integrity sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==
+
+"@esbuild/win32-arm64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz#501539cedb24468336073383989a7323005a8935"
+  integrity sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==
+
+"@esbuild/win32-ia32@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz#8ac7229aa82cef8f16ffb58f1176a973a7a15343"
+  integrity sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==
+
+"@esbuild/win32-x64@0.25.11":
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz#5ecda6f3fe138b7e456f4e429edde33c823f392f"
+  integrity sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==
 
 
 "@eslint-community/eslint-utils@^4.2.0":
 "@eslint-community/eslint-utils@^4.2.0":
   version "4.4.0"
   version "4.4.0"
@@ -1642,37 +1642,37 @@ esbuild-sass-plugin@^3.3.1:
     safe-identifier "^0.4.2"
     safe-identifier "^0.4.2"
     sass "^1.71.1"
     sass "^1.71.1"
 
 
-esbuild@^0.25.6:
-  version "0.25.8"
-  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.8.tgz#482d42198b427c9c2f3a81b63d7663aecb1dda07"
-  integrity sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==
+esbuild@^0.25.11:
+  version "0.25.11"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.11.tgz#0f31b82f335652580f75ef6897bba81962d9ae3d"
+  integrity sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==
   optionalDependencies:
   optionalDependencies:
-    "@esbuild/aix-ppc64" "0.25.8"
-    "@esbuild/android-arm" "0.25.8"
-    "@esbuild/android-arm64" "0.25.8"
-    "@esbuild/android-x64" "0.25.8"
-    "@esbuild/darwin-arm64" "0.25.8"
-    "@esbuild/darwin-x64" "0.25.8"
-    "@esbuild/freebsd-arm64" "0.25.8"
-    "@esbuild/freebsd-x64" "0.25.8"
-    "@esbuild/linux-arm" "0.25.8"
-    "@esbuild/linux-arm64" "0.25.8"
-    "@esbuild/linux-ia32" "0.25.8"
-    "@esbuild/linux-loong64" "0.25.8"
-    "@esbuild/linux-mips64el" "0.25.8"
-    "@esbuild/linux-ppc64" "0.25.8"
-    "@esbuild/linux-riscv64" "0.25.8"
-    "@esbuild/linux-s390x" "0.25.8"
-    "@esbuild/linux-x64" "0.25.8"
-    "@esbuild/netbsd-arm64" "0.25.8"
-    "@esbuild/netbsd-x64" "0.25.8"
-    "@esbuild/openbsd-arm64" "0.25.8"
-    "@esbuild/openbsd-x64" "0.25.8"
-    "@esbuild/openharmony-arm64" "0.25.8"
-    "@esbuild/sunos-x64" "0.25.8"
-    "@esbuild/win32-arm64" "0.25.8"
-    "@esbuild/win32-ia32" "0.25.8"
-    "@esbuild/win32-x64" "0.25.8"
+    "@esbuild/aix-ppc64" "0.25.11"
+    "@esbuild/android-arm" "0.25.11"
+    "@esbuild/android-arm64" "0.25.11"
+    "@esbuild/android-x64" "0.25.11"
+    "@esbuild/darwin-arm64" "0.25.11"
+    "@esbuild/darwin-x64" "0.25.11"
+    "@esbuild/freebsd-arm64" "0.25.11"
+    "@esbuild/freebsd-x64" "0.25.11"
+    "@esbuild/linux-arm" "0.25.11"
+    "@esbuild/linux-arm64" "0.25.11"
+    "@esbuild/linux-ia32" "0.25.11"
+    "@esbuild/linux-loong64" "0.25.11"
+    "@esbuild/linux-mips64el" "0.25.11"
+    "@esbuild/linux-ppc64" "0.25.11"
+    "@esbuild/linux-riscv64" "0.25.11"
+    "@esbuild/linux-s390x" "0.25.11"
+    "@esbuild/linux-x64" "0.25.11"
+    "@esbuild/netbsd-arm64" "0.25.11"
+    "@esbuild/netbsd-x64" "0.25.11"
+    "@esbuild/openbsd-arm64" "0.25.11"
+    "@esbuild/openbsd-x64" "0.25.11"
+    "@esbuild/openharmony-arm64" "0.25.11"
+    "@esbuild/sunos-x64" "0.25.11"
+    "@esbuild/win32-arm64" "0.25.11"
+    "@esbuild/win32-ia32" "0.25.11"
+    "@esbuild/win32-x64" "0.25.11"
 
 
 escape-string-regexp@^4.0.0:
 escape-string-regexp@^4.0.0:
   version "4.0.0"
   version "4.0.0"

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.4.2"
+version: "4.4.4"
 edition: "Community"
 edition: "Community"
-published: "2025-09-30"
+published: "2025-10-15"

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

@@ -18,7 +18,7 @@
     <li class="nav-item">
     <li class="nav-item">
       <a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'account:preferences' %}">{% trans "Preferences" %}</a>
       <a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'account:preferences' %}">{% trans "Preferences" %}</a>
     </li>
     </li>
-    {% if not request.user.ldap_username %}
+    {% if request.user.has_usable_password %}
       <li class="nav-item">
       <li class="nav-item">
         <a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'account:change_password' %}">{% trans "Password" %}</a>
         <a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'account:change_password' %}">{% trans "Password" %}</a>
       </li>
       </li>

+ 4 - 4
netbox/templates/base/base.html

@@ -26,7 +26,7 @@
     {# Initialize color mode #}
     {# Initialize color mode #}
     <script
     <script
       type="text/javascript"
       type="text/javascript"
-      src="{% static 'setmode.js' %}?v={{ settings.RELEASE.version }}"
+      src="{% static_with_params 'setmode.js' v=settings.RELEASE.version %}"
       onerror="window.location='{% url 'media_failure' %}?filename=setmode.js'">
       onerror="window.location='{% url 'media_failure' %}?filename=setmode.js'">
     </script>
     </script>
     <script type="text/javascript">
     <script type="text/javascript">
@@ -39,12 +39,12 @@
     {# Static resources #}
     {# Static resources #}
     <link
     <link
       rel="stylesheet"
       rel="stylesheet"
-      href="{% static 'netbox-external.css'%}?v={{ settings.RELEASE.version }}"
+      href="{% static_with_params 'netbox-external.css' v=settings.RELEASE.version %}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-external.css'"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-external.css'"
     />
     />
     <link
     <link
       rel="stylesheet"
       rel="stylesheet"
-      href="{% static 'netbox.css'%}?v={{ settings.RELEASE.version }}"
+      href="{% static_with_params 'netbox.css' v=settings.RELEASE.version %}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox.css'"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox.css'"
     />
     />
     <link rel="icon" type="image/png" href="{% static 'netbox.ico' %}" />
     <link rel="icon" type="image/png" href="{% static 'netbox.ico' %}" />
@@ -53,7 +53,7 @@
     {# Javascript #}
     {# Javascript #}
     <script
     <script
       type="text/javascript"
       type="text/javascript"
-      src="{% static 'netbox.js' %}?v={{ settings.RELEASE.version }}"
+      src="{% static_with_params 'netbox.js' v=settings.RELEASE.version %}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox.js'">
       onerror="window.location='{% url 'media_failure' %}?filename=netbox.js'">
     </script>
     </script>
     {% django_htmx_script %}
     {% django_htmx_script %}

+ 4 - 3
netbox/templates/extras/htmx/script_result.html

@@ -44,8 +44,8 @@
         <div class="htmx-container table-responsive"
         <div class="htmx-container table-responsive"
           hx-get="{% url 'extras:script_result' job_pk=job.pk %}?embedded=True&log=True&log_threshold={{log_threshold}}"
           hx-get="{% url 'extras:script_result' job_pk=job.pk %}?embedded=True&log=True&log_threshold={{log_threshold}}"
           hx-target="this"
           hx-target="this"
-          hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML"
-        ></div>
+          hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML">
+        </div>
       </div>
       </div>
     </div>
     </div>
     {% endif %}
     {% endif %}
@@ -60,11 +60,12 @@
               <a href="?export=output" class="btn btn-sm btn-primary" role="button">
               <a href="?export=output" class="btn btn-sm btn-primary" role="button">
                 <i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
                 <i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
               </a>
               </a>
+              {% copy_content "job_data_output" %}
             </div>
             </div>
           {% endif %}
           {% endif %}
         </h2>
         </h2>
         {% if job.data.output %}
         {% if job.data.output %}
-          <pre class="card-body font-monospace">{{ job.data.output }}</pre>
+          <pre class="card-body font-monospace" id="job_data_output">{{ job.data.output }}</pre>
         {% else %}
         {% else %}
           <div class="card-body text-muted">{% trans "None" %}</div>
           <div class="card-body text-muted">{% trans "None" %}</div>
         {% endif %}
         {% endif %}

+ 1 - 1
netbox/templates/ipam/vlangroup.html

@@ -40,7 +40,7 @@
         </tr>
         </tr>
         <tr>
         <tr>
           <th scope="row">{% trans "VLAN IDs" %}</th>
           <th scope="row">{% trans "VLAN IDs" %}</th>
-          <td>{{ object.vid_ranges_list }}</td>
+          <td>{{ object.vid_ranges_items|join:", " }}</td>
         </tr>
         </tr>
         <tr>
         <tr>
           <th scope="row">Utilization</th>
           <th scope="row">Utilization</th>

+ 1 - 8
netbox/templates/users/token.html

@@ -20,14 +20,7 @@
           {% if object.version == 1 %}
           {% if object.version == 1 %}
             <tr>
             <tr>
               <th scope="row">{% trans "Token" %}</th>
               <th scope="row">{% trans "Token" %}</th>
-              <td>
-                {% if settings.ALLOW_TOKEN_RETRIEVAL %}
-                  <span id="secret" class="font-monospace" data-secret="{{ object.plaintext }}">{{ object.plaintext }}</span>
-                  <button type="button" class="btn btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>
-                {% else %}
-                  {{ object.partial }}
-                {% endif %}
-              </td>
+              <td>{{ object.partial }}</td>
             </tr>
             </tr>
           {% else %}
           {% else %}
             <tr>
             <tr>

+ 10 - 4
netbox/tenancy/forms/bulk_import.py

@@ -1,3 +1,4 @@
+from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
@@ -25,7 +26,7 @@ class TenantGroupImportForm(NetBoxModelImportForm):
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text=_('Parent group')
+        help_text=_('Parent group'),
     )
     )
     slug = SlugField()
     slug = SlugField()
 
 
@@ -41,7 +42,7 @@ class TenantImportForm(NetBoxModelImportForm):
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text=_('Assigned group')
+        help_text=_('Assigned group'),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -59,7 +60,7 @@ class ContactGroupImportForm(NetBoxModelImportForm):
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text=_('Parent group')
+        help_text=_('Parent group'),
     )
     )
     slug = SlugField()
     slug = SlugField()
 
 
@@ -81,7 +82,12 @@ class ContactImportForm(NetBoxModelImportForm):
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text=_('Group names separated by commas, encased with double quotes (e.g. "Group 1,Group 2")')
+        help_text=_('Group names separated by commas, encased with double quotes (e.g. "Group 1,Group 2")'),
+    )
+    link = forms.URLField(
+        label=_('Link'),
+        assume_scheme='https',
+        required=False,
     )
     )
 
 
     class Meta:
     class Meta:

+ 5 - 0
netbox/tenancy/forms/model_forms.py

@@ -100,6 +100,11 @@ class ContactForm(NetBoxModelForm):
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         required=False
         required=False
     )
     )
+    link = forms.URLField(
+        label=_('Link'),
+        assume_scheme='https',
+        required=False,
+    )
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     fieldsets = (

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


File diff suppressed because it is too large
+ 176 - 170
netbox/translations/cs/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 176 - 170
netbox/translations/da/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 176 - 170
netbox/translations/de/LC_MESSAGES/django.po


File diff suppressed because it is too large
+ 175 - 175
netbox/translations/en/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 176 - 170
netbox/translations/es/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 176 - 170
netbox/translations/fr/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 176 - 170
netbox/translations/it/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 176 - 170
netbox/translations/ja/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 176 - 170
netbox/translations/nl/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 176 - 170
netbox/translations/pl/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 176 - 169
netbox/translations/pt/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 176 - 170
netbox/translations/ru/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 176 - 170
netbox/translations/tr/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 176 - 170
netbox/translations/uk/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 176 - 170
netbox/translations/zh/LC_MESSAGES/django.po


+ 9 - 0
netbox/users/api/serializers_/tokens.py

@@ -37,6 +37,15 @@ class TokenSerializer(ValidatedModelSerializer):
         read_only_fields = ('key',)
         read_only_fields = ('key',)
         brief_fields = ('id', 'url', 'display', 'version', 'key', 'write_enabled', 'description')
         brief_fields = ('id', 'url', 'display', 'version', 'key', 'write_enabled', 'description')
 
 
+    def get_fields(self):
+        fields = super().get_fields()
+
+        # Make user field read-only if updating an existing Token.
+        if self.instance is not None:
+            fields['user'].read_only = True
+
+        return fields
+
     def validate(self, data):
     def validate(self, data):
 
 
         # If the Token is being created on behalf of another user, enforce the grant_token permission.
         # If the Token is being created on behalf of another user, enforce the grant_token permission.

+ 10 - 7
netbox/users/forms/model_forms.py

@@ -1,7 +1,6 @@
 import json
 import json
 
 
 from django import forms
 from django import forms
-from django.conf import settings
 from django.contrib.auth import password_validation
 from django.contrib.auth import password_validation
 from django.contrib.postgres.forms import SimpleArrayField
 from django.contrib.postgres.forms import SimpleArrayField
 from django.core.exceptions import FieldError
 from django.core.exceptions import FieldError
@@ -115,7 +114,7 @@ class UserTokenForm(forms.ModelForm):
         label=_('Token'),
         label=_('Token'),
         help_text=_(
         help_text=_(
             'Tokens must be at least 40 characters in length. <strong>Be sure to record your key</strong> prior to '
             'Tokens must be at least 40 characters in length. <strong>Be sure to record your key</strong> prior to '
-            'submitting this form, as it may no longer be accessible once the token has been created.'
+            'submitting this form, as it will no longer be accessible once the token has been created.'
         ),
         ),
         widget=forms.TextInput(
         widget=forms.TextInput(
             attrs={'data-clipboard': 'true'}
             attrs={'data-clipboard': 'true'}
@@ -148,11 +147,8 @@ class UserTokenForm(forms.ModelForm):
             self.fields['version'].disabled = True
             self.fields['version'].disabled = True
             self.fields['user'].disabled = True
             self.fields['user'].disabled = True
 
 
-            # Omit the key field when editing an existing token if token retrieval is not permitted
-            if self.instance.v1 and settings.ALLOW_TOKEN_RETRIEVAL:
-                self.initial['token'] = self.instance.plaintext
-            else:
-                del self.fields['token']
+            # Omit the key field when editing an existing Token
+            del self.fields['token']
 
 
         # Generate an initial random key if none has been specified
         # Generate an initial random key if none has been specified
         elif self.instance._state.adding and not self.initial.get('token'):
         elif self.instance._state.adding and not self.initial.get('token'):
@@ -177,6 +173,13 @@ class TokenForm(UserTokenForm):
             'version', 'token', 'user', 'write_enabled', 'expires', 'description', 'allowed_ips',
             'version', 'token', 'user', 'write_enabled', 'expires', 'description', 'allowed_ips',
         ]
         ]
 
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # If not creating a new Token, disable the user field
+        if self.instance and not self.instance._state.adding:
+            self.fields['user'].disabled = True
+
 
 
 class UserForm(forms.ModelForm):
 class UserForm(forms.ModelForm):
     password = forms.CharField(
     password = forms.CharField(

+ 1 - 8
netbox/users/tables.py

@@ -11,13 +11,7 @@ __all__ = (
     'UserTable',
     'UserTable',
 )
 )
 
 
-TOKEN = """<samp><a href="{{ record.get_absolute_url }}" id="token_{{ record.pk }}">{{ record }}</a></samp>"""
-
-COPY_BUTTON = """
-{% if settings.ALLOW_TOKEN_RETRIEVAL %}
-  {% copy_content record.pk prefix="token_" color="success" %}
-{% endif %}
-"""
+TOKEN = """<samp><a href="{{ record.get_absolute_url }}">{{ record }}</a></samp>"""
 
 
 
 
 class TokenTable(NetBoxTable):
 class TokenTable(NetBoxTable):
@@ -48,7 +42,6 @@ class TokenTable(NetBoxTable):
     )
     )
     actions = columns.ActionsColumn(
     actions = columns.ActionsColumn(
         actions=('edit', 'delete'),
         actions=('edit', 'delete'),
-        extra_buttons=COPY_BUTTON
     )
     )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):

+ 26 - 3
netbox/users/tests/test_api.py

@@ -212,9 +212,9 @@ class TokenTest(
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         users = (
         users = (
-            create_test_user('User1'),
-            create_test_user('User2'),
-            create_test_user('User3'),
+            create_test_user('User 1'),
+            create_test_user('User 2'),
+            create_test_user('User 3'),
         )
         )
 
 
         tokens = (
         tokens = (
@@ -238,6 +238,10 @@ class TokenTest(
             },
             },
         ]
         ]
 
 
+        cls.update_data = {
+            'description': 'Token 1',
+        }
+
     def test_provision_token_valid(self):
     def test_provision_token_valid(self):
         """
         """
         Test the provisioning of a new REST API token given a valid username and password.
         Test the provisioning of a new REST API token given a valid username and password.
@@ -300,6 +304,25 @@ class TokenTest(
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 201)
         self.assertEqual(response.status_code, 201)
 
 
+    def test_reassign_token(self):
+        """
+        Check that a Token cannot be reassigned to another User.
+        """
+        user1 = User.objects.get(username='User 1')
+        user2 = User.objects.get(username='User 2')
+        token1 = Token.objects.filter(user=user1).first()
+        self.add_permissions('users.change_token')
+
+        data = {
+            'user': user2.pk,
+        }
+        url = self._get_detail_url(token1)
+        response = self.client.patch(url, data, format='json', **self.header)
+        # Response should succeed because the read-only `user` field is ignored
+        self.assertEqual(response.status_code, 200)
+        token1.refresh_from_db()
+        self.assertEqual(token1.user, user1, "Token's user should not have changed")
+
 
 
 class ObjectPermissionTest(
 class ObjectPermissionTest(
     # No GraphQL support for ObjectPermission
     # No GraphQL support for ObjectPermission

+ 54 - 15
netbox/utilities/data.py

@@ -1,7 +1,8 @@
 import decimal
 import decimal
-from django.db.backends.postgresql.psycopg_any import NumericRange
 from itertools import count, groupby
 from itertools import count, groupby
 
 
+from django.db.backends.postgresql.psycopg_any import NumericRange
+
 __all__ = (
 __all__ = (
     'array_to_ranges',
     'array_to_ranges',
     'array_to_string',
     'array_to_string',
@@ -10,6 +11,7 @@ __all__ = (
     'drange',
     'drange',
     'flatten_dict',
     'flatten_dict',
     'ranges_to_string',
     'ranges_to_string',
+    'ranges_to_string_list',
     'shallow_compare_dict',
     'shallow_compare_dict',
     'string_to_ranges',
     'string_to_ranges',
 )
 )
@@ -73,8 +75,10 @@ def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()):
 def array_to_ranges(array):
 def array_to_ranges(array):
     """
     """
     Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as
     Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as
-    single-item tuples. For example:
-        [0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]"
+    single-item tuples.
+
+    Example:
+        [0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]
     """
     """
     group = (
     group = (
         list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)
         list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)
@@ -87,7 +91,8 @@ def array_to_ranges(array):
 def array_to_string(array):
 def array_to_string(array):
     """
     """
     Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
     Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
-    For example:
+
+    Example:
         [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
         [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
     """
     """
     ret = []
     ret = []
@@ -135,26 +140,60 @@ def check_ranges_overlap(ranges):
     return False
     return False
 
 
 
 
-def ranges_to_string(ranges):
+def ranges_to_string_list(ranges):
     """
     """
-    Generate a human-friendly string from a set of ranges. Intended for use with ArrayField. For example:
-        [[1, 100)], [200, 300)] => "1-99,200-299"
+    Convert numeric ranges to a list of display strings.
+
+    Each range is rendered as "lower-upper" or "lower" (for singletons).
+    Bounds are normalized to inclusive values using ``lower_inc``/``upper_inc``.
+    This underpins ``ranges_to_string()``, which joins the result with commas.
+
+    Example:
+        [NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)] => ["1-5", "8", "10-12"]
     """
     """
     if not ranges:
     if not ranges:
-        return ''
-    output = []
+        return []
+
+    output: list[str] = []
     for r in ranges:
     for r in ranges:
+        # Compute inclusive bounds regardless of how the DB range is stored.
         lower = r.lower if r.lower_inc else r.lower + 1
         lower = r.lower if r.lower_inc else r.lower + 1
         upper = r.upper if r.upper_inc else r.upper - 1
         upper = r.upper if r.upper_inc else r.upper - 1
-        output.append(f'{lower}-{upper}')
-    return ','.join(output)
+        output.append(f"{lower}-{upper}" if lower != upper else str(lower))
+    return output
+
+
+def ranges_to_string(ranges):
+    """
+    Converts a list of ranges into a string representation.
+
+    This function takes a list of range objects and produces a string
+    representation of those ranges. Each range is represented as a
+    hyphen-separated pair of lower and upper bounds, with inclusive or
+    exclusive bounds adjusted accordingly. If the lower and upper bounds
+    of a range are the same, only the single value is added to the string.
+    Intended for use with ArrayField.
+
+    Example:
+        [NumericRange(1, 5), NumericRange(8, 9), NumericRange(10, 12)] => "1-5,8,10-12"
+    """
+    if not ranges:
+        return ''
+    return ','.join(ranges_to_string_list(ranges))
 
 
 
 
 def string_to_ranges(value):
 def string_to_ranges(value):
     """
     """
-    Given a string in the format "1-100, 200-300" return an list of NumericRanges. Intended for use with ArrayField.
-    For example:
-        "1-99,200-299" => [NumericRange(1, 100), NumericRange(200, 300)]
+    Converts a string representation of numeric ranges into a list of NumericRange objects.
+
+    This function parses a string containing numeric values and ranges separated by commas (e.g.,
+    "1-5,8,10-12") and converts it into a list of NumericRange objects.
+    In the case of a single integer, it is treated as a range where the start and end
+    are equal. The returned ranges are represented as half-open intervals [lower, upper).
+    Intended for use with ArrayField.
+
+    Example:
+        "1-5,8,10-12" => [NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)]
     """
     """
     if not value:
     if not value:
         return None
         return None
@@ -172,5 +211,5 @@ def string_to_ranges(value):
             upper = dash_range[1]
             upper = dash_range[1]
         else:
         else:
             return None
             return None
-        values.append(NumericRange(int(lower), int(upper), bounds='[]'))
+        values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)'))
     return values
     return values

+ 55 - 0
netbox/utilities/templatetags/builtins/tags.py

@@ -1,4 +1,8 @@
+import logging
+from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
+
 from django import template
 from django import template
+from django.templatetags.static import static
 
 
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
 from utilities.querydict import dict_to_querydict
 from utilities.querydict import dict_to_querydict
@@ -10,6 +14,7 @@ __all__ = (
     'customfield_value',
     'customfield_value',
     'htmx_table',
     'htmx_table',
     'formaction',
     'formaction',
+    'static_with_params',
     'tag',
     'tag',
 )
 )
 
 
@@ -124,3 +129,53 @@ def formaction(context):
     with 'hx-push-url="true" hx-post' for HTMX navigation.
     with 'hx-push-url="true" hx-post' for HTMX navigation.
     """
     """
     return 'formaction'
     return 'formaction'
+
+
+@register.simple_tag
+def static_with_params(path, **params):
+    """
+    Generate a static URL with properly appended query parameters.
+
+    The original Django static tag doesn't properly handle appending new parameters to URLs
+    that already contain query parameters, which can result in malformed URLs with double
+    question marks. This template tag handles the case where static files are served from
+    AWS S3 or other CDNs that automatically append query parameters to URLs.
+
+    This implementation correctly appends new parameters to existing URLs and checks for
+    parameter conflicts. A warning will be logged if any of the provided parameters
+    conflict with existing parameters in the URL.
+
+    Args:
+        path: The static file path (e.g., 'setmode.js')
+        **params: Query parameters to append (e.g., v='4.3.1')
+
+    Returns:
+        A properly formatted URL with query parameters.
+
+    Note:
+        If any provided parameters conflict with existing URL parameters, a warning
+        will be logged and the new parameter value will override the existing one.
+    """
+    # Get the base static URL
+    static_url = static(path)
+
+    # Parse the URL to extract existing query parameters
+    parsed = urlparse(static_url)
+    existing_params = parse_qs(parsed.query)
+
+    # Check for duplicate parameters and log warnings
+    logger = logging.getLogger('netbox.utilities.templatetags.tags')
+    for key, value in params.items():
+        if key in existing_params:
+            logger.warning(
+                f"Parameter '{key}' already exists in static URL '{static_url}' "
+                f"with value(s) {existing_params[key]}, overwriting with '{value}'"
+            )
+        existing_params[key] = [str(value)]
+
+    # Rebuild the query string
+    new_query = urlencode(existing_params, doseq=True)
+
+    # Reconstruct the URL with the new query string
+    new_parsed = parsed._replace(query=new_query)
+    return urlunparse(new_parsed)

+ 2 - 3
netbox/utilities/tests/test_api.py

@@ -149,14 +149,13 @@ class APIPaginationTestCase(APITestCase):
     def test_default_page_size_with_small_max_page_size(self):
     def test_default_page_size_with_small_max_page_size(self):
         response = self.client.get(self.url, format='json', **self.header)
         response = self.client.get(self.url, format='json', **self.header)
         page_size = get_config().MAX_PAGE_SIZE
         page_size = get_config().MAX_PAGE_SIZE
-        paginate_count = get_config().PAGINATE_COUNT
         self.assertLess(page_size, 100, "Default page size not sufficient for data set")
         self.assertLess(page_size, 100, "Default page size not sufficient for data set")
 
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data['count'], 100)
         self.assertEqual(response.data['count'], 100)
-        self.assertTrue(response.data['next'].endswith(f'?limit={paginate_count}&offset={paginate_count}'))
+        self.assertTrue(response.data['next'].endswith(f'?limit={page_size}&offset={page_size}'))
         self.assertIsNone(response.data['previous'])
         self.assertIsNone(response.data['previous'])
-        self.assertEqual(len(response.data['results']), paginate_count)
+        self.assertEqual(len(response.data['results']), page_size)
 
 
     def test_custom_page_size(self):
     def test_custom_page_size(self):
         response = self.client.get(f'{self.url}?limit=10', format='json', **self.header)
         response = self.client.get(f'{self.url}?limit=10', format='json', **self.header)

+ 25 - 9
netbox/utilities/tests/test_data.py

@@ -1,7 +1,11 @@
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.test import TestCase
 from django.test import TestCase
-
-from utilities.data import check_ranges_overlap, ranges_to_string, string_to_ranges
+from utilities.data import (
+    check_ranges_overlap,
+    ranges_to_string,
+    ranges_to_string_list,
+    string_to_ranges,
+)
 
 
 
 
 class RangeFunctionsTestCase(TestCase):
 class RangeFunctionsTestCase(TestCase):
@@ -47,32 +51,44 @@ class RangeFunctionsTestCase(TestCase):
             ])
             ])
         )
         )
 
 
+    def test_ranges_to_string_list(self):
+        self.assertEqual(
+            ranges_to_string_list([
+                NumericRange(10, 20),    # 10-19
+                NumericRange(30, 40),    # 30-39
+                NumericRange(50, 51),    # 50-50
+                NumericRange(100, 200),  # 100-199
+            ]),
+            ['10-19', '30-39', '50', '100-199']
+        )
+
     def test_ranges_to_string(self):
     def test_ranges_to_string(self):
         self.assertEqual(
         self.assertEqual(
             ranges_to_string([
             ranges_to_string([
                 NumericRange(10, 20),    # 10-19
                 NumericRange(10, 20),    # 10-19
                 NumericRange(30, 40),    # 30-39
                 NumericRange(30, 40),    # 30-39
+                NumericRange(50, 51),    # 50-50
                 NumericRange(100, 200),  # 100-199
                 NumericRange(100, 200),  # 100-199
             ]),
             ]),
-            '10-19,30-39,100-199'
+            '10-19,30-39,50,100-199'
         )
         )
 
 
     def test_string_to_ranges(self):
     def test_string_to_ranges(self):
         self.assertEqual(
         self.assertEqual(
             string_to_ranges('10-19, 30-39, 100-199'),
             string_to_ranges('10-19, 30-39, 100-199'),
             [
             [
-                NumericRange(10, 19, bounds='[]'),    # 10-19
-                NumericRange(30, 39, bounds='[]'),    # 30-39
-                NumericRange(100, 199, bounds='[]'),  # 100-199
+                NumericRange(10, 20, bounds='[)'),    # 10-20
+                NumericRange(30, 40, bounds='[)'),    # 30-40
+                NumericRange(100, 200, bounds='[)'),  # 100-200
             ]
             ]
         )
         )
 
 
         self.assertEqual(
         self.assertEqual(
             string_to_ranges('1-2, 5, 10-12'),
             string_to_ranges('1-2, 5, 10-12'),
             [
             [
-                NumericRange(1, 2, bounds='[]'),    # 1-2
-                NumericRange(5, 5, bounds='[]'),    # 5-5
-                NumericRange(10, 12, bounds='[]'),  # 10-12
+                NumericRange(1, 3, bounds='[)'),    # 1-3
+                NumericRange(5, 6, bounds='[)'),    # 5-6
+                NumericRange(10, 13, bounds='[)'),  # 10-13
             ]
             ]
         )
         )
 
 

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