Sfoglia il codice sorgente

Merge branch 'main' into feature

# Conflicts:
#	contrib/openapi.json
#	docs/release-notes/version-4.6.md
#	netbox/dcim/choices.py
#	netbox/dcim/forms/mixins.py
#	netbox/dcim/models/device_component_templates.py
#	netbox/dcim/models/device_components.py
#	netbox/extras/dashboard/widgets.py
#	netbox/extras/graphql/types.py
#	netbox/extras/models/configs.py
#	netbox/extras/tests/test_templatetags.py
#	netbox/ipam/choices.py
#	netbox/ipam/forms/model_forms.py
#	netbox/netbox/configuration_example.py
#	netbox/netbox/filtersets.py
#	netbox/netbox/tests/test_api.py
#	netbox/netbox/tests/test_scaffold.py
#	netbox/netbox/tests/test_tables.py
#	netbox/project-static/dist/netbox.js
#	netbox/project-static/dist/netbox.js.map
#	netbox/project-static/package.json
#	netbox/project-static/yarn.lock
#	netbox/release.yaml
#	netbox/translations/cs/LC_MESSAGES/django.mo
#	netbox/translations/cs/LC_MESSAGES/django.po
#	netbox/translations/da/LC_MESSAGES/django.mo
#	netbox/translations/da/LC_MESSAGES/django.po
#	netbox/translations/de/LC_MESSAGES/django.mo
#	netbox/translations/de/LC_MESSAGES/django.po
#	netbox/translations/en/LC_MESSAGES/django.po
#	netbox/translations/es/LC_MESSAGES/django.mo
#	netbox/translations/es/LC_MESSAGES/django.po
#	netbox/translations/fr/LC_MESSAGES/django.mo
#	netbox/translations/fr/LC_MESSAGES/django.po
#	netbox/translations/it/LC_MESSAGES/django.mo
#	netbox/translations/it/LC_MESSAGES/django.po
#	netbox/translations/ja/LC_MESSAGES/django.mo
#	netbox/translations/ja/LC_MESSAGES/django.po
#	netbox/translations/ko/LC_MESSAGES/django.mo
#	netbox/translations/ko/LC_MESSAGES/django.po
#	netbox/translations/lv/LC_MESSAGES/django.mo
#	netbox/translations/lv/LC_MESSAGES/django.po
#	netbox/translations/nl/LC_MESSAGES/django.mo
#	netbox/translations/nl/LC_MESSAGES/django.po
#	netbox/translations/pl/LC_MESSAGES/django.mo
#	netbox/translations/pl/LC_MESSAGES/django.po
#	netbox/translations/pt/LC_MESSAGES/django.mo
#	netbox/translations/pt/LC_MESSAGES/django.po
#	netbox/translations/ru/LC_MESSAGES/django.mo
#	netbox/translations/ru/LC_MESSAGES/django.po
#	netbox/translations/tr/LC_MESSAGES/django.mo
#	netbox/translations/tr/LC_MESSAGES/django.po
#	netbox/translations/uk/LC_MESSAGES/django.mo
#	netbox/translations/uk/LC_MESSAGES/django.po
#	netbox/translations/zh/LC_MESSAGES/django.mo
#	netbox/translations/zh/LC_MESSAGES/django.po
#	netbox/utilities/jinja2.py
#	netbox/utilities/tests/test_filters.py
#	requirements.txt
Jeremy Stretch 3 settimane fa
parent
commit
d2df19790f
100 ha cambiato i file con 6030 aggiunte e 4263 eliminazioni
  1. 606 14
      contrib/openapi.json
  2. 4 0
      docs/administration/permissions.md
  3. 10 0
      docs/integrations/webhooks.md
  4. 7 0
      docs/models/extras/webhook.md
  5. 17 0
      docs/reference/filtering.md
  6. 48 0
      docs/release-notes/version-4.6.md
  7. 30 0
      netbox/circuits/migrations/0058_clear_stale_cable_profile_data.py
  8. 23 0
      netbox/circuits/migrations/0059_nullify_empty_cable_end.py
  9. 2 2
      netbox/circuits/migrations/0060_denormalization_triggers.py
  10. 4 1
      netbox/core/tests/test_api.py
  11. 5 2
      netbox/core/tests/test_views.py
  12. 2 1
      netbox/core/views.py
  13. 1 1
      netbox/dcim/forms/bulk_create.py
  14. 89 1
      netbox/dcim/forms/bulk_import.py
  15. 42 0
      netbox/dcim/migrations/0240_clear_stale_cable_profile_data.py
  16. 35 0
      netbox/dcim/migrations/0241_nullify_empty_cable_end.py
  17. 1 1
      netbox/dcim/migrations/0242_ltree_paths.py
  18. 1 1
      netbox/dcim/migrations/0243_denormalization_triggers.py
  19. 1 1
      netbox/dcim/migrations/0244_device__config_context_data.py
  20. 1 1
      netbox/dcim/migrations/0245_consolidate_unique_constraints.py
  21. 1 1
      netbox/dcim/migrations/0246_add_devicetype_end_of_life.py
  22. 1 1
      netbox/dcim/migrations/0247_modulebaytype.py
  23. 1 1
      netbox/dcim/migrations/0248_interfacetemplate_channels.py
  24. 1 1
      netbox/dcim/migrations/0249_interface_channels.py
  25. 8 5
      netbox/dcim/models/devices.py
  26. 3 0
      netbox/dcim/search.py
  27. 6 1
      netbox/dcim/signals.py
  28. 2 2
      netbox/dcim/tests/test_cablepaths.py
  29. 98 0
      netbox/dcim/tests/test_models.py
  30. 34 4
      netbox/dcim/tests/test_signals.py
  31. 110 4
      netbox/dcim/tests/test_views.py
  32. 3 1
      netbox/extras/models/mixins.py
  33. 9 3
      netbox/extras/models/models.py
  34. 76 1
      netbox/extras/tests/test_event_rules.py
  35. 11 0
      netbox/extras/tests/test_jobs.py
  36. 43 1
      netbox/extras/tests/test_models.py
  37. 25 0
      netbox/extras/tests/test_views.py
  38. 4 2
      netbox/extras/views.py
  39. 2 2
      netbox/ipam/api/field_serializers.py
  40. 8 0
      netbox/ipam/filtersets.py
  41. 11 3
      netbox/ipam/forms/model_forms.py
  42. 1 1
      netbox/ipam/migrations/0094_denormalization_triggers.py
  43. 17 0
      netbox/ipam/querysets.py
  44. 2 0
      netbox/ipam/search.py
  45. 26 0
      netbox/ipam/tests/test_api.py
  46. 5 0
      netbox/ipam/tests/test_filtersets.py
  47. 11 3
      netbox/ipam/tests/test_forms.py
  48. 2 2
      netbox/netbox/api/fields.py
  49. 15 2
      netbox/netbox/filtersets.py
  50. 18 4
      netbox/netbox/graphql/filter_lookups.py
  51. 3 1
      netbox/netbox/models/deletion.py
  52. 30 2
      netbox/netbox/tables/columns.py
  53. 60 1
      netbox/netbox/tests/test_api.py
  54. 73 1
      netbox/netbox/tests/test_graphql.py
  55. 5 0
      netbox/netbox/tests/test_scaffold.py
  56. 122 11
      netbox/netbox/tests/test_tables.py
  57. 2 1
      netbox/netbox/views/generic/bulk_views.py
  58. 6 5
      netbox/project-static/package.json
  59. 95 97
      netbox/project-static/yarn.lock
  60. 2 2
      netbox/release.yaml
  61. 13 3
      netbox/templates/dcim/htmx/cable_edit.html
  62. 1 1
      netbox/templates/inc/table_controls_htmx.html
  63. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  64. 251 251
      netbox/translations/cs/LC_MESSAGES/django.po
  65. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  66. 233 233
      netbox/translations/da/LC_MESSAGES/django.po
  67. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  68. 235 235
      netbox/translations/de/LC_MESSAGES/django.po
  69. 175 172
      netbox/translations/en/LC_MESSAGES/django.po
  70. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  71. 233 233
      netbox/translations/es/LC_MESSAGES/django.po
  72. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  73. 233 233
      netbox/translations/fr/LC_MESSAGES/django.po
  74. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  75. 233 233
      netbox/translations/it/LC_MESSAGES/django.po
  76. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  77. 252 252
      netbox/translations/ja/LC_MESSAGES/django.po
  78. BIN
      netbox/translations/ko/LC_MESSAGES/django.mo
  79. 252 252
      netbox/translations/ko/LC_MESSAGES/django.po
  80. BIN
      netbox/translations/lv/LC_MESSAGES/django.mo
  81. 233 233
      netbox/translations/lv/LC_MESSAGES/django.po
  82. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  83. 233 233
      netbox/translations/nl/LC_MESSAGES/django.po
  84. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  85. 233 233
      netbox/translations/pl/LC_MESSAGES/django.po
  86. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  87. 233 233
      netbox/translations/pt/LC_MESSAGES/django.po
  88. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  89. 251 251
      netbox/translations/ru/LC_MESSAGES/django.po
  90. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  91. 251 251
      netbox/translations/tr/LC_MESSAGES/django.po
  92. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  93. 233 233
      netbox/translations/uk/LC_MESSAGES/django.po
  94. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  95. 294 294
      netbox/translations/zh/LC_MESSAGES/django.po
  96. 5 0
      netbox/utilities/constants.py
  97. 43 6
      netbox/utilities/counters.py
  98. 2 1
      netbox/utilities/export.py
  99. 1 0
      netbox/utilities/forms/mixins.py
  100. 26 5
      netbox/utilities/jinja2.py

File diff suppressed because it is too large
+ 606 - 14
contrib/openapi.json


+ 4 - 0
docs/administration/permissions.md

@@ -31,6 +31,9 @@ Some models have registered actions that appear as checkboxes in the "Actions" s
 
 Constraints are expressed as a JSON object or list representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below.
 
+!!! note
+    Constraint definitions must be valid JSON. Because a backslash (`\`) is an escape character in a JSON string, a backslash that is part of a string value must itself be escaped. For example, a regular expression containing `\.` must be entered as `\\.` in the constraint definition.
+
 All attributes defined within a single JSON object are applied with a logical AND. For example, suppose you assign a permission for the site model with the following constraints.
 
 ```json
@@ -83,6 +86,7 @@ While permissions are typically assigned to specific groups and/or users, it is
 | `{"status": "active", "role": "testing"}` | Status is active **AND** role is testing |
 | `{"name__startswith": "Foo"}` | Name starts with "Foo" (case-sensitive) |
 | `{"name__iendswith": "bar"}` | Name ends with "bar" (case-insensitive) |
+| `{"name__regex": "^foo\\.bar$"}` | Name matches the regular expression `^foo\.bar$` |
 | `{"vid__gte": 100, "vid__lt": 200}` | VLAN ID is greater than or equal to 100 **AND** less than 200 |
 | `[{"vid__lt": 200}, {"status": "reserved"}]` | VLAN ID is less than 200 **OR** status is reserved |
 

+ 10 - 0
docs/integrations/webhooks.md

@@ -36,6 +36,16 @@ The following data is available as context for Jinja2 templates:
 * `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
 * `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
 
+### Sanitizing Header Values
+
+When rendering the `additional_headers` field, a `header_safe` filter is made available for sanitizing a value for safe inclusion in a raw HTTP header. It strips newlines and other control characters from the rendered value, preventing HTTP header (CR/LF) injection.
+
+Whenever a header value incorporates data which may be influenced by other users (such as an object's attributes), pass it through this filter to avoid smuggling of additional headers. For example:
+
+```
+X-Object-Name: {{ data.name | header_safe }}
+```
+
 ### Default Request Body
 
 If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows:

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

@@ -52,6 +52,13 @@ The content type to indicate in the outgoing HTTP request header. See [this list
 
 Any additional header to include with the outgoing HTTP request. These should be defined in the format `Name: Value`, with each header on a separate line. Jinja2 templating is supported for this field.
 
+!!! warning "Sanitize interpolated header values"
+    When interpolating data which may be influenced by other users (such as object attributes) into a header value, apply the `header_safe` filter to guard against HTTP header (CR/LF) injection. This filter strips newlines and other control characters which could otherwise be used to smuggle additional headers into the request. For example:
+
+    ```
+    X-Object-Name: {{ data.name | header_safe }}
+    ```
+
 ### Body Template
 
 Jinja2 template for a custom request body, if desired. If not defined, NetBox will populate the request body with a raw dump of the webhook context.

+ 17 - 0
docs/reference/filtering.md

@@ -110,6 +110,23 @@ expression: `n`. Here is an example of a lookup expression on a foreign key, it
 GET /api/ipam/vlans/?group_id__n=3203
 ```
 
+### Tags
+
+The `tag` and `tag_id` filters support negation (`n`) as well as an `any` lookup expression:
+
+| Filter | Description                                       |
+|--------|----------------------------------------------------|
+| `n`    | Does not have any of these tags                    |
+| `any`  | Has any of these tags (logical OR)                  |
+
+Passing multiple values for `tag`/`tag_id` without a lookup expression uses a logical AND: `GET /api/dcim/sites/?tag=foo&tag=bar` returns only sites tagged with both `foo` _and_ `bar`. To instead match sites tagged with `foo` _or_ `bar`, use the `any` lookup expression:
+
+```no-highlight
+GET /api/dcim/sites/?tag__any=foo&tag__any=bar
+```
+
+Note that `n` is not the logical complement of the default (AND) behavior: passing multiple values applies NOR logic, matching only objects which have _none_ of the specified tags, rather than objects which are simply missing at least one of them.
+
 ## Ordering Objects
 
 To order results by a particular field, include the `ordering` query parameter. For example, order the list of sites according to their facility values:

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

@@ -1,5 +1,53 @@
 # NetBox v4.6
 
+## v4.6.6 (2026-07-28)
+
+### Enhancements
+
+* [#19273](https://github.com/netbox-community/netbox/issues/19273) - Enable the selection of VLANs scoped to a device's cluster or cluster group when assigning VLANs to interfaces
+* [#22522](https://github.com/netbox-community/netbox/issues/22522) - Render colored badges for custom field choices in tables
+* [#22623](https://github.com/netbox-community/netbox/issues/22623) - Change the default color of the DHCP IP address status from green to purple to distinguish it from "available"
+* [#22685](https://github.com/netbox-community/netbox/issues/22685) - Introduce an "any" lookup for the `tag` and `tag_id` filters to match objects assigned any of the specified tags
+* [#22753](https://github.com/netbox-community/netbox/issues/22753) - Add a `header_safe` Jinja2 filter for sanitizing HTTP header values
+
+### Performance Improvements
+
+* [#22497](https://github.com/netbox-community/netbox/issues/22497) - Improve the speed of bulk object deletion by avoiding per-object cascade handling and N+1 counter updates
+* [#22687](https://github.com/netbox-community/netbox/issues/22687) - Avoid an unnecessary queryset evaluation when rendering export templates
+
+### Bug Fixes
+
+* [#21988](https://github.com/netbox-community/netbox/issues/21988) - Ensure view permissions are enforced when referencing a related object by its attributes in the REST API
+* [#22513](https://github.com/netbox-community/netbox/issues/22513) - Make `JournalEntry.created_by` immutable after creation to prevent audit trail spoofing
+* [#22565](https://github.com/netbox-community/netbox/issues/22565) - Include circuit distance when calculating the total length of a cable path
+* [#22588](https://github.com/netbox-community/netbox/issues/22588) - Restrict the VLANs available for assignment to a prefix scoped to a site group
+* [#22644](https://github.com/netbox-community/netbox/issues/22644) - Record changes to front/rear port mappings in the changelog
+* [#22654](https://github.com/netbox-community/netbox/issues/22654) - Redact server filesystem paths from tracebacks rendered by ConfigTemplate debug mode
+* [#22656](https://github.com/netbox-community/netbox/issues/22656) - Pre-populate interface attributes when using "Create & Add Another"
+* [#22662](https://github.com/netbox-community/netbox/issues/22662) - Avoid raising a `DataError` exception when a cable length exceeds the maximum supported value
+* [#22675](https://github.com/netbox-community/netbox/issues/22675) - Validate the URL scheme of RSS feed entries to prevent DOM-based cross-site scripting
+* [#22677](https://github.com/netbox-community/netbox/issues/22677) - Display validation errors for form fields which lack HTML5 constraints
+* [#22682](https://github.com/netbox-community/netbox/issues/22682) - Prevent the deletion of a site group from cascading to prefixes scoped to its member sites
+* [#22690](https://github.com/netbox-community/netbox/issues/22690) - Restore the left border on the quick search field
+* [#22697](https://github.com/netbox-community/netbox/issues/22697) - Return to the scripts list when cancelling out of the "add script" form
+* [#22707](https://github.com/netbox-community/netbox/issues/22707) - Fix the resolution of port mappings when `{vc_position}` is used on device type component templates
+* [#22712](https://github.com/netbox-community/netbox/issues/22712) - Highlight relevant dropdown fields when form validation fails
+* [#22717](https://github.com/netbox-community/netbox/issues/22717) - Fix `KeyError` raised when validating a device assigned to a cluster scoped to a different location
+* [#22719](https://github.com/netbox-community/netbox/issues/22719) - Avoid raising a `KeyError` for malformed IP address and prefix values submitted via the REST API
+* [#22720](https://github.com/netbox-community/netbox/issues/22720) - Raise a protected-deletion error rather than a `TypeError` when deleting a virtual chassis with a cross-chassis LAG
+* [#22729](https://github.com/netbox-community/netbox/issues/22729) - Escape object names when populating the `Content-Disposition` header of file responses
+* [#22736](https://github.com/netbox-community/netbox/issues/22736) - Include the `comments` field of ASNs in the global search index
+* [#22737](https://github.com/netbox-community/netbox/issues/22737) - Clear stale connector metadata from cable endpoints when deleting a profiled cable
+* [#22748](https://github.com/netbox-community/netbox/issues/22748) - Ensure `ContentTypeField` respects its declared queryset to prevent the selection of non-public object types
+* [#22752](https://github.com/netbox-community/netbox/issues/22752) - Restore the rear port fields on the front port bulk import form
+* [#22766](https://github.com/netbox-community/netbox/issues/22766) - Fix the GraphQL `length` lookup for array filters
+* [#22767](https://github.com/netbox-community/netbox/issues/22767) - Include the `comments` field of several models in the global search indexes
+* [#22768](https://github.com/netbox-community/netbox/issues/22768) - Store a null value rather than an empty string for `cable_end` when removing a cable
+* [#22773](https://github.com/netbox-community/netbox/issues/22773) - Fix `TypeError` exception when bulk adding module bays to devices
+* [#22790](https://github.com/netbox-community/netbox/issues/22790) - Enforce saved filter visibility when applied via the `filter` or `filter_id` query parameter
+
+---
+
 ## v4.6.5 (2026-07-14)
 
 ### Enhancements

+ 30 - 0
netbox/circuits/migrations/0058_clear_stale_cable_profile_data.py

@@ -0,0 +1,30 @@
+from django.db import migrations
+from django.db.models import Q
+
+
+def clear_stale_cable_profile_data(apps, schema_editor):
+    """
+    Clear cached cable connector and position data from circuit terminations which no
+    longer have a cable attached. Earlier versions failed to clear these values when a
+    profiled cable was deleted, causing subsequent validation to fail.
+    """
+    CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
+    db_alias = schema_editor.connection.alias
+
+    CircuitTermination.objects.using(db_alias).filter(
+        Q(cable_connector__isnull=False) | Q(cable_positions__isnull=False),
+        cable__isnull=True,
+    ).update(
+        cable_connector=None,
+        cable_positions=None,
+    )
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('circuits', '0057_default_ordering_indexes'),
+    ]
+
+    operations = [
+        migrations.RunPython(clear_stale_cable_profile_data, migrations.RunPython.noop),
+    ]

+ 23 - 0
netbox/circuits/migrations/0059_nullify_empty_cable_end.py

@@ -0,0 +1,23 @@
+from django.db import migrations
+
+
+def nullify_empty_cable_end(apps, schema_editor):
+    """
+    Replace empty strings with null values on cached cable end data. Earlier versions
+    wrote an empty string when a cable termination was deleted, leaving disconnected
+    terminations inconsistent with those which have never been cabled.
+    """
+    CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
+    db_alias = schema_editor.connection.alias
+
+    CircuitTermination.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('circuits', '0058_clear_stale_cable_profile_data'),
+    ]
+
+    operations = [
+        migrations.RunPython(nullify_empty_cable_end, migrations.RunPython.noop),
+    ]

+ 2 - 2
netbox/circuits/migrations/0058_denormalization_triggers.py → netbox/circuits/migrations/0060_denormalization_triggers.py

@@ -10,9 +10,9 @@ from utilities.migration import InstallDenormalizationTrigger
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('circuits', '0057_default_ordering_indexes'),
+        ('circuits', '0059_nullify_empty_cable_end'),
         # Source tables (dcim_site, dcim_location) must already exist.
-        ('dcim', '0240_ltree_paths'),
+        ('dcim', '0242_ltree_paths'),
     ]
 
     operations = [

+ 4 - 1
netbox/core/tests/test_api.py

@@ -387,7 +387,10 @@ class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
         queue = get_queue('default')
         worker = get_worker('default')
         job = queue.enqueue(self.dummy_job_default)
-        worker.prepare_job_execution(job)
+        # prepare_job_execution() invokes the worker heartbeat, which logs a "re-registering"
+        # warning for this freshly-created (unregistered) worker; suppress the expected noise.
+        with disable_logging():
+            worker.prepare_job_execution(job)
         url = reverse('core-api:rqtask-stop', args=[job.id])
         self.assertEqual(job.get_status(), JobStatus.STARTED)
 

+ 5 - 2
netbox/core/tests/test_views.py

@@ -423,8 +423,11 @@ class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
 
         worker = get_worker('default')
         job = queue.enqueue(self.dummy_job_default)
-        worker.prepare_job_execution(job)
-        worker.prepare_execution(job)
+        # prepare_job_execution() invokes the worker heartbeat, which logs a "re-registering"
+        # warning for this freshly-created (unregistered) worker; suppress the expected noise.
+        with disable_logging():
+            worker.prepare_job_execution(job)
+            worker.prepare_execution(job)
 
         self.assertEqual(job.get_status(), JobStatus.STARTED)
 

+ 2 - 1
netbox/core/views.py

@@ -12,6 +12,7 @@ from django.db import DatabaseError, connection
 from django.http import Http404, HttpResponse, HttpResponseForbidden
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
+from django.utils.http import content_disposition_header
 from django.utils.translation import gettext_lazy as _
 from django.views.generic import View
 from django_rq.queues import get_queue_by_index, get_redis_connection
@@ -767,7 +768,7 @@ class SystemView(UserPassesTestMixin, View):
                 },
             }
             response = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
-            response['Content-Disposition'] = 'attachment; filename="netbox.json"'
+            response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename='netbox.json')
             return response
 
         # Serialize any JSON-based classes

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

@@ -115,7 +115,7 @@ class ModuleBayBulkCreateForm(
 ):
     model = ModuleBay
     field_order = ('name', 'label', 'position', 'enabled', 'description', 'tags')
-    replication_fields = ('name', 'label', 'position', 'enabled')
+    replication_fields = ('name', 'label', 'position')
     position = ExpandableNameField(
         label=_('Position'),
         required=False,

+ 89 - 1
netbox/dcim/forms/bulk_import.py

@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
 from dcim.choices import *
 from dcim.constants import *
 from dcim.models import *
+from dcim.utils import reconcile_port_mappings
 from extras.models import ConfigTemplate
 from ipam.choices import VLANQinQRoleChoices
 from ipam.models import VLAN, VRF, IPAddress, VLANGroup
@@ -1135,13 +1136,100 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         choices=PortTypeChoices,
         help_text=_('Physical medium classification')
     )
+    rear_port = CSVModelChoiceField(
+        label=_('Rear port'),
+        queryset=RearPort.objects.all(),
+        to_field_name='name',
+        help_text=_('Corresponding rear port (mapped to the front port\'s first position)')
+    )
+    rear_port_position = forms.IntegerField(
+        label=_('Rear port position'),
+        required=False,
+        help_text=_('Mapped position on the corresponding rear port (defaults to 1)')
+    )
 
     class Meta:
         model = FrontPort
         fields = (
-            'device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description', 'owner', 'tags'
+            'device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'rear_port',
+            'rear_port_position', 'description', 'owner', 'tags'
         )
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit RearPort choices to those belonging to this device (or VC master)
+        if self.is_bound and 'device' in self.data:
+            try:
+                device = self.fields['device'].to_python(self.data['device'])
+            except forms.ValidationError:
+                device = None
+        else:
+            try:
+                device = self.instance.device
+            except Device.DoesNotExist:
+                device = None
+
+        if device:
+            self.fields['rear_port'].queryset = RearPort.objects.filter(
+                device__in=[device, device.get_vc_master()]
+            )
+        else:
+            self.fields['rear_port'].queryset = RearPort.objects.none()
+
+    def clean(self):
+        super().clean()
+
+        rear_port = self.cleaned_data.get('rear_port')
+        rear_port_position = self.cleaned_data.get('rear_port_position') or 1
+        if not rear_port:
+            return
+
+        # Validate the rear port position against the selected rear port
+        if rear_port_position > rear_port.positions:
+            raise forms.ValidationError({
+                'rear_port_position': _(
+                    "Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
+                    "positions."
+                ).format(
+                    rear_port_position=rear_port_position,
+                    name=rear_port.name,
+                    positions=rear_port.positions
+                )
+            })
+
+        # Ensure the target rear port position isn't already occupied. reconcile_port_mappings() creates the
+        # mapping via create() (bypassing validate_unique()), so without this check a collision would surface
+        # as an uncaught IntegrityError (HTTP 500) rather than a row-level validation error.
+        occupied = PortMapping.objects.filter(
+            rear_port=rear_port, rear_port_position=rear_port_position
+        ).exclude(front_port=self.instance.pk)
+        if occupied.exists():
+            raise forms.ValidationError({
+                'rear_port_position': _(
+                    "Rear port {name} position {rear_port_position} is already occupied."
+                ).format(
+                    name=rear_port.name,
+                    rear_port_position=rear_port_position
+                )
+            })
+
+    def _save_m2m(self):
+        super()._save_m2m()
+
+        # Map the front port's first position to the specified rear port & position
+        if rear_port := self.cleaned_data.get('rear_port'):
+            reconcile_port_mappings(
+                PortMapping,
+                parent_field='front_port',
+                parent=self.instance,
+                desired=[{
+                    'front_port_position': 1,
+                    'rear_port_id': rear_port.pk,
+                    'rear_port_position': self.cleaned_data.get('rear_port_position') or 1,
+                }],
+            )
+
 
 class RearPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
     device = CSVModelChoiceField(

+ 42 - 0
netbox/dcim/migrations/0240_clear_stale_cable_profile_data.py

@@ -0,0 +1,42 @@
+from django.db import migrations
+from django.db.models import Q
+
+CABLED_MODELS = (
+    'ConsolePort',
+    'ConsoleServerPort',
+    'FrontPort',
+    'Interface',
+    'PowerFeed',
+    'PowerOutlet',
+    'PowerPort',
+    'RearPort',
+)
+
+
+def clear_stale_cable_profile_data(apps, schema_editor):
+    """
+    Clear cached cable connector and position data from endpoints which no longer have
+    a cable attached. Earlier versions failed to clear these values when a profiled
+    cable was deleted, causing subsequent validation of the endpoint to fail.
+    """
+    db_alias = schema_editor.connection.alias
+
+    for model_name in CABLED_MODELS:
+        model = apps.get_model('dcim', model_name)
+        model.objects.using(db_alias).filter(
+            Q(cable_connector__isnull=False) | Q(cable_positions__isnull=False),
+            cable__isnull=True,
+        ).update(
+            cable_connector=None,
+            cable_positions=None,
+        )
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0239_add_portmapping_objectchange'),
+    ]
+
+    operations = [
+        migrations.RunPython(clear_stale_cable_profile_data, migrations.RunPython.noop),
+    ]

+ 35 - 0
netbox/dcim/migrations/0241_nullify_empty_cable_end.py

@@ -0,0 +1,35 @@
+from django.db import migrations
+
+CABLED_MODELS = (
+    'ConsolePort',
+    'ConsoleServerPort',
+    'FrontPort',
+    'Interface',
+    'PowerFeed',
+    'PowerOutlet',
+    'PowerPort',
+    'RearPort',
+)
+
+
+def nullify_empty_cable_end(apps, schema_editor):
+    """
+    Replace empty strings with null values on cached cable end data. Earlier versions
+    wrote an empty string when a cable termination was deleted, leaving disconnected
+    endpoints inconsistent with those which have never been cabled.
+    """
+    db_alias = schema_editor.connection.alias
+
+    for model_name in CABLED_MODELS:
+        model = apps.get_model('dcim', model_name)
+        model.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0240_clear_stale_cable_profile_data'),
+    ]
+
+    operations = [
+        migrations.RunPython(nullify_empty_cable_end, migrations.RunPython.noop),
+    ]

+ 1 - 1
netbox/dcim/migrations/0240_ltree_paths.py → netbox/dcim/migrations/0242_ltree_paths.py

@@ -77,7 +77,7 @@ LEGACY_FIELDS = ('lft', 'rght', 'tree_id', 'level')
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0239_add_portmapping_objectchange'),
+        ('dcim', '0241_nullify_empty_cable_end'),
     ]
 
     operations = [

+ 1 - 1
netbox/dcim/migrations/0241_denormalization_triggers.py → netbox/dcim/migrations/0243_denormalization_triggers.py

@@ -30,7 +30,7 @@ COMPONENT_TABLES = (
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0240_ltree_paths'),
+        ('dcim', '0242_ltree_paths'),
     ]
 
     operations = [

+ 1 - 1
netbox/dcim/migrations/0242_device__config_context_data.py → netbox/dcim/migrations/0244_device__config_context_data.py

@@ -3,7 +3,7 @@ from django.db import migrations, models
 
 class Migration(migrations.Migration):
     dependencies = [
-        ('dcim', '0241_denormalization_triggers'),
+        ('dcim', '0243_denormalization_triggers'),
     ]
 
     operations = [

+ 1 - 1
netbox/dcim/migrations/0243_consolidate_unique_constraints.py → netbox/dcim/migrations/0245_consolidate_unique_constraints.py

@@ -4,7 +4,7 @@ from django.db import migrations, models
 
 class Migration(migrations.Migration):
     dependencies = [
-        ('dcim', '0242_device__config_context_data'),
+        ('dcim', '0244_device__config_context_data'),
         ('extras', '0139_alter_customfieldchoiceset_extra_choices'),
         ('tenancy', '0025_ltree_paths'),
         ('users', '0016_default_ordering_indexes'),

+ 1 - 1
netbox/dcim/migrations/0244_add_devicetype_end_of_life.py → netbox/dcim/migrations/0246_add_devicetype_end_of_life.py

@@ -4,7 +4,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ("dcim", "0243_consolidate_unique_constraints"),
+        ("dcim", "0245_consolidate_unique_constraints"),
     ]
 
     operations = [

+ 1 - 1
netbox/dcim/migrations/0245_modulebaytype.py → netbox/dcim/migrations/0247_modulebaytype.py

@@ -10,7 +10,7 @@ import utilities.json
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0244_add_devicetype_end_of_life'),
+        ('dcim', '0246_add_devicetype_end_of_life'),
         ('extras', '0141_custom_field_nulls_first'),
         ('users', '0016_default_ordering_indexes'),
     ]

+ 1 - 1
netbox/dcim/migrations/0246_interfacetemplate_channels.py → netbox/dcim/migrations/0248_interfacetemplate_channels.py

@@ -5,7 +5,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0245_modulebaytype'),
+        ('dcim', '0247_modulebaytype'),
     ]
 
     operations = [

+ 1 - 1
netbox/dcim/migrations/0247_interface_channels.py → netbox/dcim/migrations/0249_interface_channels.py

@@ -5,7 +5,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0246_interfacetemplate_channels'),
+        ('dcim', '0248_interfacetemplate_channels'),
     ]
 
     operations = [

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

@@ -946,7 +946,7 @@ class Device(
         if self.cluster and self.cluster._location is not None and self.cluster._location != self.location:
             raise ValidationError({
                 'cluster': _("The assigned cluster belongs to a different location ({location})").format(
-                    site=self.cluster._location
+                    location=self.cluster._location
                 )
             })
 
@@ -1235,10 +1235,13 @@ class VirtualChassis(PrimaryModel):
             lag__device=F('device')
         )
         if interfaces:
-            raise ProtectedError(_(
-                "Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG "
-                "interfaces."
-            ).format(self=self, interfaces=InterfaceSpeedChoices))
+            raise ProtectedError(
+                _(
+                    "Unable to delete virtual chassis {virtual_chassis}. One or more member interfaces form a "
+                    "cross-chassis LAG."
+                ).format(virtual_chassis=self),
+                set(interfaces),
+            )
 
         # Clear vc_position and vc_priority on member devices BEFORE calling super().delete()
         # This must be done here because on_delete=SET_NULL executes before pre_delete signal

+ 3 - 0
netbox/dcim/search.py

@@ -84,6 +84,7 @@ class DeviceRoleIndex(SearchIndex):
         ('name', 100),
         ('slug', 110),
         ('description', 500),
+        ('comments', 5000),
     )
     display_attrs = ('description',)
 
@@ -117,6 +118,7 @@ class MACAddressIndex(SearchIndex):
     fields = (
         ('mac_address', 100),
         ('description', 500),
+        ('comments', 5000),
     )
     display_attrs = ('assigned_object', 'description')
 
@@ -251,6 +253,7 @@ class PlatformIndex(SearchIndex):
         ('name', 100),
         ('slug', 110),
         ('description', 500),
+        ('comments', 5000),
     )
     display_attrs = ('manufacturer', 'description')
 

+ 6 - 1
netbox/dcim/signals.py

@@ -155,7 +155,12 @@ def nullify_connected_endpoints(instance, **kwargs):
     Disassociate the Cable from the termination object, and retrace any affected CablePaths.
     """
     model = instance.termination_type.model_class()
-    model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
+    model.objects.filter(pk=instance.termination_id).update(
+        cable=None,
+        cable_end=None,
+        cable_connector=None,
+        cable_positions=None,
+    )
 
     # If the removed termination was a channelized interface, also clear the cable attributes mirrored onto its channel
     # subinterfaces. This must happen before the retrace below so that each channel's (now dead) path is torn down

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

@@ -60,11 +60,11 @@ class LegacyCablePathTestCase(BaseCablePathTestCase):
         interface2.refresh_from_db()
 
         self.assertIsNone(interface1.cable_id)
-        self.assertEqual(interface1.cable_end, '')
+        self.assertIsNone(interface1.cable_end)
         self.assertPathIsNotSet(interface1)
 
         self.assertIsNone(interface2.cable_id)
-        self.assertEqual(interface2.cable_end, '')
+        self.assertIsNone(interface2.cable_end)
         self.assertPathIsNotSet(interface2)
 
     def test_102_consoleport_to_consoleserverport(self):

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

@@ -1,6 +1,7 @@
 from decimal import Decimal
 
 from django.core.exceptions import ValidationError
+from django.db.models import ProtectedError
 from django.db.models.signals import post_save
 from django.test import TestCase, tag
 
@@ -855,6 +856,50 @@ class DeviceTestCase(TestCase):
                 cluster=clusters[1]
             ).full_clean()
 
+    @tag('regression')  # Ref: #22717
+    def test_device_mismatched_location_cluster(self):
+        """
+        A cluster scoped to a different location than the device must be rejected
+        with a field validation error naming that location.
+        """
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        locations = (
+            Location(site=site, name='Location A', slug='location-a'),
+            Location(site=site, name='Location B', slug='location-b'),
+        )
+        for location in locations:
+            location.save()
+
+        cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+        cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type, scope=locations[0])
+
+        device_type = DeviceType.objects.first()
+        device_role = DeviceRole.objects.first()
+
+        # Device in the cluster's location should pass
+        Device(
+            name='device1',
+            site=site,
+            location=locations[0],
+            device_type=device_type,
+            role=device_role,
+            cluster=cluster
+        ).full_clean()
+
+        # Device in a different location of the same site should fail
+        with self.assertRaisesMessage(
+            ValidationError,
+            'The assigned cluster belongs to a different location (Location A)'
+        ):
+            Device(
+                name='device1',
+                site=site,
+                location=locations[1],
+                device_type=device_type,
+                role=device_role,
+                cluster=cluster
+            ).full_clean()
+
 
 class DeviceBayTestCase(TestCase):
 
@@ -2573,6 +2618,59 @@ class VirtualChassisTestCase(TestCase):
         self.assertIsNone(device2.vc_position)
         self.assertIsNone(device2.vc_priority)
 
+    @tag('regression')  # Ref: #22720
+    def test_virtualchassis_deletion_blocked_by_cross_chassis_lag(self):
+        """
+        Deleting a VirtualChassis whose members form a cross-chassis LAG must
+        raise ProtectedError exposing the blocking interfaces, leaving the VC
+        and its member assignments unchanged.
+        """
+        device1 = Device.objects.get(name='TestDevice1')
+        device2 = Device.objects.get(name='TestDevice2')
+
+        vc = VirtualChassis.objects.create(name='Test VC', master=device1)
+
+        device1.virtual_chassis = vc
+        device1.vc_position = 1
+        device1.vc_priority = 10
+        device1.save()
+
+        device2.virtual_chassis = vc
+        device2.vc_position = 2
+        device2.vc_priority = 20
+        device2.save()
+
+        lag = Interface.objects.create(device=device1, name='lag0', type=InterfaceTypeChoices.TYPE_LAG)
+        member_interface = Interface(
+            device=device2,
+            name='eth0',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+            lag=lag,
+        )
+        # A cross-chassis LAG member is valid while both devices share the VC
+        member_interface.full_clean()
+        member_interface.save()
+
+        with self.assertRaises(ProtectedError) as cm:
+            vc.delete()
+
+        self.assertEqual(
+            cm.exception.args[0],
+            'Unable to delete virtual chassis Test VC. One or more member interfaces form a cross-chassis LAG.'
+        )
+        self.assertEqual(set(cm.exception.protected_objects), {member_interface})
+
+        # The failed deletion must not clear the VC or its member assignments
+        self.assertTrue(VirtualChassis.objects.filter(pk=vc.pk).exists())
+        device1.refresh_from_db()
+        device2.refresh_from_db()
+        self.assertEqual(device1.virtual_chassis, vc)
+        self.assertEqual(device1.vc_position, 1)
+        self.assertEqual(device1.vc_priority, 10)
+        self.assertEqual(device2.virtual_chassis, vc)
+        self.assertEqual(device2.vc_position, 2)
+        self.assertEqual(device2.vc_priority, 20)
+
     def test_virtualchassis_duplicate_vc_position(self):
         """
         Test that two devices cannot be assigned to the same vc_position

+ 34 - 4
netbox/dcim/tests/test_signals.py

@@ -7,7 +7,7 @@ from django.db import connection
 from django.test import SimpleTestCase, TestCase
 
 from dcim import signals
-from dcim.choices import CableEndChoices, LinkStatusChoices
+from dcim.choices import CableEndChoices, CableProfileChoices, LinkStatusChoices
 from dcim.models import (
     Cable,
     CablePath,
@@ -298,8 +298,38 @@ class CableSignalTestCase(TestCase):
         self.assertIsNone(interface_b._path_id)
         self.assertIsNone(interface_a.cable_id)
         self.assertIsNone(interface_b.cable_id)
-        self.assertEqual(interface_a.cable_end, '')
-        self.assertEqual(interface_b.cable_end, '')
+        self.assertIsNone(interface_a.cable_end)
+        self.assertIsNone(interface_b.cable_end)
+
+    def test_deleting_profiled_cable_nullifies_endpoints(self):
+        """
+        Deleting a profiled cable must clear the cached connector and position data on both endpoints.
+        """
+        interface_a = Interface.objects.create(device=self.device, name='Interface A')
+        interface_b = Interface.objects.create(device=self.device, name='Interface B')
+        cable = Cable(
+            a_terminations=[interface_a],
+            b_terminations=[interface_b],
+            profile=CableProfileChoices.SINGLE_1C1P,
+        )
+        cable.save()
+
+        # Confirm the profile metadata was cached on both endpoints.
+        interface_a.refresh_from_db()
+        interface_b.refresh_from_db()
+        self.assertEqual(interface_a.cable_connector, 1)
+        self.assertEqual(interface_a.cable_positions, [1])
+        self.assertEqual(interface_b.cable_connector, 1)
+        self.assertEqual(interface_b.cable_positions, [1])
+
+        cable.delete()
+
+        for interface in (interface_a, interface_b):
+            interface.refresh_from_db()
+            self.assertIsNone(interface.cable_id)
+            self.assertIsNone(interface.cable_end)
+            self.assertIsNone(interface.cable_connector)
+            self.assertIsNone(interface.cable_positions)
 
     def test_deleting_cable_skips_per_termination_retrace(self):
         """
@@ -363,7 +393,7 @@ class CableSignalTestCase(TestCase):
         termination.delete()
         interface_a.refresh_from_db()
         self.assertIsNone(interface_a.cable_id)
-        self.assertEqual(interface_a.cable_end, '')
+        self.assertIsNone(interface_a.cable_end)
 
 
 class MACAddressInterfaceSignalTestCase(TestCase):

+ 110 - 4
netbox/dcim/tests/test_views.py

@@ -3628,10 +3628,10 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
         }
 
         cls.csv_data = (
-            "device,name,type,positions",
-            "Device 1,Front Port 4,8p8c,1",
-            "Device 1,Front Port 5,8p8c,1",
-            "Device 1,Front Port 6,8p8c,1",
+            "device,name,type,positions,rear_port,rear_port_position",
+            "Device 1,Front Port 4,8p8c,1,Rear Port 4,1",
+            "Device 1,Front Port 5,8p8c,1,Rear Port 5,1",
+            "Device 1,Front Port 6,8p8c,1,Rear Port 6,1",
         )
 
         cls.csv_update_data = (
@@ -3641,6 +3641,49 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             f"{front_ports[2].pk},Front Port 9,New description9",
         )
 
+    def test_bulk_import_objects_with_permission(self):
+        # Importing front ports with a rear_port (and position) should create the corresponding PortMapping
+        def check_port_mappings(scenario_name):
+            front_port = FrontPort.objects.get(name='Front Port 4')
+            mapping = PortMapping.objects.get(front_port=front_port)
+            self.assertEqual(mapping.rear_port.name, 'Rear Port 4')
+            self.assertEqual(mapping.front_port_position, 1)
+            self.assertEqual(mapping.rear_port_position, 1)
+
+        super().test_bulk_import_objects_with_permission(post_import_callback=check_port_mappings)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_import_rear_port_position_exceeds_capacity(self):
+        # A rear_port_position beyond the rear port's capacity is rejected without creating the front port
+        self.add_permissions('dcim.add_frontport')
+        csv_data = (
+            "device,name,type,positions,rear_port,rear_port_position",
+            "Device 1,Front Port 10,8p8c,1,Rear Port 4,2",
+        )
+        response = self.client.post(self._get_url('bulk_import'), {
+            'data': '\n'.join(csv_data),
+            'format': ImportFormatChoices.CSV,
+            'csv_delimiter': CSVDelimiterChoices.AUTO,
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertFalse(FrontPort.objects.filter(name='Front Port 10').exists())
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_import_rear_port_position_occupied(self):
+        # An already-occupied rear port position is rejected (rather than raising an IntegrityError)
+        self.add_permissions('dcim.add_frontport')
+        csv_data = (
+            "device,name,type,positions,rear_port,rear_port_position",
+            "Device 1,Front Port 10,8p8c,1,Rear Port 1,1",
+        )
+        response = self.client.post(self._get_url('bulk_import'), {
+            'data': '\n'.join(csv_data),
+            'format': ImportFormatChoices.CSV,
+            'csv_delimiter': CSVDelimiterChoices.AUTO,
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertFalse(FrontPort.objects.filter(name='Front Port 10').exists())
+
     def test_trace(self):
         self.add_permissions(
             'dcim.view_frontport',
@@ -3783,6 +3826,69 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
             f"{module_bays[2].pk},Module Bay 9,New description9",
         )
 
+    @tag('regression')  # Issue #22773
+    def test_bulk_add_module_bays_to_devices(self):
+        """
+        Bulk-adding module bays expands the name pattern per device and applies enabled to every new bay.
+        """
+        self.add_permissions('dcim.add_modulebay')
+        device1 = Device.objects.get(name='Device 1')
+        device2 = create_test_device('Device 2')
+        initial_count = self._get_queryset().count()
+
+        # An unchecked box is not submitted by the browser at all
+        request = {
+            'path': reverse('dcim:device_bulk_add_modulebay'),
+            'data': post_data({
+                'pk': [device1.pk, device2.pk],
+                'name': 'PCI-Slot[1-2]',
+                '_create': True,
+            }),
+        }
+        response = self.client.post(**request)
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(
+            list(
+                ModuleBay.objects.filter(name__startswith='PCI-Slot')
+                .order_by('device_id', 'name')
+                .values_list('device_id', 'name', 'enabled')
+            ),
+            [
+                (device1.pk, 'PCI-Slot1', False),
+                (device1.pk, 'PCI-Slot2', False),
+                (device2.pk, 'PCI-Slot1', False),
+                (device2.pk, 'PCI-Slot2', False),
+            ]
+        )
+
+        # A checked box applies True to every bay created
+        request = {
+            'path': reverse('dcim:device_bulk_add_modulebay'),
+            'data': post_data({
+                'pk': [device1.pk, device2.pk],
+                'name': 'PSU-Slot[1-2]',
+                'enabled': True,
+                '_create': True,
+            }),
+        }
+        response = self.client.post(**request)
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(
+            list(
+                ModuleBay.objects.filter(name__startswith='PSU-Slot')
+                .order_by('device_id', 'name')
+                .values_list('device_id', 'name', 'enabled')
+            ),
+            [
+                (device1.pk, 'PSU-Slot1', True),
+                (device1.pk, 'PSU-Slot2', True),
+                (device2.pk, 'PSU-Slot1', True),
+                (device2.pk, 'PSU-Slot2', True),
+            ]
+        )
+
+        self.assertEqual(initial_count + 8, self._get_queryset().count())
+
 
 class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = DeviceBay

+ 3 - 1
netbox/extras/models/mixins.py

@@ -9,6 +9,7 @@ from django.core.exceptions import ValidationError
 from django.core.files.storage import storages
 from django.db import models
 from django.http import HttpResponse
+from django.utils.http import content_disposition_header
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
 
@@ -265,6 +266,7 @@ class RenderTemplateMixin(models.Model):
                 filename = filename_from_object(context)
             else:
                 filename = "output"
-            response['Content-Disposition'] = f'attachment; filename="{filename}{extension}"'
+            filename = f'{filename}{extension}'
+            response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
 
         return response

+ 9 - 3
netbox/extras/models/models.py

@@ -34,7 +34,7 @@ from netbox.models.features import (
 )
 from netbox.models.mixins import OwnerMixin
 from utilities.html import clean_html
-from utilities.jinja2 import render_jinja2
+from utilities.jinja2 import render_jinja2, sanitize_http_header
 from utilities.querydict import dict_to_querydict
 from utilities.querysets import RestrictedQuerySet
 from utilities.tables import get_table_for_model
@@ -212,7 +212,9 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, OwnerMixin, Ch
         help_text=_(
             "User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. Headers "
             "should be defined in the format <code>Name: Value</code>. Jinja2 template processing is supported with "
-            "the same context as the request body (below)."
+            "the same context as the request body (below). When interpolating untrusted data (such as object "
+            "attributes) into a header value, apply the <code>header_safe</code> filter to guard against HTTP header "
+            "injection, e.g. <code>X-Object: {{ data.name | header_safe }}</code>."
         )
     )
     body_template = models.TextField(
@@ -297,8 +299,12 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, OwnerMixin, Ch
         if not self.additional_headers:
             return {}
         ret = {}
-        data = render_jinja2(self.additional_headers, context)
+        # Expose the `header_safe` filter so template authors can sanitize interpolated values (e.g. user-controlled
+        # object data) against HTTP header (CR/LF) injection. See utilities.jinja2.sanitize_http_header.
+        data = render_jinja2(self.additional_headers, context, filters={'header_safe': sanitize_http_header})
         for line in data.splitlines():
+            if ':' not in line:
+                continue
             header, value = line.split(':', 1)
             ret[header.strip()] = value.strip()
         return ret

+ 76 - 1
netbox/extras/tests/test_event_rules.py

@@ -8,7 +8,7 @@ from unittest.mock import Mock, patch
 import django_rq
 from django.conf import settings
 from django.http import HttpResponse
-from django.test import RequestFactory, tag
+from django.test import RequestFactory, TestCase, tag
 from django.urls import reverse
 from PIL import Image
 from requests import Session
@@ -711,6 +711,13 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
         Pre-existing non-dict action_data must not cause flush_events() to
         raise.
         """
+        # flush_events() logs a warning about the invalid action_data; mute it so the expected
+        # message doesn't clutter the test runner's output.
+        events_logger = logging.getLogger('netbox.events_processor')
+        original_level = events_logger.level
+        events_logger.setLevel(logging.CRITICAL)
+        self.addCleanup(events_logger.setLevel, original_level)
+
         site_type = ObjectType.objects.get_for_model(Site)
         webhook = Webhook.objects.get(name='Webhook 1')
         webhook_type = ObjectType.objects.get_for_model(Webhook)
@@ -874,3 +881,71 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
         job = self.queue.jobs[0]
         self.assertEqual(job.kwargs['event_rule'], event_rule)
         self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
+
+
+class WebhookRenderHeadersTest(TestCase):
+
+    def test_render_headers(self):
+        """Basic header rendering with Jinja2 interpolation."""
+        webhook = Webhook(
+            name='Webhook 1',
+            payload_url='http://localhost:9000/',
+            additional_headers='X-Foo: Bar\nX-Object: {{ data.name }}',
+        )
+        headers = webhook.render_headers({'data': {'name': 'Site 1'}})
+        self.assertEqual(headers, {'X-Foo': 'Bar', 'X-Object': 'Site 1'})
+
+    def test_render_headers_multiline_block(self):
+        """A multi-line Jinja2 block (e.g. a loop generating headers) must render against the full template."""
+        webhook = Webhook(
+            name='Webhook 1',
+            payload_url='http://localhost:9000/',
+            additional_headers=(
+                '{% for k, v in data.headers.items() %}X-{{ k }}: {{ v }}\n'
+                '{% endfor %}'
+            ),
+        )
+        headers = webhook.render_headers({'data': {'headers': {'Foo': '1', 'Bar': '2'}}})
+        self.assertEqual(headers, {'X-Foo': '1', 'X-Bar': '2'})
+
+    def test_render_headers_skips_blank_lines(self):
+        """Blank lines in the rendered output (e.g. from Jinja2 block tags) must be skipped, not raise."""
+        webhook = Webhook(
+            name='Webhook 1',
+            payload_url='http://localhost:9000/',
+            # Block tags on their own lines leave behind blank lines once rendered
+            additional_headers=(
+                '{% for k, v in data.headers.items() %}\n'
+                'X-{{ k }}: {{ v }}\n'
+                '{% endfor %}'
+            ),
+        )
+        headers = webhook.render_headers({'data': {'headers': {'Foo': '1', 'Bar': '2'}}})
+        self.assertEqual(headers, {'X-Foo': '1', 'X-Bar': '2'})
+
+    def test_render_headers_skips_lines_without_separator(self):
+        """A non-blank line lacking a 'Name: Value' separator must be skipped, not raise."""
+        webhook = Webhook(
+            name='Webhook 1',
+            payload_url='http://localhost:9000/',
+            additional_headers='X-Foo: Bar\nthis line has no colon\nX-Baz: Qux',
+        )
+        headers = webhook.render_headers({})
+        self.assertEqual(headers, {'X-Foo': 'Bar', 'X-Baz': 'Qux'})
+
+    def test_render_headers_header_safe_filter_available(self):
+        """
+        The `header_safe` filter must be available when rendering headers, and must strip control characters
+        (including CR/LF) so that untrusted data cannot smuggle additional headers via CR/LF injection.
+        """
+        webhook = Webhook(
+            name='Webhook 1',
+            payload_url='http://localhost:9000/',
+            additional_headers='X-Object: {{ data.name | header_safe }}',
+        )
+        headers = webhook.render_headers({'data': {'name': 'legit\r\nX-Injected: evil\x00'}})
+
+        # The injected newline is stripped, so only a single (sanitized) header is produced
+        self.assertEqual(list(headers.keys()), ['X-Object'])
+        self.assertNotIn('X-Injected', headers)
+        self.assertEqual(headers['X-Object'], 'legitX-Injected: evil')

+ 11 - 0
netbox/extras/tests/test_jobs.py

@@ -1,3 +1,4 @@
+import logging
 from contextlib import contextmanager
 from types import SimpleNamespace
 from unittest.mock import MagicMock, patch
@@ -45,6 +46,16 @@ class DummyScript:
 
 
 class RunScriptTestCase(TestCase):
+    def setUp(self):
+        super().setUp()
+        # The failure/abort paths log via the module logger `netbox.scripts.<full_name>`. These
+        # tests deliberately exercise those paths, so mute the logger to keep the expected error
+        # messages and tracebacks out of the test runner's output.
+        logger = logging.getLogger('netbox.scripts')
+        original_level = logger.level
+        logger.setLevel(logging.CRITICAL)
+        self.addCleanup(logger.setLevel, original_level)
+
     def test_run_script_success_commit_true_sets_output_and_job_data(self):
         runner = _make_runner()
         script = DummyScript(run_result='hello')

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

@@ -36,7 +36,7 @@ from extras.models import (
 from extras.models.mixins import RenderTemplateMixin
 from tenancy.models import Tenant, TenantGroup
 from utilities.exceptions import AbortRequest
-from utilities.jinja2 import env_filter, render_jinja2
+from utilities.jinja2 import env_filter, render_jinja2, sanitize_http_header
 from utilities.tables import get_table_for_model
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
@@ -1274,6 +1274,48 @@ class JinjaEnvFilterTestCase(TestCase):
             self.assertEqual(output, 'overridden')
 
 
+class SanitizeHTTPHeaderFilterTestCase(TestCase):
+    """
+    Tests for the sanitize_http_header() Jinja2 filter (exposed as `header_safe`) and the render_jinja2()
+    `filters` argument used to make it available.
+    """
+
+    def test_strips_crlf(self):
+        self.assertEqual(sanitize_http_header('legit\r\nX-Injected: evil'), 'legitX-Injected: evil')
+
+    def test_strips_control_characters(self):
+        self.assertEqual(sanitize_http_header('foo\x00\x1f\x7fbar'), 'foobar')
+
+    def test_preserves_normal_value(self):
+        self.assertEqual(sanitize_http_header('application/json'), 'application/json')
+
+    def test_coerces_non_string(self):
+        self.assertEqual(sanitize_http_header(42), '42')
+
+    def test_available_via_render_filters_argument(self):
+        output = render_jinja2(
+            "{{ value | header_safe }}",
+            {'value': 'a\r\nb'},
+            filters={'header_safe': sanitize_http_header},
+        )
+        self.assertEqual(output, 'ab')
+
+    def test_render_filters_take_precedence_over_user_config(self):
+        # A per-render filter cannot be shadowed by a user-configured filter of the same name
+        with self.settings(JINJA_FILTERS={'header_safe': lambda v: 'shadowed'}):
+            output = render_jinja2(
+                "{{ value | header_safe }}",
+                {'value': 'a\r\nb'},
+                filters={'header_safe': sanitize_http_header},
+            )
+            self.assertEqual(output, 'ab')
+
+    def test_not_registered_without_filters_argument(self):
+        # The filter must not leak into general-purpose rendering
+        with self.assertRaises(TemplateError):
+            render_jinja2("{{ 'x' | header_safe }}", {})
+
+
 class ExportTemplateContextTestCase(TestCase):
     """
     Tests for ExportTemplate.get_context() including public model population.

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

@@ -1,3 +1,4 @@
+import logging
 import uuid
 from unittest.mock import PropertyMock, patch
 
@@ -467,6 +468,16 @@ class ImageAttachmentTestCase(
     # placeholder URLs instead of real images on disk.
     model = ImageAttachment
 
+    def setUp(self):
+        super().setUp()
+        # The fixtures use placeholder image URLs with no file on disk, so rendering the thumbnail
+        # column logs a FileNotFoundError traceback for every attachment. The missing files are
+        # expected here, so mute the sorl-thumbnail logger to keep the test output clean.
+        logger = logging.getLogger('sorl.thumbnail')
+        original_level = logger.level
+        logger.setLevel(logging.CRITICAL)
+        self.addCleanup(logger.setLevel, original_level)
+
     @classmethod
     def setUpTestData(cls):
         ct = ContentType.objects.get_for_model(Site)
@@ -1150,6 +1161,20 @@ class ScriptListViewTestCase(TestCase):
         self.assertTemplateUsed(response, 'extras/inc/script_list_content.html')
 
 
+class ScriptModuleCreateViewTestCase(TestCase):
+    user_permissions = ['core.add_managedfile', 'extras.add_scriptmodule']
+
+    @tag('regression')
+    def test_default_return_url(self):
+        """
+        The add view should fall back to the scripts list as its return URL.
+        """
+        response = self.client.get(reverse('extras:scriptmodule_add'))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.context['return_url'], reverse('extras:script_list'))
+
+
 class ScriptValidationErrorTestCase(TestCase):
     user_permissions = ['extras.view_script', 'extras.run_script']
 

+ 4 - 2
netbox/extras/views.py

@@ -9,6 +9,7 @@ from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpRespo
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.utils import timezone
+from django.utils.http import content_disposition_header
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
 from django.views.generic import View
@@ -1273,7 +1274,7 @@ class ObjectRenderConfigView(generic.ObjectView):
             content = context['rendered_config'] or context['error_message']
             response = HttpResponse(content, content_type='text')
             filename = f"{instance.name or 'config'}.txt"
-            response['Content-Disposition'] = f'attachment; filename="{filename}"'
+            response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
             return response
 
         return render(
@@ -1636,6 +1637,7 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
 class ScriptModuleCreateView(generic.ObjectEditView):
     queryset = ScriptModule.objects.all()
     form = forms.ScriptFileForm
+    default_return_url = 'extras:script_list'
 
     def alter_object(self, obj, *args, **kwargs):
         obj.file_root = ManagedFileRootPathChoices.SCRIPTS
@@ -1882,7 +1884,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
             content = (job.data.get("output") or "").encode()
             response = HttpResponse(content, content_type='text')
             filename = f"{job.object.name or 'script-output'}_{job.completed.strftime('%Y-%m-%d_%H%M%S')}.txt"
-            response['Content-Disposition'] = f'attachment; filename="{filename}"'
+            response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
             return response
 
         if job.completed:

+ 2 - 2
netbox/ipam/api/field_serializers.py

@@ -20,7 +20,7 @@ class IPAddressField(serializers.CharField):
         try:
             return IPNetwork(data)
         except AddrFormatError:
-            raise serializers.ValidationError(_("Invalid IP address format: {data}").format(data))
+            raise serializers.ValidationError(_("Invalid IP address format: {data}").format(data=data))
         except (TypeError, ValueError) as e:
             raise serializers.ValidationError(e)
 
@@ -40,7 +40,7 @@ class IPNetworkField(serializers.CharField):
         try:
             return IPNetwork(data)
         except AddrFormatError:
-            raise serializers.ValidationError(_("Invalid IP prefix format: {data}").format(data))
+            raise serializers.ValidationError(_("Invalid IP prefix format: {data}").format(data=data))
         except (TypeError, ValueError) as e:
             raise serializers.ValidationError(e)
 

+ 8 - 0
netbox/ipam/filtersets.py

@@ -1069,6 +1069,10 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         queryset=Site.objects.all(),
         method='get_for_site'
     )
+    available_at_site_group = django_filters.ModelChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        method='get_for_site_group'
+    )
     available_on_device = django_filters.ModelChoiceFilter(
         queryset=Device.objects.select_related('cluster'),
         method='get_for_device'
@@ -1130,6 +1134,10 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     def get_for_site(self, queryset, name, value):
         return queryset.get_for_site(value)
 
+    @extend_schema_field(OpenApiTypes.STR)
+    def get_for_site_group(self, queryset, name, value):
+        return queryset.get_for_site_group(value)
+
     @extend_schema_field(OpenApiTypes.STR)
     def get_for_device(self, queryset, name, value):
         return queryset.get_for_device(value)

+ 11 - 3
netbox/ipam/forms/model_forms.py

@@ -5,7 +5,7 @@ from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
 from dcim.forms.mixins import ScopedForm
-from dcim.models import Device, Interface, Site
+from dcim.models import Device, Interface, Site, SiteGroup
 from ipam.choices import *
 from ipam.constants import *
 from ipam.formfields import IPNetworkFormField
@@ -251,9 +251,17 @@ class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        # #18605: only filter VLAN select list if the selected scope is a Site (or none is selected yet)
+        # #18605: only filter the VLAN select list if the selected scope is a Site (or none is selected yet).
+        # #22588: a Site Group scope filters VLANs by the group's member sites instead.
         if scope_field := self.fields.get('scope', None):
-            if scope_field.selected_model not in (None, Site):
+            selected_model = scope_field.selected_model
+            if selected_model is SiteGroup:
+                self.fields['vlan'].widget.dynamic_params.clear()
+                self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None)
+                self.fields['vlan'].widget.add_query_params({
+                    'available_at_site_group': '$scope_object_id',
+                })
+            elif selected_model not in (None, Site):
                 self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None)
 
 

+ 1 - 1
netbox/ipam/migrations/0094_denormalization_triggers.py

@@ -12,7 +12,7 @@ class Migration(migrations.Migration):
     dependencies = [
         ('ipam', '0093_alter_prefix__region_alter_prefix__site_group'),
         # Source tables (dcim_site, dcim_location) must already exist.
-        ('dcim', '0240_ltree_paths'),
+        ('dcim', '0242_ltree_paths'),
     ]
 
     operations = [

+ 17 - 0
netbox/ipam/querysets.py

@@ -290,6 +290,23 @@ class VLANQuerySet(RestrictedQuerySet):
             Q(group__isnull=True, site__isnull=True)  # Global VLANs
         )
 
+    def get_for_site_group(self, site_group):
+        """
+        Return all VLANs available to the specified site group.
+        """
+        if site_group is None:
+            return self.none()
+        from .models import VLANGroup
+        q = Q(
+            scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
+            scope_id__in=site_group.get_ancestors(include_self=True)
+        )
+        return self.filter(
+            Q(group__in=VLANGroup.objects.filter(q)) |
+            Q(group__scope_id__isnull=True, site__isnull=True) |  # Global group VLANs
+            Q(group__isnull=True, site__isnull=True)  # Global VLANs
+        )
+
     def get_for_device(self, device):
         """
         Return all VLANs available to the specified Device.

+ 2 - 0
netbox/ipam/search.py

@@ -22,6 +22,7 @@ class ASNIndex(SearchIndex):
         ('asn', 100),
         ('prefixed_name', 110),
         ('description', 500),
+        ('comments', 5000),
     )
     display_attrs = ('rir', 'role', 'tenant', 'description')
 
@@ -171,6 +172,7 @@ class VLANTranslationPolicyIndex(SearchIndex):
     fields = (
         ('name', 100),
         ('description', 500),
+        ('comments', 5000),
     )
     display_attrs = ('description',)
 

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

@@ -452,6 +452,19 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
         )
         Prefix.objects.bulk_create(prefixes)
 
+    @tag('regression')
+    def test_create_with_invalid_prefix(self):
+        """
+        POST of a malformed prefix value returns a 400 validation error.
+        """
+        self.add_permissions('ipam.add_prefix')
+        url = reverse('ipam-api:prefix-list')
+
+        response = self.client.post(url, {'prefix': 'invalid'}, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.data['prefix'][0], 'Invalid IP prefix format: invalid')
+
     @tag('regression')
     def test_clean_validates_scope(self):
         prefix = Prefix.objects.first()
@@ -858,6 +871,19 @@ class IPAddressTestCase(APIViewTestCases.APIViewTestCase):
         )
         IPAddress.objects.bulk_create(ip_addresses)
 
+    @tag('regression')
+    def test_create_with_invalid_address(self):
+        """
+        POST of a malformed address value returns a 400 validation error.
+        """
+        self.add_permissions('ipam.add_ipaddress')
+        url = reverse('ipam-api:ipaddress-list')
+
+        response = self.client.post(url, {'address': 'invalid'}, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.data['address'][0], 'Invalid IP address format: invalid')
+
     def test_assign_object(self):
         """
         Test the creation of available IP addresses within a parent IP range.

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

@@ -2221,6 +2221,11 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'available_at_site': site_id}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)  # 4 scoped + 1 global group + 1 global
 
+    def test_available_at_site_group(self):
+        site_group = SiteGroup.objects.get(name='Site Group 1')
+        params = {'available_at_site_group': site_group.pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)  # 1 scoped + 1 global group + 1 global
+
     def test_interface(self):
         interface_id = Interface.objects.first().pk
         params = {'interface_id': interface_id}

+ 11 - 3
netbox/ipam/tests/test_forms.py

@@ -29,12 +29,21 @@ class PrefixFormTestCase(TestCase):
 
         assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == self.default_dynamic_params
 
+    def test_vlan_field_sets_dynamic_params_for_scope_site_group(self):
+        """data-dynamic-params present with available_at_site_group when scope type is Site Group"""
+        site_group = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
+        form = PrefixForm(data={
+            'scope_content_type': ContentType.objects.get_for_model(SiteGroup).id,
+            'scope_object_id': site_group.pk,
+        })
+        expected = '[{"fieldName":"scope_object_id","queryParam":"available_at_site_group"}]'
+        assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == expected
+
     def test_vlan_field_does_not_set_dynamic_params_for_other_scopes(self):
-        """data-dynamic-params not present when scope type is populated by is not Site"""
+        """data-dynamic-params not present when scope type is not Site or Site Group"""
         cases = [
             Region(name='Region 1', slug='region-1'),
             Location(site=self.site, name='Location 1', slug='location-1'),
-            SiteGroup(name='Site Group 1', slug='site-group-1'),
         ]
         for case in cases:
             case.save()
@@ -42,7 +51,6 @@ class PrefixFormTestCase(TestCase):
                 'scope_content_type': ContentType.objects.get_for_model(case._meta.model).id,
                 'scope_object_id': case.pk,
             })
-
             assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs
 
 

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

@@ -1,4 +1,3 @@
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.utils.translation import gettext as _
@@ -112,7 +111,8 @@ class ContentTypeField(RelatedField):
     def to_internal_value(self, data):
         try:
             app_label, model = data.split('.')
-            return ContentType.objects.get_by_natural_key(app_label=app_label, model=model)
+            # Scoped to the field's declared queryset, not the raw ContentType table (#22748).
+            return self.get_queryset().get(app_label=app_label, model=model)
         except ObjectDoesNotExist:
             self.fail('does_not_exist', content_type=data)
         except (AttributeError, TypeError, ValueError):

+ 15 - 2
netbox/netbox/filtersets.py

@@ -2,6 +2,7 @@ import json
 from copy import deepcopy
 
 import django_filters
+from django.contrib.auth.models import AnonymousUser
 from django.contrib.contenttypes.models import ContentType
 from django.db import models
 from django.db.models import Q
@@ -20,6 +21,7 @@ from utilities.constants import (
     FILTER_CHAR_BASED_LOOKUP_MAP,
     FILTER_NEGATION_LOOKUP_MAP,
     FILTER_NUMERIC_BASED_LOOKUP_MAP,
+    FILTER_TAG_LOOKUP_MAP,
     FILTER_TREENODE_NEGATION_LOOKUP_MAP,
 )
 from utilities.forms.fields import MACAddressField
@@ -117,7 +119,11 @@ class BaseFilterSet(django_filters.FilterSet):
                 except (ValueError, TypeError):
                     pass
 
-            saved_filters = SavedFilter.objects.filter(
+            # Only apply SavedFilters the requesting user is permitted to see (#22790). Fall back to
+            # anonymous visibility (shared filters only) when no request is available.
+            request = kwargs.get('request')
+            user = request.user if request else AnonymousUser()
+            saved_filters = SavedFilter.objects.restrict_to_shared(user).filter(
                 Q(slug__in=data.pop('filter', [])) |
                 Q(pk__in=filter_ids)
             )
@@ -153,10 +159,13 @@ class BaseFilterSet(django_filters.FilterSet):
             # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression
             return FILTER_TREENODE_NEGATION_LOOKUP_MAP
 
+        if isinstance(existing_filter, (TagFilter, TagIDFilter)):
+            # Tags additionally support an "any of" (OR) mode, unlike other model choice filters
+            return FILTER_TAG_LOOKUP_MAP
+
         if isinstance(existing_filter, (
             django_filters.ModelChoiceFilter,
             django_filters.ModelMultipleChoiceFilter,
-            TagFilter
         )):
             # These filter types support only negation
             return FILTER_NEGATION_LOOKUP_MAP
@@ -237,6 +246,10 @@ class BaseFilterSet(django_filters.FilterSet):
                 # Of course setting the negation of the existing filter's exclude attribute handles both cases
                 new_filter.exclude = not existing_filter.exclude
 
+            if lookup_name == 'any' and isinstance(new_filter, (TagFilter, TagIDFilter)):
+                # "Any of" is an OR match, whereas TagFilter/TagIDFilter default to AND (conjoined=True)
+                new_filter.conjoined = False
+
             new_filters[new_filter_name] = new_filter
 
         return new_filters

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

@@ -308,10 +308,24 @@ class ArrayLookup(Generic[T]):
     Class for Array field lookups
     """
 
-    contains: list[T] | None = strawberry_django.filter_field(description='Contains the value')
-    contained_by: list[T] | None = strawberry_django.filter_field(description='Contained by the value')
-    overlap: list[T] | None = strawberry_django.filter_field(description='Overlaps with the value')
-    length: int | None = strawberry_django.filter_field(description='Length of the array')
+    contains: list[T] | None = strawberry.field(default=strawberry.UNSET, description='Contains the value')
+    contained_by: list[T] | None = strawberry.field(default=strawberry.UNSET, description='Contained by the value')
+    overlap: list[T] | None = strawberry.field(default=strawberry.UNSET, description='Overlaps with the value')
+    length: int | None = strawberry.field(default=strawberry.UNSET, description='Length of the array')
+
+    @strawberry_django.filter_field
+    def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> tuple[QuerySet, Q]:
+        # Map the public GraphQL ``length`` field to Django's ``len`` array transform; the
+        # remaining lookups share their name with the corresponding ORM transform.
+        if self.contains is not strawberry.UNSET and self.contains is not None:
+            return queryset, Q(**{f'{prefix}contains': self.contains})
+        if self.contained_by is not strawberry.UNSET and self.contained_by is not None:
+            return queryset, Q(**{f'{prefix}contained_by': self.contained_by})
+        if self.overlap is not strawberry.UNSET and self.overlap is not None:
+            return queryset, Q(**{f'{prefix}overlap': self.overlap})
+        if self.length is not strawberry.UNSET and self.length is not None:
+            return queryset, Q(**{f'{prefix}len': self.length})
+        return queryset, Q()
 
 
 @strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')

+ 3 - 1
netbox/netbox/models/deletion.py

@@ -79,7 +79,9 @@ class DeleteMixin:
                 )
             )
 
-        collector = CustomCollector(using=using)
+        # Pass origin=self (matching Django's Model.delete) so signal receivers can tell that
+        # cascaded child objects are being deleted as part of deleting this object.
+        collector = CustomCollector(using=using, origin=self)
         collector.collect([self], keep_parents=keep_parents)
 
         return collector.delete()

+ 30 - 2
netbox/netbox/tables/columns.py

@@ -570,9 +570,37 @@ class CustomFieldColumn(tables.Column):
                 return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
             return escape(value)
         if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
-            return self.customfield.get_choice_label(value)
+            if value is None:
+                return self.default
+            label = self.customfield.get_choice_label(value)
+            color = self.customfield.get_choice_color(value)
+            if color:
+                return mark_safe(
+                    f'<span class="badge text-bg-{escape(color)}">{escape(label)}</span>'
+                )
+            return label
         if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
-            return ', '.join(self.customfield.get_choice_label(v) for v in value)
+            if not value:
+                return ''
+
+            has_color = False
+            parts = []
+
+            for v in value:
+                label = self.customfield.get_choice_label(v)
+                color = self.customfield.get_choice_color(v)
+                if color:
+                    has_color = True
+                parts.append((label, color))
+            if has_color:
+                badges = []
+                for label, color in parts:
+                    badges.append(
+                        f'<span class="badge text-bg-{escape(color or "secondary")}">{escape(label)}</span>'
+                    )
+                return mark_safe(' '.join(badges))
+            return ', '.join(label for label, _ in parts)
+
         if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
             return mark_safe(', '.join(
                 self._linkify_item(obj) for obj in self.customfield.deserialize(value)

+ 60 - 1
netbox/netbox/tests/test_api.py

@@ -1,5 +1,6 @@
 import uuid
 
+from django.contrib.contenttypes.models import ContentType
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.test import RequestFactory, TestCase
 from django.urls import reverse
@@ -7,8 +8,9 @@ from rest_framework.exceptions import ValidationError
 from rest_framework.request import Request
 
 from dcim.api.serializers import RackSerializer
+from dcim.models import Device, Site
 from netbox.api.exceptions import QuerySetNotOrdered
-from netbox.api.fields import IntegerRangeSerializer, RelatedObjectCountField
+from netbox.api.fields import ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField
 from netbox.api.pagination import NetBoxPagination
 from users.models import Token
 from utilities.testing import APITestCase
@@ -178,3 +180,60 @@ class IntegerRangeSerializerTestCase(TestCase):
             serializer.to_internal_value(['100', '200'])
         with self.assertRaises(ValidationError):
             serializer.to_internal_value([100.5, 200.5])
+
+
+class ContentTypeFieldTestCase(TestCase):
+
+    def test_to_internal_value_resolves_content_type_within_queryset(self):
+        """A content type present in the field's declared queryset resolves successfully."""
+        site_ct = ContentType.objects.get_for_model(Site)
+        field = ContentTypeField(queryset=ContentType.objects.filter(pk=site_ct.pk))
+        self.assertEqual(field.to_internal_value('dcim.site'), site_ct)
+
+    def test_to_internal_value_rejects_content_type_outside_queryset(self):
+        """
+        Regression test for #22748: ContentTypeField.to_internal_value() previously resolved
+        against the raw, unfiltered ContentType table via get_by_natural_key(), ignoring the
+        field's own declared queryset entirely. A content type that is real and resolvable in
+        general, but falls outside the specific queryset a given field declares, must be
+        rejected rather than silently accepted.
+        """
+        site_ct = ContentType.objects.get_for_model(Site)
+        device_ct = ContentType.objects.get_for_model(Device)
+
+        # Scope the field to a single, unrelated content type so `dcim.site` is guaranteed to
+        # fall outside it.
+        field = ContentTypeField(queryset=ContentType.objects.filter(pk=device_ct.pk))
+        with self.assertRaises(ValidationError):
+            field.to_internal_value('dcim.site')
+
+        # Sanity check: the rejected content type is a genuine, generally-resolvable content
+        # type, so the rejection above is attributable to queryset scoping and not a bogus value.
+        self.assertTrue(ContentType.objects.filter(pk=site_ct.pk).exists())
+
+    def test_to_internal_value_rejects_malformed_input(self):
+        """Input must be exactly '<app_label>.<model>'."""
+        field = ContentTypeField(queryset=ContentType.objects.all())
+        with self.assertRaises(ValidationError):
+            field.to_internal_value('not-a-valid-format')
+        with self.assertRaises(ValidationError):
+            field.to_internal_value('too.many.dots')
+
+    def test_to_internal_value_rejects_nonexistent_content_type(self):
+        """A syntactically valid but nonexistent content type must be rejected."""
+        field = ContentTypeField(queryset=ContentType.objects.all())
+        with self.assertRaises(ValidationError):
+            field.to_internal_value('nonexistent_app.nonexistent_model')
+
+    def test_to_internal_value_many_rejects_content_type_outside_queryset(self):
+        """
+        many=True wraps the field in a ManyRelatedField, which delegates per-item validation to
+        the child field's to_internal_value() unconditionally; the same queryset scoping must
+        hold there too.
+        """
+        device_ct = ContentType.objects.get_for_model(Device)
+        field = ContentTypeField(queryset=ContentType.objects.filter(pk=device_ct.pk), many=True)
+
+        self.assertEqual(field.to_internal_value(['dcim.device']), [device_ct])
+        with self.assertRaises(ValidationError):
+            field.to_internal_value(['dcim.device', 'dcim.site'])

+ 73 - 1
netbox/netbox/tests/test_graphql.py

@@ -14,7 +14,17 @@ from strawberry.extensions import QueryDepthLimiter
 from strawberry.schema.config import StrawberryConfig
 
 from dcim.choices import LocationStatusChoices
-from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Site, VirtualChassis
+from dcim.models import (
+    Device,
+    DeviceRole,
+    DeviceType,
+    Location,
+    Manufacturer,
+    Rack,
+    RackReservation,
+    Site,
+    VirtualChassis,
+)
 from extras.models import TableConfig, Tag
 from netbox.graphql.scalars import BigInt, BigIntScalar
 from netbox.graphql.schema import Query, get_schema_extensions, schema
@@ -337,6 +347,68 @@ class GraphQLAPITestCase(APITestCase):
         self.assertNotIn('errors', data)
         self.assertEqual(len(data['data']['device_list']), 3)
 
+    def test_graphql_array_length_lookup(self):
+        """
+        The public GraphQL ``length`` array lookup must map to Django's ``len`` transform.
+        Regression test for #22766 using a standard array field (RackReservation.units).
+        """
+        self.add_permissions('dcim.view_rackreservation')
+        url = reverse('graphql')
+
+        site = Site.objects.first()
+        rack = Rack.objects.create(name='Reservation Rack', site=site)
+        RackReservation.objects.create(rack=rack, units=[1, 2], user=self.user, description='Two units')
+        RackReservation.objects.create(rack=rack, units=[3, 4, 5], user=self.user, description='Three units')
+
+        query = """
+        {
+            rack_reservation_list(filters: {units: {length: 2}}) {
+                id units
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['rack_reservation_list']), 1)
+        self.assertEqual(data['data']['rack_reservation_list'][0]['units'], [1, 2])
+
+    def test_graphql_array_lookups(self):
+        """
+        The ``contains``, ``contained_by``, and ``overlap`` array lookups share their name with the
+        corresponding ORM transform. Verify they still resolve after #22766 replaced the auto-generated
+        filter fields with a manual ``filter()`` method.
+        """
+        self.add_permissions('dcim.view_rackreservation')
+        url = reverse('graphql')
+
+        site = Site.objects.first()
+        rack = Rack.objects.create(name='Array Lookup Rack', site=site)
+        RackReservation.objects.create(rack=rack, units=[1, 2], user=self.user, description='Low units')
+        RackReservation.objects.create(rack=rack, units=[3, 4, 5], user=self.user, description='High units')
+
+        def run(lookup):
+            query = f"""
+            {{
+                rack_reservation_list(filters: {{units: {lookup}}}) {{
+                    id units
+                }}
+            }}
+            """
+            response = self.client.post(url, data={'query': query}, format='json', **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            data = json.loads(response.content)
+            self.assertNotIn('errors', data)
+            return [r['units'] for r in data['data']['rack_reservation_list']]
+
+        # contains: arrays that include all of the given elements
+        self.assertEqual(run('{contains: [1]}'), [[1, 2]])
+        # contained_by: arrays whose elements all fall within the given set
+        self.assertEqual(run('{contained_by: [1, 2, 3]}'), [[1, 2]])
+        # overlap: arrays sharing at least one element with the given set
+        self.assertCountEqual(run('{overlap: [2, 3]}'), [[1, 2], [3, 4, 5]])
+
     def test_graphql_tableconfig_object_type_exposes_id(self):
         """TableConfigType.object_type must expose ContentType fields (e.g. id)."""
         self.add_permissions('extras.view_tableconfig')

+ 5 - 0
netbox/netbox/tests/test_scaffold.py

@@ -44,6 +44,11 @@ class ScaffoldInstanceTest(SimpleTestCase):
         self.enterContext(patch('netbox.scaffold._config_template', return_value=template))
         self.enterContext(patch('netbox.scaffold._contrib_dir', return_value=contrib_src))
 
+        # scaffold_instance()/main() print per-file progress to stdout (legitimate `netbox setup`
+        # CLI feedback); swallow it here so it doesn't clutter the test runner's output. Tests that
+        # assert on captured output redirect stdout themselves within the individual test method.
+        self.enterContext(contextlib.redirect_stdout(StringIO()))
+
     def test_scaffolds_configuration_and_contrib_examples(self):
         """A fresh target gets conf/__init__.py, conf/configuration.py, local_requirements.txt, and contrib/."""
         written = scaffold.scaffold_instance(self.target)

+ 122 - 11
netbox/netbox/tests/test_tables.py

@@ -2,10 +2,11 @@ from django.contrib.auth.models import AnonymousUser
 from django.template import Context, Template
 from django.test import RequestFactory, TestCase
 
+from core.models import ObjectType
 from dcim.models import Device, Site
 from dcim.tables import DeviceTable
-from extras.choices import CustomFieldTypeChoices
-from extras.models import CustomField
+from extras.choices import CustomFieldChoiceColorChoices, CustomFieldTypeChoices
+from extras.models import CustomField, CustomFieldChoiceSet
 from netbox.tables import NetBoxTable, columns
 from utilities.testing import create_tags, create_test_device, create_test_user
 
@@ -124,26 +125,136 @@ class TagColumnTestCase(TestCase):
 
 
 class CustomFieldColumnTestCase(TestCase):
-    """
-    A URL custom field value is rendered directly into an href, so its scheme must be validated
-    against ALLOWED_URL_SCHEMES to avoid rendering dangerous schemes (e.g. javascript:) as clickable
-    links (fixes #22640).
-    """
+    @classmethod
+    def setUpTestData(cls):
+        cls.object_type = ObjectType.objects.get_for_model(Site)
+
+        # Choice set containing one colored and two uncolored choices
+        cls.mixed_choice_set = CustomFieldChoiceSet.objects.create(
+            name='Mixed Choice Set',
+            extra_choices=(
+                ('a', 'Option A'),
+                ('b', 'Option B'),
+                ('c', 'Option C'),
+            ),
+            choice_colors={
+                'a': CustomFieldChoiceColorChoices.RED,
+            },
+        )
+
+        cls.select_cf = CustomField.objects.create(
+            name='select_field',
+            type=CustomFieldTypeChoices.TYPE_SELECT,
+            choice_set=cls.mixed_choice_set,
+            required=False,
+        )
+        cls.select_cf.object_types.set([cls.object_type])
+
+        cls.multiselect_cf = CustomField.objects.create(
+            name='multiselect_field',
+            type=CustomFieldTypeChoices.TYPE_MULTISELECT,
+            choice_set=cls.mixed_choice_set,
+            required=False,
+        )
+        cls.multiselect_cf.object_types.set([cls.object_type])
+
+    def test_colored_single_select(self):
+        column = columns.CustomFieldColumn(self.select_cf)
+
+        rendered = str(column.render('a'))
+
+        self.assertIn('badge', rendered)
+        self.assertIn('text-bg-red', rendered)
+        self.assertIn('Option A', rendered)
+
+    def test_uncolored_single_select(self):
+        column = columns.CustomFieldColumn(self.select_cf)
+
+        rendered = str(column.render('b'))
+
+        self.assertEqual(rendered, 'Option B')
+        self.assertNotIn('badge', rendered)
+
+    def test_empty_multiselect(self):
+        column = columns.CustomFieldColumn(self.multiselect_cf)
+
+        rendered = column.render([])
+
+        self.assertEqual(rendered, '')
+
+    def test_multiselect_without_selected_colored_choices(self):
+        column = columns.CustomFieldColumn(self.multiselect_cf)
 
-    def _render(self, value):
+        rendered = str(column.render(['b', 'c']))
+
+        self.assertEqual(rendered, 'Option B, Option C')
+        self.assertNotIn('badge', rendered)
+
+    def test_multiselect_with_mixed_colored_choices(self):
+        column = columns.CustomFieldColumn(self.multiselect_cf)
+
+        rendered = str(column.render(['a', 'b']))
+
+        self.assertIn('Option A', rendered)
+        self.assertIn('Option B', rendered)
+
+        self.assertIn('text-bg-red', rendered)
+        self.assertIn('text-bg-secondary', rendered)
+
+        self.assertNotIn(',', rendered)
+
+    def test_html_sensitive_multiselect_labels(self):
+        choice_set = CustomFieldChoiceSet.objects.create(
+            name='HTML Choice Set',
+            extra_choices=(
+                ('x', '<b>Bold Option</b>'),
+                ('y', "<script>alert('xss')</script>"),
+            ),
+            choice_colors={
+                'x': CustomFieldChoiceColorChoices.RED,
+            },
+        )
+
+        custom_field = CustomField.objects.create(
+            name='html_multiselect_field',
+            type=CustomFieldTypeChoices.TYPE_MULTISELECT,
+            choice_set=choice_set,
+            required=False,
+        )
+        custom_field.object_types.set([self.object_type])
+
+        column = columns.CustomFieldColumn(custom_field)
+
+        rendered = str(column.render(['x', 'y']))
+
+        self.assertIn('&lt;b&gt;Bold Option&lt;/b&gt;', rendered)
+        self.assertNotIn('&amp;lt;', rendered)
+        self.assertIn('&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;', rendered,)
+        self.assertNotIn('<script>', rendered)
+
+        self.assertIn('text-bg-red', rendered)
+        self.assertIn('text-bg-secondary', rendered)
+
+    def _render_url(self, value):
         customfield = CustomField(name='url_field', type=CustomFieldTypeChoices.TYPE_URL)
         return columns.CustomFieldColumn(customfield).render(value)
 
+    # A URL custom field value is rendered directly into an href, so its scheme must be validated
+    # against ALLOWED_URL_SCHEMES to avoid rendering dangerous schemes (e.g. javascript:) as
+    # clickable links (fixes #22640).
+
     def test_url_allowed_scheme_rendered_as_link(self):
-        self.assertEqual(self._render('https://example.com'), '<a href="https://example.com">https://example.com</a>')
+        self.assertEqual(
+            self._render_url('https://example.com'), '<a href="https://example.com">https://example.com</a>'
+        )
 
     def test_url_disallowed_scheme_not_rendered_as_link(self):
-        rendered = self._render('javascript:alert(1)')
+        rendered = self._render_url('javascript:alert(1)')
         self.assertNotIn('href', rendered)
         self.assertIn('javascript:alert(1)', rendered)
 
     def test_url_percent_encoded_scheme_rendered_as_relative_link(self):
         # A percent-encoded scheme is inert: a browser will not decode "%3A" to execute javascript:,
         # so the value has no scheme and is rendered as a link as-is.
-        rendered = self._render('javascript%3Aalert(1)')
+        rendered = self._render_url('javascript%3Aalert(1)')
         self.assertEqual(rendered, '<a href="javascript%3Aalert(1)">javascript%3Aalert(1)</a>')

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

@@ -15,6 +15,7 @@ from django.db.models.fields.reverse_related import ManyToManyRel
 from django.forms import ModelMultipleChoiceField, MultipleHiddenInput
 from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
+from django.utils.http import content_disposition_header
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
 
@@ -227,7 +228,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
             if hasattr(model, 'to_yaml'):
                 response = HttpResponse(self.export_yaml(), content_type='text/yaml')
                 filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
-                response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
+                response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
                 return response
 
             # Fall back to default table/YAML export

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

@@ -31,8 +31,9 @@
     "gridstack": "12.6.0",
     "htmx.org": "2.0.10",
     "markdown-it": "^14.3.0",
+    "picomatch": "4.0.5",
     "query-string": "9.4.1",
-    "sass": "1.101.0",
+    "sass": "1.102.0",
     "tom-select": "2.6.2",
     "typeface-inter": "3.18.1",
     "typeface-roboto-mono": "1.1.13"
@@ -44,16 +45,16 @@
     "@types/bootstrap": "5.2.11",
     "@types/cookie": "^1.0.0",
     "@types/node": "^24.10.1",
-    "@typescript-eslint/eslint-plugin": "^8.64.0",
-    "@typescript-eslint/parser": "^8.64.0",
+    "@typescript-eslint/eslint-plugin": "^8.65.0",
+    "@typescript-eslint/parser": "^8.65.0",
     "esbuild": "^0.28.1",
     "esbuild-sass-plugin": "^3.7.0",
-    "eslint": "^10.7.0",
+    "eslint": "^10.8.0",
     "eslint-config-prettier": "^10.1.8",
     "eslint-import-resolver-typescript": "^4.4.5",
     "eslint-plugin-import": "^2.32.0",
     "eslint-plugin-prettier": "^5.5.6",
-    "globals": "^17.7.0",
+    "globals": "^17.8.0",
     "prettier": "^3.9.5",
     "typescript": "^5.9.3"
   },

+ 95 - 97
netbox/project-static/yarn.lock

@@ -182,10 +182,10 @@
     debug "^4.3.1"
     minimatch "^10.2.4"
 
-"@eslint/config-helpers@^0.6.0":
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.6.0.tgz#ef9a36881d39dfd5dbeac22b0da997fabfb08b03"
-  integrity sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==
+"@eslint/config-helpers@^0.7.0":
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377"
+  integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==
   dependencies:
     "@eslint/core" "^1.2.1"
 
@@ -917,100 +917,100 @@
   dependencies:
     "@types/estree" "*"
 
-"@typescript-eslint/eslint-plugin@^8.64.0":
-  version "8.64.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz#71a0c3d5f8a5e6c5dfdb4f0f04bd1bfb572d5e24"
-  integrity sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==
+"@typescript-eslint/eslint-plugin@^8.65.0":
+  version "8.65.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz#0a58df6fea8c0bf6b396f518077099bc8b762bb5"
+  integrity sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==
   dependencies:
     "@eslint-community/regexpp" "^4.12.2"
-    "@typescript-eslint/scope-manager" "8.64.0"
-    "@typescript-eslint/type-utils" "8.64.0"
-    "@typescript-eslint/utils" "8.64.0"
-    "@typescript-eslint/visitor-keys" "8.64.0"
+    "@typescript-eslint/scope-manager" "8.65.0"
+    "@typescript-eslint/type-utils" "8.65.0"
+    "@typescript-eslint/utils" "8.65.0"
+    "@typescript-eslint/visitor-keys" "8.65.0"
     ignore "^7.0.5"
     natural-compare "^1.4.0"
     ts-api-utils "^2.5.0"
 
-"@typescript-eslint/parser@^8.64.0":
-  version "8.64.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.64.0.tgz#c9864a1cc28a13ff29a7314fbdef0528bb122f72"
-  integrity sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==
+"@typescript-eslint/parser@^8.65.0":
+  version "8.65.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.65.0.tgz#5295c1058c0a1dd746ef28baaf9c0341dbdf03dc"
+  integrity sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==
   dependencies:
-    "@typescript-eslint/scope-manager" "8.64.0"
-    "@typescript-eslint/types" "8.64.0"
-    "@typescript-eslint/typescript-estree" "8.64.0"
-    "@typescript-eslint/visitor-keys" "8.64.0"
+    "@typescript-eslint/scope-manager" "8.65.0"
+    "@typescript-eslint/types" "8.65.0"
+    "@typescript-eslint/typescript-estree" "8.65.0"
+    "@typescript-eslint/visitor-keys" "8.65.0"
     debug "^4.4.3"
 
-"@typescript-eslint/project-service@8.64.0":
-  version "8.64.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.64.0.tgz#14c4e29390d7325a7f8a1218c2788fd649b85da6"
-  integrity sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==
+"@typescript-eslint/project-service@8.65.0":
+  version "8.65.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.65.0.tgz#65fbbc9a1591abffaeab5513200f848271cb0aa5"
+  integrity sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==
   dependencies:
-    "@typescript-eslint/tsconfig-utils" "^8.64.0"
-    "@typescript-eslint/types" "^8.64.0"
+    "@typescript-eslint/tsconfig-utils" "^8.65.0"
+    "@typescript-eslint/types" "^8.65.0"
     debug "^4.4.3"
 
-"@typescript-eslint/scope-manager@8.64.0":
-  version "8.64.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz#d45f15304a94c85c39db317b717b158fb6259958"
-  integrity sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==
+"@typescript-eslint/scope-manager@8.65.0":
+  version "8.65.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz#9547202ce7e608e7b6283df585703b980a0ea70d"
+  integrity sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==
   dependencies:
-    "@typescript-eslint/types" "8.64.0"
-    "@typescript-eslint/visitor-keys" "8.64.0"
+    "@typescript-eslint/types" "8.65.0"
+    "@typescript-eslint/visitor-keys" "8.65.0"
 
-"@typescript-eslint/tsconfig-utils@8.64.0", "@typescript-eslint/tsconfig-utils@^8.64.0":
-  version "8.64.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz#c62ac8ea9173c3cac8b38b8e66e30a046b548851"
-  integrity sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==
+"@typescript-eslint/tsconfig-utils@8.65.0", "@typescript-eslint/tsconfig-utils@^8.65.0":
+  version "8.65.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz#36f168fcdbb1295f7446ff0379667f98c3cf1bf3"
+  integrity sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==
 
-"@typescript-eslint/type-utils@8.64.0":
-  version "8.64.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz#106fa7d58cf9cf7758f3dd8e426ac8237eceacf3"
-  integrity sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==
+"@typescript-eslint/type-utils@8.65.0":
+  version "8.65.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz#d316d7522d93cff4cd14f305e02f3df2d804f9c1"
+  integrity sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==
   dependencies:
-    "@typescript-eslint/types" "8.64.0"
-    "@typescript-eslint/typescript-estree" "8.64.0"
-    "@typescript-eslint/utils" "8.64.0"
+    "@typescript-eslint/types" "8.65.0"
+    "@typescript-eslint/typescript-estree" "8.65.0"
+    "@typescript-eslint/utils" "8.65.0"
     debug "^4.4.3"
     ts-api-utils "^2.5.0"
 
-"@typescript-eslint/types@8.64.0", "@typescript-eslint/types@^8.64.0":
-  version "8.64.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.64.0.tgz#b41f8ef5dd40616908658b991197a9d486cda60b"
-  integrity sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==
+"@typescript-eslint/types@8.65.0", "@typescript-eslint/types@^8.65.0":
+  version "8.65.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.65.0.tgz#3e86738416a777c8b8925ab46745f48ecf904c9f"
+  integrity sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==
 
-"@typescript-eslint/typescript-estree@8.64.0":
-  version "8.64.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz#b8d51255e2d726eb4bd80d397a4fb4170c02eecc"
-  integrity sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==
+"@typescript-eslint/typescript-estree@8.65.0":
+  version "8.65.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz#f1f514808f6aa713e2d678ae8ff592a65e1632af"
+  integrity sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==
   dependencies:
-    "@typescript-eslint/project-service" "8.64.0"
-    "@typescript-eslint/tsconfig-utils" "8.64.0"
-    "@typescript-eslint/types" "8.64.0"
-    "@typescript-eslint/visitor-keys" "8.64.0"
+    "@typescript-eslint/project-service" "8.65.0"
+    "@typescript-eslint/tsconfig-utils" "8.65.0"
+    "@typescript-eslint/types" "8.65.0"
+    "@typescript-eslint/visitor-keys" "8.65.0"
     debug "^4.4.3"
     minimatch "^10.2.2"
     semver "^7.7.3"
     tinyglobby "^0.2.15"
     ts-api-utils "^2.5.0"
 
-"@typescript-eslint/utils@8.64.0":
-  version "8.64.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.64.0.tgz#98bb2010cfb754b41985b9c93e6e8b3dcd7bd600"
-  integrity sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==
+"@typescript-eslint/utils@8.65.0":
+  version "8.65.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.65.0.tgz#afedd974a0c8deeef553b509df5800bafd615a72"
+  integrity sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==
   dependencies:
     "@eslint-community/eslint-utils" "^4.9.1"
-    "@typescript-eslint/scope-manager" "8.64.0"
-    "@typescript-eslint/types" "8.64.0"
-    "@typescript-eslint/typescript-estree" "8.64.0"
+    "@typescript-eslint/scope-manager" "8.65.0"
+    "@typescript-eslint/types" "8.65.0"
+    "@typescript-eslint/typescript-estree" "8.65.0"
 
-"@typescript-eslint/visitor-keys@8.64.0":
-  version "8.64.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz#7a08421d10e54960733352cd7c95fab1784e8473"
-  integrity sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==
+"@typescript-eslint/visitor-keys@8.65.0":
+  version "8.65.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz#e3704c13cb4a1c22454c1abf28ff4737e15018c6"
+  integrity sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==
   dependencies:
-    "@typescript-eslint/types" "8.64.0"
+    "@typescript-eslint/types" "8.65.0"
     eslint-visitor-keys "^5.0.0"
 
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
@@ -1137,7 +1137,7 @@ ajv@^6.14.0:
 
 argparse@^2.0.1:
   version "2.0.1"
-  resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
   integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
 
 aria-hidden@^1.2.3, aria-hidden@^1.2.4:
@@ -1539,7 +1539,7 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1:
 
 entities@^4.4.0, entities@^4.5.0:
   version "4.5.0"
-  resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
   integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
 
 es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.2:
@@ -1881,15 +1881,15 @@ eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1:
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
   integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
 
-eslint@^10.7.0:
-  version "10.7.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.7.0.tgz#cd3b8022f3b1e3b183760d90dfc58e9d3644106b"
-  integrity sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==
+eslint@^10.8.0:
+  version "10.8.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.8.0.tgz#e6d19907a3f090a53a022261ba34c5ff6d04908b"
+  integrity sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==
   dependencies:
     "@eslint-community/eslint-utils" "^4.8.0"
     "@eslint-community/regexpp" "^4.12.2"
     "@eslint/config-array" "^0.23.5"
-    "@eslint/config-helpers" "^0.6.0"
+    "@eslint/config-helpers" "^0.7.0"
     "@eslint/core" "^1.2.1"
     "@eslint/plugin-kit" "^0.7.2"
     "@humanfs/node" "^0.16.6"
@@ -1913,7 +1913,7 @@ eslint@^10.7.0:
     imurmurhash "^0.1.4"
     is-glob "^4.0.0"
     json-stable-stringify-without-jsonify "^1.0.1"
-    minimatch "^10.2.4"
+    minimatch "^10.2.5"
     natural-compare "^1.4.0"
     optionator "^0.9.3"
 
@@ -2168,10 +2168,10 @@ globals@^14.0.0:
   resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
   integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
 
-globals@^17.7.0:
-  version "17.7.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-17.7.0.tgz#553d55090b4dde8209ec2da42580d6e7e7d8b10d"
-  integrity sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==
+globals@^17.8.0:
+  version "17.8.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-17.8.0.tgz#a1f213a06adcd0eec38004c5cd39fef7af7e0830"
+  integrity sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==
 
 globalthis@^1.0.3, globalthis@^1.0.4:
   version "1.0.4"
@@ -2697,14 +2697,7 @@ levn@^0.4.1:
     prelude-ls "^1.2.1"
     type-check "~0.4.0"
 
-linkify-it@^5.0.1:
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.1.tgz#10c4cecbb5c6828eabf81d3c801adc4a542dfb55"
-  integrity sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==
-  dependencies:
-    uc.micro "^2.0.0"
-
-linkify-it@^5.0.2:
+linkify-it@^5.0.1, linkify-it@^5.0.2:
   version "5.0.2"
   resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.2.tgz#d3be0a693af3da9df3883f1e346a0e97461a8c19"
   integrity sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==
@@ -2743,9 +2736,9 @@ math-intrinsics@^1.1.0:
   integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
 
 mdurl@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz"
-  integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.1.0.tgz#d711d3f7bce7f22c487c91be78545f356fa96573"
+  integrity sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==
 
 meros@^1.1.4:
   version "1.3.2"
@@ -2767,7 +2760,7 @@ minimatch@^10.2.2:
   dependencies:
     brace-expansion "^5.0.5"
 
-minimatch@^10.2.4, minimatch@^3.1.2, minimatch@^3.1.3, minimatch@^3.1.5:
+minimatch@^10.2.4, minimatch@^10.2.5, minimatch@^3.1.2, minimatch@^3.1.3, minimatch@^3.1.5:
   version "3.1.5"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
   integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
@@ -2949,6 +2942,11 @@ picomatch@4.0.4, picomatch@^4.0.3, picomatch@^4.0.4:
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
   integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
 
+picomatch@4.0.5:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab"
+  integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==
+
 possible-typed-array-names@^1.0.0:
   version "1.0.0"
   resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz"
@@ -2973,7 +2971,7 @@ prettier@^3.9.5:
 
 punycode.js@^2.3.1:
   version "2.3.1"
-  resolved "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz"
+  resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
   integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
 
 punycode@^2.1.0:
@@ -3185,10 +3183,10 @@ safe-regex-test@^1.1.0:
     es-errors "^1.3.0"
     is-regex "^1.2.1"
 
-sass@1.101.0:
-  version "1.101.0"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.101.0.tgz#c2db5bbf2f956be7277f6223b899d0d4be3c899b"
-  integrity sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==
+sass@1.102.0:
+  version "1.102.0"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.102.0.tgz#4ed9378f37ca4186a76d6d1f52a6680c92b6bd80"
+  integrity sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==
   dependencies:
     chokidar "^5.0.0"
     immutable "^5.1.5"
@@ -3610,7 +3608,7 @@ typescript@^5.9.3:
 
 uc.micro@^2.0.0, uc.micro@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
   integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
 
 unbox-primitive@^1.0.2:

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.6.5"
+version: "4.6.6"
 edition: "Community"
-published: "2026-07-14"
+published: "2026-07-28"

+ 13 - 3
netbox/templates/dcim/htmx/cable_edit.html

@@ -59,13 +59,23 @@
   {% render_field form.label %}
   {% render_field form.description %}
   {% render_field form.color %}
-  <div class="row mb-3">
+  <div class="row mb-3{% if form.length.errors or form.length_unit.errors %} has-errors{% endif %}">
     <label class="col-sm-3 col-form-label text-lg-end">{{ form.length.label }}</label>
     <div class="col-md-5">
-      {{ form.length }}
+      {% render_field_with_aria form.length %}
+      {% if form.length.errors %}
+        <div class="form-text text-danger" id="{{ form.length.auto_id }}_errors" role="alert">
+          {% for error in form.length.errors %}{{ error }}{% if not forloop.last %}<br />{% endif %}{% endfor %}
+        </div>
+      {% endif %}
     </div>
     <div class="col-md-4">
-      {{ form.length_unit }}
+      {% render_field_with_aria form.length_unit %}
+      {% if form.length_unit.errors %}
+        <div class="form-text text-danger" id="{{ form.length_unit.auto_id }}_errors" role="alert">
+          {% for error in form.length_unit.errors %}{{ error }}{% if not forloop.last %}<br />{% endif %}{% endfor %}
+        </div>
+      {% endif %}
     </div>
   </div>
   {% render_field form.tags %}

+ 1 - 1
netbox/templates/inc/table_controls_htmx.html

@@ -7,8 +7,8 @@
 
 <div class="row mb-3" id="results">
   <div class="col-auto d-print-none">
+    <label for="quicksearch" class="visually-hidden">{% trans "Quick search" %}</label>
     <div class="input-group input-group-flat me-2 quicksearch" hx-disinherit="hx-select hx-swap">
-      <label for="quicksearch" class="visually-hidden">{% trans "Quick search" %}</label>
       <input type="search" results="5" name="q" id="quicksearch" class="form-control" placeholder="{% trans "Quick search" %}"
           hx-get="{{ request.full_path }}" hx-target="#object_list" hx-trigger="keyup changed delay:500ms, search"/>
       <span class="input-group-text py-1">

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


+ 5 - 0
netbox/utilities/constants.py

@@ -30,6 +30,11 @@ FILTER_NEGATION_LOOKUP_MAP = dict(
     n='exact'
 )
 
+FILTER_TAG_LOOKUP_MAP = dict(
+    n='exact',
+    any='exact',
+)
+
 FILTER_TREENODE_NEGATION_LOOKUP_MAP = dict(
     n='in'
 )

+ 43 - 6
netbox/utilities/counters.py

@@ -1,5 +1,5 @@
 from django.apps import apps
-from django.db.models import Count, F, OuterRef, Subquery
+from django.db.models import Count, F, OuterRef, QuerySet, Subquery
 from django.db.models.signals import post_delete, post_save, pre_delete
 
 from netbox.registry import registry
@@ -64,22 +64,59 @@ def post_save_receiver(sender, instance, created, **kwargs):
             update_counter(parent_model, new_pk, counter_name, 1)
 
 
+def _parent_is_being_deleted(origin, parent_model, parent_pk):
+    """
+    Return True if `origin` (the object or queryset that `delete()` was called on) indicates that
+    the parent identified by (parent_model, parent_pk) is itself being deleted as part of the same
+    operation. In that case, decrementing its counter is wasted work: the parent row is going away,
+    so the UPDATE would be a no-op. Skipping it avoids an N+1 storm of pointless UPDATEs when a
+    parent with many tracked children is deleted (e.g. a Device with thousands of Interfaces).
+
+    Note: only the *direct* parent is detected, since `origin` is just the top-level object/queryset
+    delete() was called on. In a deeper cascade (DeviceType -> Device -> Interface) `origin` stays
+    the DeviceType, so intermediate Devices' interface counters still get the (harmless) no-op
+    UPDATE. Suppressing that would require the full deletion set, which the signals don't expose.
+    """
+    if origin is None:
+        return False
+    if isinstance(origin, QuerySet):
+        # A bulk delete; every collected child belongs to an object in this queryset by construction
+        return origin.model is parent_model
+    # A single object delete
+    return isinstance(origin, parent_model) and origin.pk == parent_pk
+
+
 def pre_delete_receiver(sender, instance, origin, **kwargs):
-    model = instance._meta.model
-    if not model.objects.filter(pk=instance.pk).exists():
-        instance._previously_removed = True
+    """
+    Before a tracked object is deleted, check whether its row has already been removed (e.g. by an
+    earlier cascade) and, if so, flag it so post_delete_receiver skips the now-redundant counter
+    update. The existence check is skipped when the tracked parent is itself being deleted, since
+    the counter update would be skipped regardless — this avoids a SELECT per cascaded child.
+    """
+    for field_name, counter_name in get_counters_for_model(sender):
+        parent_model = sender._meta.get_field(field_name).related_model
+        parent_pk = getattr(instance, field_name, None)
+        if parent_pk is None or _parent_is_being_deleted(origin, parent_model, parent_pk):
+            continue
+        # A tracked parent will survive this operation, so the double-delete guard is needed
+        if not sender.objects.filter(pk=instance.pk).exists():
+            instance._previously_removed = True
+        return
 
 
 def post_delete_receiver(sender, instance, origin, **kwargs):
     """
     Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
     """
+    if hasattr(instance, '_previously_removed'):
+        return
+
     for field_name, counter_name in get_counters_for_model(sender):
         parent_model = sender._meta.get_field(field_name).related_model
         parent_pk = getattr(instance, field_name, None)
 
-        # Decrement the parent's counter by one
-        if parent_pk is not None and not hasattr(instance, '_previously_removed'):
+        # Decrement the parent's counter by one, unless the parent is itself being deleted
+        if parent_pk is not None and not _parent_is_being_deleted(origin, parent_model, parent_pk):
             update_counter(parent_model, parent_pk, counter_name, -1)
 
 

+ 2 - 1
netbox/utilities/export.py

@@ -2,6 +2,7 @@ import csv
 
 from django.http import StreamingHttpResponse
 from django.utils.encoding import force_str
+from django.utils.http import content_disposition_header
 from django.utils.translation import gettext_lazy as _
 from django_tables2.data import TableQuerysetData
 from django_tables2.export import TableExport as TableExport_
@@ -88,5 +89,5 @@ def stream_table_csv_response(table, exclude_columns=None, filename=None, delimi
 
     response = StreamingHttpResponse(row_generator(), content_type='text/csv; charset=utf-8')
     if filename is not None:
-        response['Content-Disposition'] = f'attachment; filename="{filename}"'
+        response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
     return response

+ 1 - 0
netbox/utilities/forms/mixins.py

@@ -83,6 +83,7 @@ FORM_FIELD_LOOKUPS = {
     ],
     TagFilterField: [
         ('exact', _('has these tags')),
+        ('any', _('has any of these tags')),
         ('n', _('does not have these tags')),
         (MODIFIER_EMPTY_TRUE, _('is empty')),
         (MODIFIER_EMPTY_FALSE, _('is not empty')),

+ 26 - 5
netbox/utilities/jinja2.py

@@ -1,5 +1,6 @@
 import fnmatch
 import os
+import re
 
 from django.apps import apps
 from jinja2 import BaseLoader, TemplateNotFound
@@ -14,8 +15,13 @@ __all__ = (
     'DataFileLoader',
     'env_filter',
     'render_jinja2',
+    'sanitize_http_header',
 )
 
+# Control characters (C0 range plus DEL) which are invalid in an HTTP header value. Notably, this includes the
+# carriage return and line feed characters used to smuggle additional headers (CR/LF injection).
+HTTP_HEADER_INVALID_CHARS_RE = re.compile(r'[\x00-\x1f\x7f]')
+
 
 def env_filter(name):
     """
@@ -29,6 +35,15 @@ def env_filter(name):
     return os.environ.get(name)
 
 
+def sanitize_http_header(value):
+    """
+    Jinja2 filter which sanitizes a value for safe inclusion in a raw HTTP header by stripping newlines and other
+    control characters. This guards against HTTP header (CR/LF) injection when interpolating untrusted data (e.g.
+    user-controlled object attributes) into a webhook's additional headers.
+    """
+    return HTTP_HEADER_INVALID_CHARS_RE.sub('', str(value))
+
+
 DEFAULT_JINJA2_FILTERS = {
     'env': env_filter,
 }
@@ -72,11 +87,14 @@ class DataFileLoader(BaseLoader):
 # Utility functions
 #
 
-def render_jinja2(template_code, context, environment_params=None, data_file=None, debug=False):
+def render_jinja2(template_code, context, environment_params=None, data_file=None, debug=False, filters=None):
     """
     Render a Jinja2 template with the provided context. Return the rendered content.
 
     If debug is True, the Jinja2 debug extension is enabled to assist with template development.
+
+    The optional `filters` argument is a mapping of additional Jinja2 filters to make available for this render only
+    (e.g. context-specific sanitization filters). These take precedence over the default and user-configured filters.
     """
     environment_params = dict(environment_params or {})
 
@@ -98,14 +116,17 @@ def render_jinja2(template_code, context, environment_params=None, data_file=Non
 
     environment = SandboxedEnvironment(**environment_params)
 
-    # Build filter table: default < plugin-registered < instance JINJA_FILTERS.
-    # Instance-level config always wins so site admins can override anything.
-    filters = {
+    # Build filter table: default < plugin-registered < instance JINJA_FILTERS < per-render filters.
+    # Instance-level config wins over plugin-registered filters so site admins can override anything.
+    # Filters passed for this render take precedence over all of them, so that context-specific
+    # (e.g. sanitization) filters cannot be shadowed.
+    all_filters = {
         **DEFAULT_JINJA2_FILTERS,
         **registry['plugins'].get('jinja_filters', {}),
         **get_config().JINJA_FILTERS,
+        **(filters or {}),
     }
-    environment.filters.update(filters)
+    environment.filters.update(all_filters)
 
     if data_file:
         template = environment.get_template(data_file.path)

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