Преглед изворни кода

Merge branch 'main' into feature

Jeremy Stretch пре 4 часа
родитељ
комит
c89a544eac
95 измењених фајлова са 5104 додато и 3364 уклоњено
  1. 1 1
      .claude/skills/add-config-param/SKILL.md
  2. 12 0
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 11 0
      contrib/generated_schema.json
  4. 38 141
      contrib/openapi.json
  5. 3 0
      docs/configuration/security.md
  6. 37 0
      docs/release-notes/version-4.6.md
  7. 2 2
      netbox/core/forms/bulk_edit.py
  8. 0 6
      netbox/core/forms/model_forms.py
  9. 25 0
      netbox/core/tests/test_forms.py
  10. 19 0
      netbox/core/tests/test_views.py
  11. 3 0
      netbox/core/views.py
  12. 32 3
      netbox/dcim/choices.py
  13. 15 1
      netbox/dcim/forms/bulk_edit.py
  14. 66 19
      netbox/dcim/graphql/types.py
  15. 5 4
      netbox/dcim/models/devices.py
  16. 12 6
      netbox/dcim/models/modules.py
  17. 182 14
      netbox/dcim/signals.py
  18. 112 0
      netbox/dcim/tests/test_api.py
  19. 429 5
      netbox/dcim/tests/test_signals.py
  20. 57 9
      netbox/dcim/tests/test_views.py
  21. 1 1
      netbox/dcim/ui/panels.py
  22. 7 2
      netbox/extras/forms/bulk_edit.py
  23. 0 2
      netbox/extras/models/models.py
  24. 1 1
      netbox/extras/scripts.py
  25. 34 0
      netbox/extras/tests/test_event_rules.py
  26. 30 0
      netbox/extras/tests/test_scripts.py
  27. 79 0
      netbox/extras/tests/test_views.py
  28. 8 0
      netbox/extras/views.py
  29. 0 2
      netbox/ipam/models/fhrp.py
  30. 2 2
      netbox/ipam/models/ip.py
  31. 17 15
      netbox/ipam/signals.py
  32. 57 2
      netbox/ipam/tests/test_signals.py
  33. 8 3
      netbox/netbox/api/viewsets/mixins.py
  34. 4 4
      netbox/netbox/config/parameters.py
  35. 5 1
      netbox/netbox/forms/bulk_edit.py
  36. 73 0
      netbox/netbox/tests/test_base_classes.py
  37. 20 1
      netbox/netbox/tests/test_model_features.py
  38. 35 0
      netbox/netbox/tests/test_ui.py
  39. 233 2
      netbox/netbox/tests/test_views.py
  40. 3 1
      netbox/netbox/views/generic/bulk_views.py
  41. 4 1
      netbox/netbox/views/generic/object_views.py
  42. 0 0
      netbox/project-static/dist/netbox.js
  43. 0 0
      netbox/project-static/dist/netbox.js.map
  44. 5 5
      netbox/project-static/package.json
  45. 16 78
      netbox/project-static/src/sidenav.ts
  46. 75 75
      netbox/project-static/yarn.lock
  47. 1 1
      netbox/tenancy/forms/bulk_edit.py
  48. 0 2
      netbox/tenancy/models/contacts.py
  49. 1 1
      netbox/tenancy/ui/panels.py
  50. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  51. 171 171
      netbox/translations/cs/LC_MESSAGES/django.po
  52. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  53. 171 171
      netbox/translations/da/LC_MESSAGES/django.po
  54. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  55. 172 172
      netbox/translations/de/LC_MESSAGES/django.po
  56. 178 178
      netbox/translations/en/LC_MESSAGES/django.po
  57. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  58. 171 171
      netbox/translations/es/LC_MESSAGES/django.po
  59. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  60. 171 171
      netbox/translations/fr/LC_MESSAGES/django.po
  61. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  62. 171 171
      netbox/translations/it/LC_MESSAGES/django.po
  63. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  64. 172 172
      netbox/translations/ja/LC_MESSAGES/django.po
  65. BIN
      netbox/translations/ko/LC_MESSAGES/django.mo
  66. 172 172
      netbox/translations/ko/LC_MESSAGES/django.po
  67. BIN
      netbox/translations/lv/LC_MESSAGES/django.mo
  68. 171 171
      netbox/translations/lv/LC_MESSAGES/django.po
  69. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  70. 171 171
      netbox/translations/nl/LC_MESSAGES/django.po
  71. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  72. 171 171
      netbox/translations/pl/LC_MESSAGES/django.po
  73. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  74. 171 171
      netbox/translations/pt/LC_MESSAGES/django.po
  75. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  76. 171 171
      netbox/translations/ru/LC_MESSAGES/django.po
  77. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  78. 172 172
      netbox/translations/tr/LC_MESSAGES/django.po
  79. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  80. 172 172
      netbox/translations/uk/LC_MESSAGES/django.po
  81. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  82. 172 172
      netbox/translations/zh/LC_MESSAGES/django.po
  83. 3 0
      netbox/utilities/constants.py
  84. 9 9
      netbox/utilities/counters.py
  85. 21 2
      netbox/utilities/html.py
  86. 14 0
      netbox/utilities/testing/base.py
  87. 27 0
      netbox/utilities/testing/utils.py
  88. 98 1
      netbox/utilities/tests/test_counters.py
  89. 101 0
      netbox/utilities/tests/test_html.py
  90. 7 7
      netbox/virtualization/signals.py
  91. 65 1
      netbox/virtualization/tests/test_signals.py
  92. 49 1
      netbox/virtualization/tests/test_views.py
  93. 2 2
      netbox/virtualization/ui/panels.py
  94. 1 1
      netbox/wireless/ui/panels.py
  95. 7 7
      requirements.txt

+ 1 - 1
.claude/skills/add-config-param/SKILL.md

@@ -43,7 +43,7 @@ ConfigParam(
     field=forms.BooleanField,   # or IntegerField, CharField, JSONField, SimpleArrayField
     # field_kwargs only when extra widget/validation config is needed:
     field_kwargs={
-        'widget': forms.Textarea(attrs={'class': 'vLargeTextField'}),
+        'widget': forms.Textarea(attrs={'class': 'font-monospace'}),
     },
 ),
 ```

+ 12 - 0
.github/ISSUE_TEMPLATE/02-bug_report.yaml

@@ -70,3 +70,15 @@ body:
       placeholder: A TypeError exception was raised
     validations:
       required: true
+  - type: textarea
+    attributes:
+      label: Suspected Cause
+      description: >
+        If you have identified the likely root cause(s), please detail your findings
+        here (optional).
+  - type: textarea
+    attributes:
+      label: Proposed Fix
+      description: >
+        If you would like to propose a specific fix likely to resolve this issue, please
+        describe it here (optional).

+ 11 - 0
contrib/generated_schema.json

@@ -440,6 +440,7 @@
                         "100gbase-x-dsfp",
                         "100gbase-x-qsfp28",
                         "100gbase-x-qsfpdd",
+                        "100gbase-x-sfp112",
                         "100gbase-x-sfpdd",
                         "200gbase-x-cfp2",
                         "200gbase-x-qsfp56",
@@ -511,6 +512,15 @@
                         "infiniband-hdr",
                         "infiniband-ndr",
                         "infiniband-xdr",
+                        "infiniband-sdr-4x",
+                        "infiniband-ddr-4x",
+                        "infiniband-qdr-4x",
+                        "infiniband-fdr10-4x",
+                        "infiniband-fdr-4x",
+                        "infiniband-edr-4x",
+                        "infiniband-hdr-4x",
+                        "infiniband-ndr-4x",
+                        "infiniband-xdr-4x",
                         "t1",
                         "e1",
                         "t3",
@@ -541,6 +551,7 @@
                         "extreme-summitstack-128",
                         "extreme-summitstack-256",
                         "extreme-summitstack-512",
+                        "hpe-synergy-interconnect-link",
                         "other"
                     ]
                 },

Разлика између датотеке није приказан због своје велике величине
+ 38 - 141
contrib/openapi.json


+ 3 - 0
docs/configuration/security.md

@@ -8,6 +8,9 @@ Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh
 
 A list of permitted URL schemes referenced when rendering links within NetBox. This list is also enforced when validating the value of URL custom fields. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all the default values as well (excluding those schemes which are not desirable).
 
+!!! note
+    Image sources (`<img src="...">`) are limited to HTTP(S) and relative URLs, subject to `ALLOWED_URL_SCHEMES`.
+
 ---
 
 ## AUTH_PASSWORD_VALIDATORS

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

@@ -1,5 +1,42 @@
 # NetBox v4.6
 
+## v4.6.9 (2026-08-25)
+
+### Enhancements
+
+* [#21387](https://github.com/netbox-community/netbox/issues/21387) - Add InfiniBand 4X interface types
+* [#22660](https://github.com/netbox-community/netbox/issues/22660) - Add an interface type for HPE Synergy interconnect links
+* [#22716](https://github.com/netbox-community/netbox/issues/22716) - Restrict images in rendered Markdown to HTTP(S) and relative URLs
+* [#22998](https://github.com/netbox-community/netbox/issues/22998) - Add a 100GBase-X-SFP112 interface type
+
+### Performance Improvements
+
+* [#23000](https://github.com/netbox-community/netbox/issues/23000) - Prefetch cable terminations to avoid N+1 queries when fetching cables via the GraphQL API
+
+### Bug Fixes
+
+* [#22683](https://github.com/netbox-community/netbox/issues/22683) - Prevent a server error when bulk import validation raises an error referencing a field omitted from the import data
+* [#22812](https://github.com/netbox-community/netbox/issues/22812) - Avoid loading all associated jobs into memory when deleting a custom script or other job-bearing object
+* [#22889](https://github.com/netbox-community/netbox/issues/22889) - Restore the monospace font for text areas on the config revision form
+* [#22922](https://github.com/netbox-community/netbox/issues/22922) - Honor the saving database connection in the scope propagation signal handlers
+* [#22923](https://github.com/netbox-community/netbox/issues/22923) - Clear `current_request` and the query cache when an exception is raised within an `event_tracking()` block
+* [#22929](https://github.com/netbox-community/netbox/issues/22929) - Avoid initializing a second `SideNav` instance for the top header
+* [#22930](https://github.com/netbox-community/netbox/issues/22930) - Fix sidebar navigation initialization at a viewport width of exactly 1200 pixels
+* [#22934](https://github.com/netbox-community/netbox/issues/22934) - Discard queued events when a write performed by a UI view is rolled back
+* [#22944](https://github.com/netbox-community/netbox/issues/22944) - Display the complete role hierarchy in the virtual machine info panel
+* [#22953](https://github.com/netbox-community/netbox/issues/22953) - Restore custom script log messages in the output of the `runscript` management command
+* [#22954](https://github.com/netbox-community/netbox/issues/22954) - Display nested group and platform hierarchies in several info panels
+* [#22957](https://github.com/netbox-community/netbox/issues/22957) - Include ancestors in the breadcrumbs for device roles, platforms, and power panels
+* [#22963](https://github.com/netbox-community/netbox/issues/22963) - Honor the saving database connection in the counter cache signal handlers
+* [#22967](https://github.com/netbox-community/netbox/issues/22967) - Update the cached scope fields of circuit terminations when a location is moved to a different site
+* [#22978](https://github.com/netbox-community/netbox/issues/22978) - Discard queued events when a write performed via the REST API is rolled back
+* [#22985](https://github.com/netbox-community/netbox/issues/22985) - Exempt data file content from browser caching
+* [#22990](https://github.com/netbox-community/netbox/issues/22990) - Correct inconsistent field declarations which prevented certain fields from being edited or cleared via bulk edit
+* [#23007](https://github.com/netbox-community/netbox/issues/23007) - Align the sidebar navigation JavaScript breakpoint with that of the responsive layout
+* [#23013](https://github.com/netbox-community/netbox/issues/23013) - Avoid propagating a location's site assignment to descendant objects when the site has not changed
+
+---
+
 ## v4.6.8 (2026-08-11)
 
 ### Performance Improvements

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

@@ -42,8 +42,8 @@ class DataSourceBulkEditForm(PrimaryModelBulkEditForm):
 
     model = DataSource
     fieldsets = (
-        FieldSet('type', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules', 'comments'),
+        FieldSet('type', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules'),
     )
     nullable_fields = (
-        'description', 'description', 'sync_interval', 'parameters', 'parameters', 'ignore_rules' 'comments',
+        'description', 'sync_interval', 'parameters', 'ignore_rules', 'comments',
     )

+ 0 - 6
netbox/core/forms/model_forms.py

@@ -169,12 +169,6 @@ class ConfigRevisionForm(forms.ModelForm, metaclass=ConfigFormMetaclass):
         model = ConfigRevision
         fields = '__all__'
         widgets = {
-            'BANNER_LOGIN': forms.Textarea(attrs={'class': 'font-monospace'}),
-            'BANNER_MAINTENANCE': forms.Textarea(attrs={'class': 'font-monospace'}),
-            'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
-            'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
-            'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
-            'PROTECTION_RULES': forms.Textarea(attrs={'class': 'font-monospace'}),
             'comment': forms.Textarea(),
         }
 

+ 25 - 0
netbox/core/tests/test_forms.py

@@ -0,0 +1,25 @@
+from django.test import TestCase
+
+from core.forms import ConfigRevisionForm
+
+
+class ConfigRevisionFormTestCase(TestCase):
+
+    def test_code_fields_render_monospace(self):
+        """
+        Config parameters that hold markup or code (banners, JSON) must render their
+        textareas in a monospace font. See #8974 and #22889.
+        """
+        form = ConfigRevisionForm()
+        monospace_fields = (
+            'BANNER_LOGIN',
+            'BANNER_MAINTENANCE',
+            'BANNER_TOP',
+            'BANNER_BOTTOM',
+            'CUSTOM_VALIDATORS',
+            'PROTECTION_RULES',
+        )
+        for name in monospace_fields:
+            with self.subTest(field=name):
+                css_classes = form[name].field.widget.attrs.get('class', '').split()
+                self.assertIn('font-monospace', css_classes)

+ 19 - 0
netbox/core/tests/test_views.py

@@ -105,6 +105,25 @@ class DataFileTestCase(
         )
         DataFile.objects.bulk_create(data_files)
 
+    def test_content_is_not_cacheable(self):
+        """
+        The detail view renders file content inline, which may include plaintext secrets, so the
+        response must instruct the browser not to persist it to its local cache.
+        """
+        datafile = DataFile.objects.first()
+        datafile.data = b'super-secret-password'
+        datafile.save()
+
+        self.add_permissions('core.view_datafile')
+        response = self.client.get(datafile.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+
+        # Confirm the content is in fact rendered in the response
+        self.assertIn('super-secret-password', str(response.content))
+
+        # Confirm the response is not cacheable
+        self.assertNotCacheable(response)
+
 
 class JobTestCase(
     ViewTestCases.GetObjectViewTestCase,

+ 3 - 0
netbox/core/views.py

@@ -12,8 +12,10 @@ 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.decorators import method_decorator
 from django.utils.http import content_disposition_header
 from django.utils.translation import gettext_lazy as _
+from django.views.decorators.cache import never_cache
 from django.views.generic import View
 from django_rq.queues import get_queue_by_index, get_redis_connection
 from django_rq.settings import get_queues_list, get_queues_map
@@ -196,6 +198,7 @@ class DataFileListView(generic.ObjectListView):
 
 
 @register_model_view(DataFile)
+@method_decorator(never_cache, name='dispatch')
 class DataFileView(generic.ObjectView):
     template_name = 'generic/object.html'
     queryset = DataFile.objects.all()

+ 32 - 3
netbox/dcim/choices.py

@@ -1050,6 +1050,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_100GE_CXP = '100gbase-x-cxp'
     TYPE_100GE_CPAK = '100gbase-x-cpak'
     TYPE_100GE_DSFP = '100gbase-x-dsfp'
+    TYPE_100GE_SFP112 = '100gbase-x-sfp112'
     TYPE_100GE_SFP_DD = '100gbase-x-sfpdd'
     TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
     TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd'
@@ -1125,7 +1126,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_64GFC_SFP_PLUS = '64gfc-sfpp'
     TYPE_128GFC_QSFP28 = '128gfc-qsfp28'
 
-    # InfiniBand
+    # InfiniBand 1X
     TYPE_INFINIBAND_SDR = 'infiniband-sdr'
     TYPE_INFINIBAND_DDR = 'infiniband-ddr'
     TYPE_INFINIBAND_QDR = 'infiniband-qdr'
@@ -1136,6 +1137,17 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_INFINIBAND_NDR = 'infiniband-ndr'
     TYPE_INFINIBAND_XDR = 'infiniband-xdr'
 
+    # InfiniBand 4X
+    TYPE_INFINIBAND_SDR_4X = 'infiniband-sdr-4x'
+    TYPE_INFINIBAND_DDR_4X = 'infiniband-ddr-4x'
+    TYPE_INFINIBAND_QDR_4X = 'infiniband-qdr-4x'
+    TYPE_INFINIBAND_FDR10_4X = 'infiniband-fdr10-4x'
+    TYPE_INFINIBAND_FDR_4X = 'infiniband-fdr-4x'
+    TYPE_INFINIBAND_EDR_4X = 'infiniband-edr-4x'
+    TYPE_INFINIBAND_HDR_4X = 'infiniband-hdr-4x'
+    TYPE_INFINIBAND_NDR_4X = 'infiniband-ndr-4x'
+    TYPE_INFINIBAND_XDR_4X = 'infiniband-xdr-4x'
+
     # Serial
     TYPE_T1 = 't1'
     TYPE_E1 = 'e1'
@@ -1175,6 +1187,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
     TYPE_SUMMITSTACK256 = 'extreme-summitstack-256'
     TYPE_SUMMITSTACK512 = 'extreme-summitstack-512'
+    TYPE_HPE_SYNERGY_INTERCONNECT = 'hpe-synergy-interconnect-link'
 
     # Other
     TYPE_OTHER = 'other'
@@ -1363,6 +1376,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 Choice(TYPE_100GE_DSFP, 'DSFP (100GE)'),
                 Choice(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
                 Choice(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
+                Choice(TYPE_100GE_SFP112, 'SFP112 (100GE)'),
                 Choice(TYPE_100GE_SFP_DD, 'SFP-DD (100GE)'),
                 Choice(TYPE_200GE_CFP2, 'CFP2 (200GE)'),
                 Choice(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
@@ -1453,7 +1467,7 @@ class InterfaceTypeChoices(ChoiceSet):
             )
         ),
         (
-            'InfiniBand',
+            'InfiniBand 1X',
             (
                 Choice(TYPE_INFINIBAND_SDR, 'SDR (2 Gbps)'),
                 Choice(TYPE_INFINIBAND_DDR, 'DDR (4 Gbps)'),
@@ -1463,7 +1477,21 @@ class InterfaceTypeChoices(ChoiceSet):
                 Choice(TYPE_INFINIBAND_EDR, 'EDR (25 Gbps)'),
                 Choice(TYPE_INFINIBAND_HDR, 'HDR (50 Gbps)'),
                 Choice(TYPE_INFINIBAND_NDR, 'NDR (100 Gbps)'),
-                Choice(TYPE_INFINIBAND_XDR, 'XDR (250 Gbps)'),
+                Choice(TYPE_INFINIBAND_XDR, 'XDR (200 Gbps)'),
+            )
+        ),
+        (
+            'InfiniBand 4X',
+            (
+                Choice(TYPE_INFINIBAND_SDR_4X, 'SDR 4X (8 Gbps)'),
+                Choice(TYPE_INFINIBAND_DDR_4X, 'DDR 4X (16 Gbps)'),
+                Choice(TYPE_INFINIBAND_QDR_4X, 'QDR 4X (32 Gbps)'),
+                Choice(TYPE_INFINIBAND_FDR10_4X, 'FDR10 4X (40 Gbps)'),
+                Choice(TYPE_INFINIBAND_FDR_4X, 'FDR 4X (56 Gbps)'),
+                Choice(TYPE_INFINIBAND_EDR_4X, 'EDR 4X (100 Gbps)'),
+                Choice(TYPE_INFINIBAND_HDR_4X, 'HDR 4X (200 Gbps)'),
+                Choice(TYPE_INFINIBAND_NDR_4X, 'NDR 4X (400 Gbps)'),
+                Choice(TYPE_INFINIBAND_XDR_4X, 'XDR 4X (800 Gbps)'),
             )
         ),
         (
@@ -1519,6 +1547,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 Choice(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
                 Choice(TYPE_SUMMITSTACK256, 'Extreme SummitStack-256'),
                 Choice(TYPE_SUMMITSTACK512, 'Extreme SummitStack-512'),
+                Choice(TYPE_HPE_SYNERGY_INTERCONNECT, 'HPE Synergy Interconnect Link'),
             )
         ),
         (

+ 15 - 1
netbox/dcim/forms/bulk_edit.py

@@ -1083,7 +1083,7 @@ class PowerFeedBulkEditForm(PrimaryModelBulkEditForm):
         FieldSet('power_panel', 'rack', 'status', 'type', 'mark_connected', 'description', 'tenant'),
         FieldSet('supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Power'))
     )
-    nullable_fields = ('location', 'tenant', 'description', 'comments')
+    nullable_fields = ('rack', 'tenant', 'description', 'comments')
 
 
 #
@@ -1233,6 +1233,10 @@ class ConsolePortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         choices=add_blank_choice(ConsolePortTypeChoices),
         required=False
     )
+    description = forms.CharField(
+        label=_('Description'),
+        required=False
+    )
 
     nullable_fields = ('label', 'type', 'description')
 
@@ -1592,6 +1596,11 @@ class ModuleBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         required=False
     )
+    position = forms.CharField(
+        label=_('Position'),
+        max_length=30,
+        required=False
+    )
     description = forms.CharField(
         label=_('Description'),
         required=False
@@ -1666,6 +1675,11 @@ class InventoryItemTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         queryset=Manufacturer.objects.all(),
         required=False
     )
+    part_id = forms.CharField(
+        label=_('Part ID'),
+        max_length=50,
+        required=False
+    )
 
     nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description')
 

+ 66 - 19
netbox/dcim/graphql/types.py

@@ -2,11 +2,12 @@ from typing import TYPE_CHECKING, Annotated
 
 import strawberry
 import strawberry_django
-from django.db.models import Func, IntegerField
+from django.db.models import Func, IntegerField, Prefetch
 
 from circuits.models import CircuitTermination
 from core.graphql.mixins import ChangelogMixin
 from dcim import models
+from dcim.choices import CableEndChoices
 from extras.graphql.mixins import ConfigContextMixin, ContactsMixin, ImageAttachmentsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from netbox.graphql.optimization import build_gfk_prefetch
@@ -103,6 +104,57 @@ __all__ = (
 )
 
 
+#
+# Cable termination prefetching
+#
+
+# The concrete models which may terminate a cable, mirroring dcim.constants.CABLE_TERMINATION_MODELS
+_CABLE_TERMINATION_MODELS = (
+    CircuitTermination,
+    models.ConsolePort,
+    models.ConsoleServerPort,
+    models.FrontPort,
+    models.Interface,
+    models.PowerFeed,
+    models.PowerOutlet,
+    models.PowerPort,
+    models.RearPort,
+)
+
+_termination_gfk_prefetch = build_gfk_prefetch('termination', _CABLE_TERMINATION_MODELS)
+
+
+def _cable_terminations_prefetch(side, to_attr):
+    """
+    Return a callable which builds a selection-aware Prefetch of a cable's terminations for the
+    given cable end.
+
+    Each end is prefetched under its own `to_attr`: two prefetches of the same relation cannot be
+    merged by the query optimizer, so a shared lookup would break any query selecting both ends.
+    """
+    def prefetch(info):
+        return Prefetch(
+            'terminations',
+            queryset=models.CableTermination.objects.filter(cable_end=side).prefetch_related(
+                _termination_gfk_prefetch(info)
+            ),
+            to_attr=to_attr,
+        )
+
+    return prefetch
+
+
+def _resolve_cable_terminations(cable, side, to_attr):
+    """
+    Return the terminating objects for the given cable end, using the prefetched terminations if
+    available and falling back to the model property otherwise.
+    """
+    if (terminations := getattr(cable, to_attr, None)) is not None:
+        return [ct.termination for ct in terminations]
+
+    return cable._get_x_terminations(side)
+
+
 #
 # Base types
 #
@@ -162,20 +214,7 @@ class CableTerminationType(NetBoxObjectType):
     cable: Annotated['CableType', strawberry.lazy('dcim.graphql.types')] | None
 
     @strawberry_django.field(
-        prefetch_related=build_gfk_prefetch(
-            'termination',
-            [
-                CircuitTermination,
-                models.ConsolePort,
-                models.ConsoleServerPort,
-                models.FrontPort,
-                models.Interface,
-                models.PowerFeed,
-                models.PowerOutlet,
-                models.PowerPort,
-                models.RearPort,
-            ],
-        ),
+        prefetch_related=_termination_gfk_prefetch,
         only=['termination_type', 'termination_id'],
     )
     def termination(self) -> Annotated[
@@ -206,7 +245,10 @@ class CableType(PrimaryObjectType):
 
     terminations: list[CableTerminationType]
 
-    a_terminations: list[Annotated[
+    @strawberry_django.field(
+        prefetch_related=_cable_terminations_prefetch(CableEndChoices.SIDE_A, '_prefetched_a_terminations'),
+    )
+    def a_terminations(self) -> list[Annotated[
         Annotated['CircuitTerminationType', strawberry.lazy('circuits.graphql.types')]
         | Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
@@ -217,9 +259,13 @@ class CableType(PrimaryObjectType):
         | Annotated['PowerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         strawberry.union('CableTerminationTerminationType'),
-    ]]
+    ]]:
+        return _resolve_cable_terminations(self, CableEndChoices.SIDE_A, '_prefetched_a_terminations')
 
-    b_terminations: list[Annotated[
+    @strawberry_django.field(
+        prefetch_related=_cable_terminations_prefetch(CableEndChoices.SIDE_B, '_prefetched_b_terminations'),
+    )
+    def b_terminations(self) -> list[Annotated[
         Annotated['CircuitTerminationType', strawberry.lazy('circuits.graphql.types')]
         | Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
@@ -230,7 +276,8 @@ class CableType(PrimaryObjectType):
         | Annotated['PowerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         strawberry.union('CableTerminationTerminationType'),
-    ]]
+    ]]:
+        return _resolve_cable_terminations(self, CableEndChoices.SIDE_B, '_prefetched_b_terminations')
 
 
 @register_type(

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

@@ -1029,6 +1029,7 @@ class Device(
                          (default). Otherwise, save() will be called on each instance individually.
         """
         model = queryset.model.component_model
+        using = self._state.db
 
         if bulk_create:
             components = [obj.instantiate(device=self) for obj in queryset]
@@ -1047,7 +1048,7 @@ class Device(
                 component._site = self.site
                 component._location = self.location
                 component._rack = self.rack
-            components = model.objects.bulk_create(components)
+            components = model.objects.using(using).bulk_create(components)
             # Prefetch related objects to minimize queries needed during post_save
             prefetch_fields = get_prefetchable_fields(model)
             prefetch_related_objects(components, *prefetch_fields)
@@ -1058,7 +1059,7 @@ class Device(
                     instance=component,
                     created=True,
                     raw=False,
-                    using='default',
+                    using=using,
                     update_fields=None
                 )
         else:
@@ -1073,11 +1074,11 @@ class Device(
                 # Set default values for any applicable custom fields
                 if cf_defaults := CustomField.objects.get_defaults_for_model(model):
                     component.custom_field_data = cf_defaults
-                component.save()
+                component.save(using=using)
                 # Copy module_bay_types from the source template (set by instantiate()).
                 if src := getattr(component, '_source_template', None):
                     if hasattr(component, 'module_bay_types'):
-                        component.module_bay_types.set(src.module_bay_types.all())
+                        component.module_bay_types.set(src.module_bay_types.db_manager(using).all())
 
     def save(self, *args, **kwargs):
         is_new = not bool(self.pk)

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

@@ -517,6 +517,8 @@ class Module(TrackingModelMixin, PrimaryModel):
     def _save_new(self, *args, **kwargs):
         super().save(*args, **kwargs)
 
+        using = self._state.db
+
         adopt_components = getattr(self, '_adopt_components', False)
         disable_replication = getattr(self, '_disable_replication', False)
 
@@ -543,7 +545,9 @@ class Module(TrackingModelMixin, PrimaryModel):
             # Prefetch installed components
             installed_components = {
                 component.name: component
-                for component in getattr(self.device, component_attribute).filter(module__isnull=True)
+                for component in getattr(self.device, component_attribute).db_manager(using).filter(
+                    module__isnull=True
+                )
             }
 
             # Get the template for the module type.
@@ -579,21 +583,22 @@ class Module(TrackingModelMixin, PrimaryModel):
             # Bulk-create new instances. ModuleBay is ltree-backed: its parent is set
             # in ModuleBayTemplate.instantiate() (bulk_create bypasses ModuleBay.save()),
             # and the BEFORE INSERT trigger derives path/sort_path from parent_id per row.
-            component_model.objects.bulk_create(create_instances)
+            component_model.objects.using(using).bulk_create(create_instances)
 
             # Copy M2M module_bay_types from template to new ModuleBay instances.
             if component_model is ModuleBay:
                 for component in create_instances:
                     if src := getattr(component, '_source_template', None):
-                        component.module_bay_types.set(src.module_bay_types.all())
+                        component.module_bay_types.set(src.module_bay_types.db_manager(using).all())
 
+            # Emit the post_save signal for each newly created object
             for component in create_instances:
                 post_save.send(
                     sender=component_model,
                     instance=component,
                     created=True,
                     raw=False,
-                    using='default',
+                    using=using,
                     update_fields=None
                 )
 
@@ -608,16 +613,17 @@ class Module(TrackingModelMixin, PrimaryModel):
                     instance.parent = self.module_bay
                 update_fields = ['module', 'parent']
 
-            component_model.objects.bulk_update(
+            component_model.objects.using(using).bulk_update(
                 update_instances, update_fields, batch_size=settings.BULK_UPDATE_CHUNK_SIZE
             )
+            # Emit the post_save signal for each updated object
             for component in update_instances:
                 post_save.send(
                     sender=component_model,
                     instance=component,
                     created=False,
                     raw=False,
-                    using='default',
+                    using=using,
                     update_fields=update_fields
                 )
 

+ 182 - 14
netbox/dcim/signals.py

@@ -1,7 +1,8 @@
 import logging
 
+from django.db import transaction
 from django.db.models import Q
-from django.db.models.signals import post_delete, post_save
+from django.db.models.signals import post_delete, post_save, pre_save
 from django.dispatch import receiver
 
 from dcim.choices import CableEndChoices, LinkStatusChoices
@@ -26,32 +27,199 @@ from .models.cables import trace_paths
 from .search import DeviceIndex
 from .utils import create_cablepaths, rebuild_cable_paths, rebuild_paths
 
+# The scope-relevant fields stashed before each model's save by cache_presave_scope_fields(),
+# so that the post_save handlers can tell whether the save actually changed any of them and
+# skip their work when it did not. Only the models whose cascades are still carried out in
+# Python are listed: the denormalized columns on device components, cable terminations, and
+# the CachedScopeMixin models are maintained by database triggers (see the
+# 'denormalization_triggers' migrations), which need no such stash.
+STASHED_SCOPE_FIELDS = {
+    Location: ('site_id',),
+    Rack: ('site_id', 'location_id'),
+}
+
+
 #
 # Location/rack/device assignment
 #
 
+def cache_presave_scope_fields(instance, raw=False, using=None, **kwargs):
+    """
+    Stash the scope-relevant field values currently in the database so that the post_save
+    handlers below can determine whether this save actually changed any of them. The read
+    locks the row, so overlapping saves of the same object serialize here and the
+    comparison always runs against the final committed state.
+
+    No stash is taken for a raw save, for a new instance, or outside a transaction: in
+    autocommit, this read and the subsequent UPDATE would run in separate transactions, so
+    the comparison could race a concurrent save. In each of those cases any stash left by a
+    previous save of the same instance is cleared, as it no longer reflects the current
+    database state. The post_save handlers treat a missing stash as "the values may have
+    changed" and rebuild or repair unconditionally — except on a raw save, which they skip
+    before consulting the stash at all, making the clearing there purely defensive.
+    """
+    if raw or instance.pk is None or not transaction.get_connection(using).in_atomic_block:
+        # Clear any stash left by a previous save of this instance.
+        instance._presave_scope_fields = None
+        return
+    fields = STASHED_SCOPE_FIELDS[instance.__class__]
+    instance._presave_scope_fields = (
+        instance.__class__.objects.using(using)
+        .filter(pk=instance.pk)
+        .order_by()  # Clear default ordering to avoid JOINs
+        .select_for_update(no_key=True)  # no_key: Avoid blocking foreign key inserts that reference this object
+        .values(*fields)
+        .first()
+    )
+
+
+for _model in STASHED_SCOPE_FIELDS:
+    pre_save.connect(cache_presave_scope_fields, sender=_model)
+
+
+# update_fields may name a foreign key by either its name ('site') or its attname
+# ('site_id') — Django accepts both — so deciding whether a save wrote a stashed field has
+# to test both forms. Derived from each model's own meta rather than written out, so the two
+# spellings cannot disagree.
+STASHED_FIELD_ALIASES = {
+    model: {
+        field.attname: frozenset((field.attname, field.name))
+        for field in model._meta.concrete_fields
+        if field.attname in fields
+    }
+    for model, fields in STASHED_SCOPE_FIELDS.items()
+}
+
+
+def _unwritten_scope_fields(instance, update_fields):
+    """
+    Return the scope-relevant fields listed for the instance's model which this save did
+    not write.
+    """
+    if update_fields is None:
+        return frozenset()
+    aliases = STASHED_FIELD_ALIASES[instance.__class__]
+    return frozenset(field for field, names in aliases.items() if names.isdisjoint(update_fields))
+
+
+def _scope_fields_unchanged(instance, update_fields=None):
+    """
+    Return True when the values stashed immediately before this save show that it changed
+    none of the scope-relevant fields listed for the instance's model, meaning the caller's
+    propagation or rebuild can be skipped in its entirety.
+    """
+    prev = getattr(instance, '_presave_scope_fields', None)
+    if prev is None:
+        return False
+    unwritten = _unwritten_scope_fields(instance, update_fields)
+    return all(value == getattr(instance, field) for field, value in prev.items() if field not in unwritten)
+
+
+def _scope_values(instance, update_fields, using):
+    """
+    Return the values the scope-relevant fields hold in the database once this save has
+    been applied, keyed by field name, for the propagation handlers to push down.
+
+    Must be called inside the transaction the propagation runs in: the fallback read below
+    locks the row for the remainder of it, so that no concurrent write can move the object
+    out from under the values being propagated.
+
+    Returns None when the row cannot be read at all, leaving the caller nothing to
+    propagate.
+    """
+    values = {field: getattr(instance, field) for field in STASHED_SCOPE_FIELDS[instance.__class__]}
+    unwritten = _unwritten_scope_fields(instance, update_fields)
+    if not unwritten:
+        return values
+    stashed = getattr(instance, '_presave_scope_fields', None)
+    if stashed is None:
+        stashed = (
+            instance.__class__.objects.using(using)
+            .filter(pk=instance.pk)
+            # Cleared for the same reason as in cache_presave_scope_fields().
+            .order_by()
+            .select_for_update(no_key=True)
+            .values(*unwritten)
+            .first()
+        )
+        # No row to read: it was deleted after this save committed, or was never inserted
+        # (an instance with a pre-assigned primary key).
+        if stashed is None:
+            return None
+    values.update({field: stashed[field] for field in unwritten})
+    return values
+
 
 @receiver(post_save, sender=Location)
-def handle_location_site_change(instance, created, **kwargs):
+def handle_location_site_change(instance, created, raw=False, using=None, update_fields=None, **kwargs):
     """
-    Cascade a Location's Site assignment down to the Racks, Devices, and PowerPanels it contains
-    (and to descendant Locations).
+    Cascade a Location's Site assignment down to the Racks, Devices, and PowerPanels it
+    contains (and to descendant Locations). All updates are queryset update() calls, which
+    fire no signals and generate no change records for the affected objects; the
+    denormalized columns on device components and cable terminations are refreshed by the
+    database triggers those updates fire in turn.
+
+    Each query is pinned to the connection the Location was saved on: on an installation
+    with database routers configured, letting the router pick the alias would both write to
+    a different database than the one being saved and leave the row locks below outside the
+    transaction opened here. For the same reason the new Site is assigned by ID: reading
+    instance.site would fetch the related object over a router-selected connection whenever
+    the save left it uncached (a rename, say).
+
+    When the values read from the database immediately before this save show that the Site
+    assignment is unchanged, the propagation is skipped: every value written below is
+    derived from it, so there is nothing for the descendants to pick up. A raw save is
+    skipped outright.
     """
-    if not created:
-        chunked_update(instance.get_descendants(), site=instance.site)
-        locations = instance.get_descendants(include_self=True).values_list('pk', flat=True)
-        chunked_update(Rack.objects.filter(location__in=locations), site=instance.site)
-        chunked_update(Device.objects.filter(location__in=locations), site=instance.site)
-        chunked_update(PowerPanel.objects.filter(location__in=locations), site=instance.site)
+    if created or raw:
+        return
+
+    # Skip the propagation when this save left the Site assignment untouched.
+    if _scope_fields_unchanged(instance, update_fields):
+        return
+
+    with transaction.atomic(using=using, savepoint=False):
+        scope = _scope_values(instance, update_fields, using)
+        if scope is None:
+            return
+        site_id = scope['site_id']
+        chunked_update(instance.get_descendants().using(using), site_id=site_id)
+        # Materialized once so every statement below sees the same membership, even if a
+        # concurrent commit renumbers the tree mid-handler.
+        locations = list(instance.get_descendants(include_self=True).using(using).values_list('pk', flat=True))
+        chunked_update(Rack.objects.using(using).filter(location__in=locations), site_id=site_id)
+        chunked_update(Device.objects.using(using).filter(location__in=locations), site_id=site_id)
+        chunked_update(PowerPanel.objects.using(using).filter(location__in=locations), site_id=site_id)
 
 
 @receiver(post_save, sender=Rack)
-def handle_rack_site_change(instance, created, **kwargs):
+def handle_rack_site_change(instance, created, raw=False, using=None, update_fields=None, **kwargs):
     """
-    Cascade a Rack's Site/Location assignment down to the Devices it contains.
+    Cascade a Rack's Site/Location assignment down to the Devices it contains; the
+    denormalized columns on those Devices' components and cable terminations are refreshed
+    by the database triggers the update fires in turn. Queries are pinned to the connection
+    the Rack was saved on, and the new values are assigned by ID so that no related object
+    is fetched over a router-selected connection.
+
+    A save which changed neither assignment propagates nothing and is skipped, as does a
+    raw save.
     """
-    if not created:
-        chunked_update(Device.objects.filter(rack=instance), site=instance.site, location=instance.location)
+    if created or raw:
+        return
+
+    # Skip the propagation when this save left the Site and Location assignments untouched.
+    if _scope_fields_unchanged(instance, update_fields):
+        return
+
+    with transaction.atomic(using=using, savepoint=False):
+        scope = _scope_values(instance, update_fields, using)
+        if scope is None:
+            return
+        chunked_update(
+            Device.objects.using(using).filter(rack=instance),
+            site_id=scope['site_id'],
+            location_id=scope['location_id'],
+        )
 
 
 #

+ 112 - 0
netbox/dcim/tests/test_api.py

@@ -2,7 +2,9 @@ import json
 
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
+from django.db import connection
 from django.test import override_settings, tag
+from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
 from django.utils.translation import gettext as _
 from rest_framework import status
@@ -11,6 +13,7 @@ from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange, ObjectType
 from dcim.choices import *
 from dcim.constants import *
+from dcim.graphql.types import _CABLE_TERMINATION_MODELS
 from dcim.models import *
 from extras.models import ConfigTemplate, Tag
 from ipam.choices import VLANQinQRoleChoices
@@ -4834,6 +4837,115 @@ class CableTestCase(APIViewTestCases.APIViewTestCase):
 
                 self.assertSetEqual(set(ids), expected)
 
+    def test_graphql_cable_terminations_query_count(self):
+        """
+        Resolving CableType.a_terminations and CableType.b_terminations must take a constant number
+        of queries, regardless of how many cables (and hence terminations) are returned.
+
+        Also exercises selecting both cable ends in a single query: each end must be prefetched
+        under its own attribute, as two prefetches of the same relation cannot be merged.
+        """
+        self.add_permissions(
+            'dcim.view_cable',
+            'dcim.view_device',
+            'dcim.view_devicerole',
+            'dcim.view_devicetype',
+            'dcim.view_interface',
+            'dcim.view_platform',
+        )
+
+        # Reuse existing fixtures from setUpTestData()
+        site = Site.objects.get(slug='site-1')
+        devicetype = DeviceType.objects.get(slug='device-type-1')
+        role = DeviceRole.objects.get(slug='device-role-1')
+
+        # Create an isolated topology of cables between two devices
+        devices = (
+            Device(device_type=devicetype, role=role, name='GQL Count Device A', site=site),
+            Device(device_type=devicetype, role=role, name='GQL Count Device B', site=site),
+        )
+        Device.objects.bulk_create(devices)
+
+        interfaces = []
+        for device in devices:
+            for i in range(0, 8):
+                interfaces.append(
+                    Interface(device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name=f'gql{i}')
+                )
+        Interface.objects.bulk_create(interfaces)
+
+        expected_terminations = {}
+        for i in range(0, 8):
+            cable = Cable(
+                a_terminations=[interfaces[i]],
+                b_terminations=[interfaces[i + 8]],
+                label=f'GQL Count Cable {i}',
+            )
+            cable.save()
+            expected_terminations[str(cable.pk)] = (interfaces[i].pk, interfaces[i + 8].pk)
+
+        url = reverse('graphql')
+        termination_fields = """
+            ... on InterfaceType {
+              id
+              name
+              device { id name platform { id } role { id } device_type { id } }
+            }
+        """
+
+        def build_query(limit):
+            return f"""{{
+              cable_list(
+                filters: {{ label: {{ contains: "GQL Count Cable " }} }},
+                pagination: {{ limit: {limit} }}
+              ) {{
+                id
+                a_terminations {{ {termination_fields} }}
+                b_terminations {{ {termination_fields} }}
+              }}
+            }}"""
+
+        # Warm per-process caches (e.g. ContentType) so they are not counted below
+        self.client.post(url, data={'query': build_query(1)}, format='json', **self.header)
+
+        query_counts = {}
+        for limit in (2, 8):
+            with CaptureQueriesContext(connection) as queries:
+                response = self.client.post(
+                    url, data={'query': build_query(limit)}, format='json', **self.header
+                )
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            data = response.json()
+            self.assertNotIn('errors', data)
+
+            rows = data['data']['cable_list']
+            self.assertEqual(len(rows), limit)
+
+            # Both ends must resolve to the expected interfaces
+            for row in rows:
+                interface_a, interface_b = expected_terminations[row['id']]
+                self.assertEqual([t['id'] for t in row['a_terminations']], [str(interface_a)])
+                self.assertEqual([t['id'] for t in row['b_terminations']], [str(interface_b)])
+
+            query_counts[limit] = len(queries.captured_queries)
+
+        self.assertEqual(
+            query_counts[2],
+            query_counts[8],
+            f"Query count scales with the number of cables returned: {query_counts}"
+        )
+
+    def test_graphql_cable_termination_models(self):
+        """
+        The GraphQL prefetch hint for a cable termination enumerates the terminating models
+        explicitly; a model missing from that list silently falls back to an unoptimized query
+        rather than raising, so guard against drift from CABLE_TERMINATION_MODELS.
+        """
+        self.assertSetEqual(
+            {(model._meta.app_label, model._meta.model_name) for model in _CABLE_TERMINATION_MODELS},
+            {(ot.app_label, ot.model) for ot in ObjectType.objects.filter(CABLE_TERMINATION_MODELS)},
+        )
+
 
 class CableTerminationTestCase(
     APIViewTestCases.GetObjectViewTestCase,

+ 429 - 5
netbox/dcim/tests/test_signals.py

@@ -3,9 +3,11 @@ from unittest.mock import MagicMock, patch
 
 from django.apps import apps
 from django.contrib.contenttypes.models import ContentType
-from django.db import connection
-from django.test import SimpleTestCase, TestCase
+from django.db import connection, transaction
+from django.test import SimpleTestCase, TestCase, TransactionTestCase, override_settings
+from django.test.utils import CaptureQueriesContext
 
+from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
 from dcim import signals
 from dcim.choices import CableEndChoices, CableProfileChoices, LinkStatusChoices
 from dcim.models import (
@@ -24,6 +26,7 @@ from dcim.models import (
     PowerPanel,
     Rack,
     RearPort,
+    Region,
     Site,
     SiteGroup,
     VirtualChassis,
@@ -32,16 +35,49 @@ from dcim.models.device_components import ComponentModel
 from dcim.models.mixins import CachedScopeMixin
 from ipam.models import Prefix
 from netbox.plugins import PluginConfig
+from utilities.testing import PinnedConnectionRouter
 from virtualization.models import Cluster, ClusterType
 from wireless.models import WirelessLAN
 
 
-class LocationSiteChangeSignalTestCase(TestCase):
+class ScopePropagationCaptureMixin:
+    """
+    Helper for asserting whether a save propagated to the tables its post_save handler
+    rewrites.
+
+    Only the tables the handler itself rewrites are listed. The device components and
+    cable terminations are refreshed by database triggers, which issue their UPDATEs
+    inside the database where no query capture can see them. Neither is the saved
+    object's own table, which carries the save's own UPDATE.
+    """
+    propagation_tables = frozenset()
+
+    def capture_propagation_updates(self, obj, raw=False, update_fields=None):
+        with CaptureQueriesContext(connection) as ctx:
+            if raw:
+                obj.save_base(raw=True)
+            elif update_fields is not None:
+                obj.save(update_fields=update_fields)
+            else:
+                obj.save()
+
+        return {
+            table for table in self.propagation_tables
+            for q in ctx.captured_queries
+            # The config-context cache invalidation in extras.signals writes to dcim_device on
+            # an upstream save too, so matching the table alone would report a propagation that
+            # never ran. It is identifiable by the column it nulls.
+            if q['sql'].startswith(f'UPDATE "{table}"') and '_config_context_data' not in q['sql']
+        }
+
+
+class LocationSiteChangeSignalTestCase(ScopePropagationCaptureMixin, TestCase):
     """
     Verify dcim.signals.handle_location_site_change propagates a Location's new Site to
     every descendant Location, Rack, Device, PowerPanel, and component when the parent
     Location's site assignment changes.
     """
+    propagation_tables = frozenset({'dcim_rack', 'dcim_device', 'dcim_powerpanel'})
 
     @classmethod
     def setUpTestData(cls):
@@ -79,16 +115,205 @@ class LocationSiteChangeSignalTestCase(TestCase):
         self.assertEqual(interface._site, self.site_b)
         self.assertEqual(power_panel.site, self.site_b)
 
+    def test_changing_location_site_updates_circuittermination_caches(self):
+        # CircuitTermination caches its scope ancestry under termination_type/termination_id
+        # rather than under CachedScopeMixin's scope field, and is kept current by the
+        # denormalization trigger sourced from dcim_location. Both the
+        # moved Location's own terminations and those of its descendants must be repaired
+        # here, region and site group included. Origin and destination Sites are given
+        # distinct regions and groups so a value left stale is distinguishable from one that
+        # was never set.
+        origin_region = Region.objects.create(name='Region C', slug='region-c')
+        origin_group = SiteGroup.objects.create(name='Group C', slug='group-c')
+        origin = Site.objects.create(
+            name='Site C', slug='site-c', region=origin_region, group=origin_group
+        )
+        region = Region.objects.create(name='Region D', slug='region-d')
+        group = SiteGroup.objects.create(name='Group D', slug='group-d')
+        site = Site.objects.create(name='Site D', slug='site-d', region=region, group=group)
+        parent_location = Location.objects.create(name='Parent', slug='parent', site=origin)
+        child_location = Location.objects.create(name='Child', slug='child', site=origin, parent=parent_location)
+        provider = Provider.objects.create(name='Provider', slug='provider')
+        circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
+        circuit = Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type)
+        termination_a = CircuitTermination.objects.create(
+            circuit=circuit, term_side='A', termination=parent_location
+        )
+        termination_z = CircuitTermination.objects.create(
+            circuit=circuit, term_side='Z', termination=child_location
+        )
+        for termination in (termination_a, termination_z):
+            self.assertEqual(termination._site, origin)
+            self.assertEqual(termination._region, origin_region)
+            self.assertEqual(termination._site_group, origin_group)
+
+        parent_location.site = site
+        parent_location.save()
+
+        for termination, location in ((termination_a, parent_location), (termination_z, child_location)):
+            termination.refresh_from_db()
+            self.assertEqual(termination._location, location)
+            self.assertEqual(termination._site, site)
+            self.assertEqual(termination._region, region)
+            self.assertEqual(termination._site_group, group)
+
     def test_creating_location_does_not_attempt_to_propagate(self):
         # Should not raise — newly-created locations have no descendants.
         Location.objects.create(name='New', slug='new', site=self.site_a)
 
+    def _seed_location_with_children(self):
+        location = Location.objects.create(name='Parent', slug='parent', site=self.site_a)
+        device = Device.objects.create(
+            name='Device',
+            site=self.site_a,
+            location=location,
+            device_type=self.device_type,
+            role=self.device_role,
+        )
+        Interface.objects.create(device=device, name='Interface 1')
+        Rack.objects.create(name='Rack', site=self.site_a, location=location)
+        PowerPanel.objects.create(name='Panel', site=self.site_a, location=location)
+        return location
+
+    def test_unchanged_site_skips_propagation(self):
+        # Every value the handler writes is derived from the Location's site assignment, so a
+        # save which leaves it alone has nothing to propagate and must not rewrite a single
+        # descendant row. Rewriting them is not merely wasted work: PostgreSQL writes a new
+        # tuple version for every row an UPDATE matches, and holds a row lock on each for the
+        # remainder of the transaction.
+        location = self._seed_location_with_children()
+        location.description = 'updated'
+
+        self.assertEqual(self.capture_propagation_updates(location), set())
+
+    def test_changed_site_propagates(self):
+        # Counterpart to the test above, which would pass vacuously if these UPDATEs stopped
+        # being issued (or their tables were renamed) rather than merely being skipped.
+        location = self._seed_location_with_children()
+        location.site = self.site_b
+
+        self.assertEqual(self.capture_propagation_updates(location), self.propagation_tables)
+
+    def test_raw_save_skips_propagation(self):
+        # raw=True is set only by Django's loaddata pathway, whose fixture already carries the
+        # denormalized values for every object it loads, so the propagation would rewrite each
+        # matched row with what it already holds.
+        location = self._seed_location_with_children()
+        location.site = self.site_b
+
+        self.assertEqual(self.capture_propagation_updates(location, raw=True), set())
+
+    def test_stale_partial_save_does_not_propagate_an_unwritten_site(self):
+        # A save passing update_fields writes only the fields it names, so an omitted field
+        # keeps whatever the database holds no matter what the instance carries. This instance
+        # was loaded before the move below, so its in-memory site is one the database no longer
+        # holds and this save does not write: propagating it would push every descendant back
+        # to a site the Location itself has left.
+        location = self._seed_location_with_children()
+        stale = Location.objects.get(pk=location.pk)
+        self.assertEqual(stale.site, self.site_a)
+
+        location.site = self.site_b
+        location.save()
+
+        stale.description = 'updated'
+        self.assertEqual(
+            self.capture_propagation_updates(stale, update_fields=['description']), set()
+        )
+
+        # Nothing beneath the Location was dragged back to site_a.
+        self.assertEqual(Rack.objects.get(location=location).site, self.site_b)
+        device = Device.objects.get(location=location)
+        self.assertEqual(device.site, self.site_b)
+        self.assertEqual(Interface.objects.get(device=device)._site, self.site_b)
+
+    def test_partial_save_naming_the_field_still_propagates(self):
+        # The converse of the test above: a save which really did write the site must still
+        # propagate. update_fields may name a foreign key by its field name...
+        location = self._seed_location_with_children()
+        location.site = self.site_b
+
+        self.assertEqual(
+            self.capture_propagation_updates(location, update_fields=['site']),
+            self.propagation_tables,
+        )
+
+    def test_partial_save_naming_the_attname_still_propagates(self):
+        # ...or by its attname, which Django accepts equally. Deciding whether a guarded field
+        # was written has to recognise both spellings, or a real move named this way would be
+        # mistaken for an unwritten field and silently skipped.
+        location = self._seed_location_with_children()
+        location.site = self.site_b
+
+        self.assertEqual(
+            self.capture_propagation_updates(location, update_fields=['site_id']),
+            self.propagation_tables,
+        )
+
+    def test_raw_save_does_not_reuse_a_previous_saves_stash(self):
+        # A raw save takes no stash of its own, so it must clear the one left by the previous
+        # save of the same instance: comparing against a snapshot of the database as it stood
+        # before an earlier write can report the propagated fields as unchanged when they are
+        # not. The raw guard above means no handler consults the stash on this save, making the
+        # clearing defensive — but it keeps the invariant that a stash never outlives its save,
+        # so a later reader cannot be handed a stale one.
+        location = self._seed_location_with_children()
+        location.save()
+        self.assertIsNotNone(location._presave_scope_fields)
+
+        location.save_base(raw=True)
+
+        self.assertIsNone(location._presave_scope_fields)
+
 
-class RackSiteChangeSignalTestCase(TestCase):
+class LocationSiteChangeAutocommitTestCase(TransactionTestCase):
+    """
+    Exercise the autocommit save path, which TestCase cannot reach (it wraps every test in a
+    transaction). Outside an atomic block the pre-save read and the save's UPDATE run in
+    separate transactions, so the skip guard is disabled there: the stash is cleared and the
+    propagation runs unconditionally.
+
+    Note: TransactionTestCase teardown flushes all tables, which removes rows seeded by data
+    migrations from a --keepdb database (e.g. the dcim.0206 ModuleTypeProfiles). A fresh test
+    database restores them.
+    """
+
+    def test_autocommit_noop_save_always_propagates(self):
+        site = Site.objects.create(name='Site', slug='site')
+        other_site = Site.objects.create(name='Other Site', slug='other-site')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
+        device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+        location = Location.objects.create(name='Loc', slug='loc', site=site)
+        device = Device.objects.create(
+            name='Device', site=site, location=location, device_type=device_type, role=device_role
+        )
+        interface = Interface.objects.create(device=device, name='Interface 1')
+
+        # A transactional save first, so the instance carries a stash. The subsequent
+        # autocommit save must clear it rather than compare against a previous save's values.
+        with transaction.atomic():
+            location.save()
+
+        # Poison a propagated column via a signal-less update; an unconditional propagation
+        # repairs it, and the components follow via the trigger on dcim_device.
+        Device.objects.filter(pk=device.pk).update(site=other_site)
+        Interface.objects.filter(pk=interface.pk).update(_site=other_site)
+
+        location.save()  # Autocommit: no stash, unconditional propagation
+
+        device.refresh_from_db()
+        interface.refresh_from_db()
+        self.assertEqual(device.site, site)
+        self.assertEqual(interface._site, site)
+
+
+class RackSiteChangeSignalTestCase(ScopePropagationCaptureMixin, TestCase):
     """
     Verify dcim.signals.handle_rack_site_change propagates a Rack's site/location to its
-    Devices and their components when the Rack is moved.
+    Devices and their components when the Rack is moved, and only then.
     """
+    propagation_tables = frozenset({'dcim_device'})
 
     @classmethod
     def setUpTestData(cls):
@@ -121,6 +346,205 @@ class RackSiteChangeSignalTestCase(TestCase):
         self.assertEqual(interface._site, self.site_b)
         self.assertEqual(interface._location, self.location_b)
 
+    def _seed_rack_with_devices(self):
+        rack = Rack.objects.create(name='Rack', site=self.site_a)
+        device = Device.objects.create(
+            name='Device',
+            site=self.site_a,
+            rack=rack,
+            device_type=self.device_type,
+            role=self.device_role,
+        )
+        Interface.objects.create(device=device, name='Interface 1')
+        return rack
+
+    def test_unchanged_scope_skips_propagation(self):
+        # Both values the handler writes are derived from the Rack's site and location
+        # assignments, so a save which leaves both alone must not rewrite a single device or
+        # component row.
+        rack = self._seed_rack_with_devices()
+        rack.description = 'updated'
+
+        self.assertEqual(self.capture_propagation_updates(rack), set())
+
+    def test_changed_site_propagates(self):
+        # Counterpart to the test above, which would pass vacuously if these UPDATEs stopped
+        # being issued (or their tables were renamed) rather than merely being skipped.
+        rack = self._seed_rack_with_devices()
+        rack.site = self.site_b
+
+        self.assertEqual(self.capture_propagation_updates(rack), self.propagation_tables)
+
+    def test_changed_location_propagates(self):
+        # Location moves within the same Site must propagate too: the guard covers both
+        # fields, not just the Site.
+        rack = self._seed_rack_with_devices()
+        rack.site = self.site_b
+        rack.save()
+        rack.location = self.location_b
+
+        self.assertEqual(self.capture_propagation_updates(rack), self.propagation_tables)
+
+    def test_raw_save_skips_propagation(self):
+        # raw=True is set only by Django's loaddata pathway, whose fixture already carries the
+        # denormalized values for every object it loads, so the propagation would rewrite each
+        # matched row with what it already holds.
+        rack = self._seed_rack_with_devices()
+        rack.site = self.site_b
+
+        self.assertEqual(self.capture_propagation_updates(rack, raw=True), set())
+
+    def test_stale_partial_save_does_not_propagate_an_unwritten_scope(self):
+        # As for Location: this instance was loaded before the move below, so neither of its
+        # in-memory scope values is one this save writes, and neither may be propagated.
+        rack = self._seed_rack_with_devices()
+        stale = Rack.objects.get(pk=rack.pk)
+
+        rack.site = self.site_b
+        rack.location = self.location_b
+        rack.save()
+
+        stale.description = 'updated'
+        self.assertEqual(
+            self.capture_propagation_updates(stale, update_fields=['description']), set()
+        )
+
+        device = Device.objects.get(rack=rack)
+        self.assertEqual(device.site, self.site_b)
+        self.assertEqual(device.location, self.location_b)
+        interface = Interface.objects.get(device=device)
+        self.assertEqual(interface._site, self.site_b)
+        self.assertEqual(interface._location, self.location_b)
+
+
+class StashedScopeFieldsRegistrationTestCase(TestCase):
+    """
+    Verify cache_presave_scope_fields() is connected for every model in
+    signals.STASHED_SCOPE_FIELDS, and that each entry's fields resolve. An entry whose
+    receiver was never connected would leave the post_save handlers reading its stash
+    finding none, and doing their work unconditionally on every save.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        site = Site.objects.create(name='Site', slug='site')
+        location = Location.objects.create(name='Location', slug='location', site=site)
+        cls.instances = {
+            Location: location,
+            Rack: Rack.objects.create(name='Rack', site=site, location=location),
+        }
+
+    def test_every_mapped_model_stashes_its_fields_on_save(self):
+        # TestCase wraps each test in a transaction, so every save below takes a stash.
+        self.assertEqual(set(self.instances), set(signals.STASHED_SCOPE_FIELDS))
+
+        for model, fields in signals.STASHED_SCOPE_FIELDS.items():
+            with self.subTest(model=model.__name__):
+                instance = self.instances[model]
+                instance.save()
+
+                self.assertEqual(instance._presave_scope_fields.keys(), set(fields))
+
+    def test_every_mapped_field_resolves_to_both_spellings(self):
+        # STASHED_FIELD_ALIASES is derived from the model meta, so a field name which stopped
+        # resolving would drop out of it silently — and a field missing from it is one that
+        # update_fields can never mark as written, permanently skipping its propagation.
+        self.assertEqual(set(signals.STASHED_FIELD_ALIASES), set(signals.STASHED_SCOPE_FIELDS))
+
+        for model, fields in signals.STASHED_SCOPE_FIELDS.items():
+            with self.subTest(model=model.__name__):
+                aliases = signals.STASHED_FIELD_ALIASES[model]
+                self.assertEqual(set(aliases), set(fields))
+                for attname, names in aliases.items():
+                    # Both the field name and its attname, which update_fields may use
+                    # interchangeably.
+                    field = model._meta.get_field(attname.removesuffix('_id'))
+                    self.assertEqual(names, frozenset((field.name, field.attname)))
+
+
+class ScopeSignalConnectionTestCase(TestCase):
+    """
+    Verify the scope-propagation handlers issue every query against the connection the
+    saved object was written to, rather than letting DATABASE_ROUTERS select one. On an
+    installation with routers configured (e.g. netbox_branching), a routed query both
+    writes to the wrong database and falls outside the transaction opened by the handler,
+    which makes the handler's select_for_update() raise.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site_a = Site.objects.create(name='Site A', slug='site-a')
+        cls.site_b = Site.objects.create(name='Site B', slug='site-b')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
+        cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
+        cls.device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+
+    def test_location_save_pins_queries_to_saving_connection(self):
+        parent = Location.objects.create(name='Parent', slug='parent', site=self.site_a)
+        child = Location.objects.create(name='Child', slug='child', site=self.site_a, parent=parent)
+        rack = Rack.objects.create(name='Rack', site=self.site_a, location=parent)
+        device = Device.objects.create(
+            name='Device',
+            site=self.site_a,
+            location=parent,
+            device_type=self.device_type,
+            role=self.device_role,
+        )
+        interface = Interface.objects.create(device=device, name='Interface 1')
+        power_panel = PowerPanel.objects.create(name='Panel', site=self.site_a, location=parent)
+        cluster_type = ClusterType.objects.create(name='Cluster Type', slug='cluster-type')
+        cluster = Cluster.objects.create(name='Cluster', type=cluster_type, scope=child)
+
+        # Re-fetch and assign the new Site by ID, leaving the site relation uncached: a
+        # handler which reads instance.site rather than instance.site_id would fetch it
+        # over a routed connection, which is what the Site entry below catches.
+        parent = Location.objects.get(pk=parent.pk)
+        parent.site_id = self.site_b.pk
+        router = PinnedConnectionRouter(
+            CableTermination,
+            CircuitTermination,
+            Cluster,
+            Device,
+            Interface,
+            PowerPanel,
+            Prefix,
+            Rack,
+            Site,
+            WirelessLAN,
+        )
+        with override_settings(DATABASE_ROUTERS=[router]):
+            parent.save()
+
+        for obj in (child, rack, device, power_panel):
+            obj.refresh_from_db()
+            self.assertEqual(obj.site, self.site_b)
+        interface.refresh_from_db()
+        self.assertEqual(interface._site, self.site_b)
+        cluster.refresh_from_db()
+        self.assertEqual(cluster._site, self.site_b)
+
+    def test_rack_save_pins_queries_to_saving_connection(self):
+        rack = Rack.objects.create(name='Rack', site=self.site_a)
+        device = Device.objects.create(
+            name='Device',
+            site=self.site_a,
+            rack=rack,
+            device_type=self.device_type,
+            role=self.device_role,
+        )
+        interface = Interface.objects.create(device=device, name='Interface 1')
+
+        rack = Rack.objects.get(pk=rack.pk)
+        rack.site_id = self.site_b.pk
+        router = PinnedConnectionRouter(CableTermination, Device, Interface, Site)
+        with override_settings(DATABASE_ROUTERS=[router]):
+            rack.save()
+
+        device.refresh_from_db()
+        interface.refresh_from_db()
+        self.assertEqual(device.site, self.site_b)
+        self.assertEqual(interface._site, self.site_b)
+
 
 class DeviceComponentScopeTriggerTestCase(TestCase):
     """

+ 57 - 9
netbox/dcim/tests/test_views.py

@@ -21,7 +21,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.views import DeviceTypeListView, ModuleTypeListView
-from extras.models import ConfigTemplate
+from extras.models import ConfigContext, ConfigTemplate
 from ipam.models import ASN, RIR, VLAN, VRF
 from netbox.choices import (
     CSVDelimiterChoices,
@@ -1361,13 +1361,13 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         Manufacturer.objects.bulk_create(manufacturers)
 
-        profile = ModuleTypeProfile.objects.create(name='Module Type Profile 1', schema=cls.SCHEMA)
+        cls.profile = ModuleTypeProfile.objects.create(name='Module Type Profile 1', schema=cls.SCHEMA)
 
         module_types = ModuleType.objects.bulk_create([
             ModuleType(
                 model='Module Type 1',
                 manufacturer=manufacturers[0],
-                profile=profile,
+                profile=cls.profile,
                 attribute_data={'media': ['copper', 'qsfp28']},
             ),
             ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
@@ -1376,8 +1376,6 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
-        fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
-
         cls.form_data = {
             'manufacturer': manufacturers[1].pk,
             'model': 'Device Type X',
@@ -1395,7 +1393,7 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
         cls.csv_data = (
             "manufacturer,model,part_number,end_of_life,comments,profile",
-            f"Manufacturer 1,fan0,generic-fan,2035-06-30,,{fan_module_type_profile.name}"
+            f"Manufacturer 1,Module Type 4,module-type-4,2035-06-30,,{cls.profile.name}",
         )
 
         cls.csv_update_data = (
@@ -1455,9 +1453,8 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
         def verify_module_type_profile(scenario_name):
             # TODO: remove extra regression asserts once parent test supports testing all import fields
-            fan_module_type = ModuleType.objects.get(part_number='generic-fan')
-            fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
-            assert fan_module_type.profile == fan_module_type_profile
+            module_type = ModuleType.objects.get(part_number='module-type-4')
+            self.assertEqual(module_type.profile_id, self.profile.pk)
 
         # run base test
         super().test_bulk_import_objects_with_permission(post_import_callback=verify_module_type_profile)
@@ -2049,6 +2046,7 @@ class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
 
         cls.bulk_edit_data = {
             'type': ConsolePortTypeChoices.TYPE_RJ45,
+            'description': 'Foo bar',
         }
 
 
@@ -2313,6 +2311,7 @@ class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
         cls.bulk_edit_data = {
             'description': 'Foo bar',
+            'position': 'A1',
         }
 
     @tag('regression')  # Issue #22961
@@ -2444,6 +2443,7 @@ class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTes
 
         cls.bulk_edit_data = {
             'description': 'Foo bar',
+            'part_id': 'PN-1',
         }
 
 
@@ -2874,6 +2874,54 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertHttpStatus(response, 200)
         self.assertIn(b'Error rendering template', response.content)
 
+    def test_device_configcontext_is_not_cacheable(self):
+        """
+        The config context tab renders the merged context data, which may contain sensitive
+        values, so the response must not be cached by the browser.
+        """
+        ConfigContext.objects.create(name='Config Context 1', data={'password': 'super-secret-password'})
+        device = Device.objects.first()
+
+        self.add_permissions('dcim.view_device', 'extras.view_configcontext')
+        url = reverse('dcim:device_configcontext', kwargs={'pk': device.pk})
+        response = self.client.get(url)
+        self.assertHttpStatus(response, 200)
+
+        # Confirm the context data is in fact rendered in the response
+        self.assertIn(b'super-secret-password', response.content)
+
+        self.assertNotCacheable(response)
+
+    def test_device_renderconfig_is_not_cacheable(self):
+        """
+        The render config tab renders the config template with context data substituted into it,
+        which may contain sensitive values, so the response must not be cached by the browser.
+        """
+        configtemplate = ConfigTemplate.objects.create(
+            name='Test Config Template',
+            template_code='enable secret super-secret-password'
+        )
+        device = Device.objects.first()
+        device.config_template = configtemplate
+        device.save()
+
+        self.add_permissions('dcim.view_device', 'dcim.render_config_device')
+        url = reverse('dcim:device_render-config', kwargs={'pk': device.pk})
+
+        response = self.client.get(url)
+        self.assertHttpStatus(response, 200)
+
+        # Confirm the rendered config is in fact present in the response
+        self.assertIn(b'super-secret-password', response.content)
+
+        self.assertNotCacheable(response)
+
+        # The direct export of the rendered config must not be cached either
+        response = self.client.get(url, {'export': 1})
+        self.assertHttpStatus(response, 200)
+        self.assertIn(b'super-secret-password', response.content)
+        self.assertNotCacheable(response)
+
     def test_device_role_display_colored(self):
         parent_role = DeviceRole.objects.create(name='Parent Role', slug='parent-role', color='111111')
         child_role = DeviceRole.objects.create(name='Child Role', slug='child-role', parent=parent_role, color='aa00bb')

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

@@ -179,7 +179,7 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
     manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
     model = attrs.TextAttr('model')
     part_number = attrs.TextAttr('part_number')
-    default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True)
+    default_platform = attrs.NestedObjectAttr('default_platform', linkify=True, max_depth=3)
     description = attrs.TextAttr('description')
     height = attrs.TemplatedAttr('u_height', template_name='dcim/devicetype/attrs/height.html')
     exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization')

+ 7 - 2
netbox/extras/forms/bulk_edit.py

@@ -107,7 +107,7 @@ class CustomFieldBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):
             name=_('Validation')
         ),
     )
-    nullable_fields = ('group_name', 'description', 'choice_set', 'validation_schema')
+    nullable_fields = ('group_name', 'description', 'choice_set', 'validation_schema', 'owner', 'comments')
 
 
 class CustomFieldChoiceSetBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):
@@ -311,6 +311,11 @@ class EventRuleBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
         max_length=200,
         required=False
     )
+    conditions = JSONField(
+        label=_('Conditions'),
+        required=False,
+        help_text=_('Enter conditions in <a href="https://json.org/">JSON</a> format.')
+    )
 
     nullable_fields = ('description', 'conditions')
 
@@ -378,7 +383,7 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm)
     fieldsets = (
         FieldSet('weight', 'profile', 'is_active', 'description'),
     )
-    nullable_fields = ('profile', 'description')
+    nullable_fields = ('profile', 'description', 'owner')
 
 
 class ConfigTemplateBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):

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

@@ -830,8 +830,6 @@ class ImageAttachment(ChangeLoggedModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    clone_fields = ('object_type', 'object_id')
-
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 

+ 1 - 1
netbox/extras/scripts.py

@@ -326,7 +326,7 @@ class BaseScript:
         self._current_test = None  # Tracks the current test method being run (if any)
 
         # Initiate the log
-        self.logger = logging.getLogger(f"netbox.scripts.{self.__module__}.{self.__class__.__name__}")
+        self.logger = logging.getLogger(f"netbox.scripts.{self.full_name}")
 
         # Declare the placeholder for the current request
         self.request = None

+ 34 - 0
netbox/extras/tests/test_event_rules.py

@@ -27,6 +27,8 @@ from extras.models import EventRule, Notification, Script, ScriptModule, Tag, We
 from extras.scripts import Script as ScriptBase
 from extras.signals import process_job_end_event_rules
 from extras.webhooks import generate_signature, send_webhook
+from ipam.choices import IPAddressStatusChoices
+from ipam.models import IPAddress, Prefix
 from netbox.context_managers import event_tracking
 from netbox.event_rules import (
     EventRuleAction,
@@ -330,6 +332,38 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
         # No task may be queued for a creation that was rolled back
         self.assertEqual(self.queue.count, 0)
 
+    def test_available_objects_create_rollback_discards_events(self):
+        """
+        Check that creating an object via an available-objects endpoint (e.g. available-ips) queues
+        no background task when the object-level permission check rolls the transaction back.
+        """
+        prefix = Prefix.objects.create(prefix='192.0.2.0/24')
+
+        event_rule = EventRule.objects.get(name='Event Rule 1')
+        event_rule.object_types.set([ObjectType.objects.get_for_model(IPAddress)])
+
+        # Permit the creation of active IP addresses only. The new object is saved (queueing its
+        # event) before _validate_objects() rejects it and the transaction is rolled back.
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['add'],
+            constraints={'status': IPAddressStatusChoices.STATUS_ACTIVE},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(IPAddress))
+        self.add_permissions('ipam.view_prefix')
+
+        url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
+        data = {'status': IPAddressStatusChoices.STATUS_RESERVED}
+        with disable_warnings('django.request'):
+            response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+        self.assertEqual(IPAddress.objects.count(), 0)
+
+        # No task may be queued for a creation that was rolled back
+        self.assertEqual(self.queue.count, 0)
+
     def test_single_update_process_eventrule(self):
         """
         Check that updating an object with an applicable EventRule queues a background task for the rule's action.

+ 30 - 0
netbox/extras/tests/test_scripts.py

@@ -439,3 +439,33 @@ class ScriptModuleLoadingTestCase(TestCase):
         # The real circuits app must be untouched and remain an importable package
         self.assertIs(sys.modules['circuits'], circuits)
         self.assertTrue(hasattr(circuits, '__path__'))
+
+    def test_script_logger_uses_public_module_name(self):
+        """
+        A dynamically loaded script logs to the public netbox.scripts.<module>.<class> namespace.
+        """
+        script_content = (
+            b"from extras.scripts import Script\n\n\n"
+            b"class TestScript(Script):\n    pass\n"
+        )
+
+        class _Storage:
+            def open(self, name, mode='rb'):
+                return io.BytesIO(script_content)
+
+        module = ScriptModule(file_root='scripts', file_path='example.py')
+        namespaced_key = f'{SCRIPT_MODULE_NAME_PREFIX}example'
+        self.addCleanup(lambda: sys.modules.pop(namespaced_key, None))
+
+        with patch('extras.models.mixins.storages') as mock_storages:
+            mock_storages.__getitem__.return_value = _Storage()
+            script_class = module.get_module().TestScript
+
+        # This is the name runscript and ScriptJob attach their handlers to
+        logger_name = f'netbox.scripts.{script_class.full_name}'
+        script = script_class()
+        self.assertEqual(script.logger.name, logger_name)
+
+        with self.assertLogs(logger_name, 'INFO') as captured:
+            script.log_success('Start')
+        self.assertIn('Start', captured.output[0])

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

@@ -589,6 +589,24 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'as_attachment': True,
         }
 
+    def test_content_is_not_cacheable(self):
+        """
+        The detail view renders the template code inline, which may have been synced from a data
+        file containing sensitive values, so the response must not be cached by the browser.
+        """
+        export_template = ExportTemplate.objects.first()
+        export_template.template_code = 'super-secret-password'
+        export_template.save()
+
+        self.add_permissions('extras.view_exporttemplate')
+        response = self.client.get(export_template.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+
+        # Confirm the template code is in fact rendered in the response
+        self.assertIn(b'super-secret-password', response.content)
+
+        self.assertNotCacheable(response)
+
 
 class ExportTemplateExportFlowTestCase(TestCase):
     """
@@ -854,6 +872,31 @@ class ConfigContextProfileTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             f"{profiles[2].pk},New description",
         )
 
+    def test_content_is_not_cacheable(self):
+        """
+        The detail view renders the schema inline, which may have been synced from a data file
+        containing sensitive values, so the response must not be cached by the browser.
+        """
+        instance = ConfigContextProfile.objects.first()
+        instance.schema = {
+            'properties': {
+                'password': {
+                    'type': 'string',
+                    'default': 'super-secret-password',
+                }
+            }
+        }
+        instance.save()
+
+        self.add_permissions('extras.view_configcontextprofile')
+        response = self.client.get(instance.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+
+        # Confirm the schema is in fact rendered in the response
+        self.assertIn(b'super-secret-password', response.content)
+
+        self.assertNotCacheable(response)
+
 
 # TODO: Change base class to PrimaryObjectViewTestCase
 # Blocked by absence of standard create/edit, bulk create views
@@ -904,6 +947,24 @@ class ConfigContextTestCase(
             'description': 'New description',
         }
 
+    def test_content_is_not_cacheable(self):
+        """
+        The detail view renders the data inline, which may have been synced from a data
+        file containing sensitive values, so the response must not be cached by the browser.
+        """
+        instance = ConfigContext.objects.first()
+        instance.data = {'password': 'super-secret-password'}
+        instance.save()
+
+        self.add_permissions('extras.view_configcontext')
+        response = self.client.get(instance.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+
+        # Confirm the context data is in fact rendered in the response
+        self.assertIn(b'super-secret-password', response.content)
+
+        self.assertNotCacheable(response)
+
 
 class ConfigTemplateTestCase(
     ViewTestCases.GetObjectViewTestCase,
@@ -961,6 +1022,24 @@ class ConfigTemplateTestCase(
             'as_attachment': True,
         }
 
+    def test_content_is_not_cacheable(self):
+        """
+        The detail view renders the template code inline, which may have been synced from a data
+        file containing sensitive values, so the response must not be cached by the browser.
+        """
+        instance = ConfigTemplate.objects.first()
+        instance.template_code = 'super-secret-password'
+        instance.save()
+
+        self.add_permissions('extras.view_configtemplate')
+        response = self.client.get(instance.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+
+        # Confirm the template code is in fact rendered in the response
+        self.assertIn(b'super-secret-password', response.content)
+
+        self.assertNotCacheable(response)
+
 
 class JournalEntryTestCase(
     # ViewTestCases.GetObjectViewTestCase,

+ 8 - 0
netbox/extras/views.py

@@ -9,9 +9,11 @@ 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.decorators import method_decorator
 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.decorators.cache import never_cache
 from django.views.generic import View
 
 from core.choices import ManagedFileRootPathChoices
@@ -308,6 +310,7 @@ class ExportTemplateListView(generic.ObjectListView):
 
 
 @register_model_view(ExportTemplate)
+@method_decorator(never_cache, name='dispatch')
 class ExportTemplateView(generic.ObjectView):
     queryset = ExportTemplate.objects.all()
     template_name = 'generic/object.html'
@@ -994,6 +997,7 @@ class ConfigContextProfileListView(generic.ObjectListView):
 
 
 @register_model_view(ConfigContextProfile)
+@method_decorator(never_cache, name='dispatch')
 class ConfigContextProfileView(generic.ObjectView):
     queryset = ConfigContextProfile.objects.all()
     template_name = 'generic/object.html'
@@ -1069,6 +1073,7 @@ class ConfigContextListView(generic.ObjectListView):
 
 
 @register_model_view(ConfigContext)
+@method_decorator(never_cache, name='dispatch')
 class ConfigContextView(generic.ObjectView):
     queryset = ConfigContext.objects.all()
     template_name = 'generic/object.html'
@@ -1155,6 +1160,7 @@ class ConfigContextBulkSyncDataView(generic.BulkSyncDataView):
     queryset = ConfigContext.objects.all()
 
 
+@method_decorator(never_cache, name='dispatch')
 class ObjectConfigContextView(generic.ObjectView):
     base_template = None
     template_name = 'extras/object_configcontext.html'
@@ -1199,6 +1205,7 @@ class ConfigTemplateListView(generic.ObjectListView):
 
 
 @register_model_view(ConfigTemplate)
+@method_decorator(never_cache, name='dispatch')
 class ConfigTemplateView(generic.ObjectView):
     queryset = ConfigTemplate.objects.all()
     template_name = 'generic/object.html'
@@ -1260,6 +1267,7 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
     queryset = ConfigTemplate.objects.all()
 
 
+@method_decorator(never_cache, name='dispatch')
 class ObjectRenderConfigView(generic.ObjectView):
     base_template = None
     template_name = 'extras/object_render_config.html'

+ 0 - 2
netbox/ipam/models/fhrp.py

@@ -103,8 +103,6 @@ class FHRPGroupAssignment(ChangeLoggedModel):
         )
     )
 
-    clone_fields = ('interface_type', 'interface_id')
-
     class Meta:
         ordering = ('-priority', 'pk')
         indexes = (

+ 2 - 2
netbox/ipam/models/ip.py

@@ -399,7 +399,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
         """
         lookup = 'net_contains_or_equals' if include_self else 'net_contains'
         return Prefix.objects.filter(**{
-            'vrf': self.vrf,
+            'vrf_id': self.vrf_id,
             f'prefix__{lookup}': self.prefix
         })
 
@@ -409,7 +409,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
         """
         lookup = 'net_contained_or_equal' if include_self else 'net_contained'
         return Prefix.objects.filter(**{
-            'vrf': self.vrf,
+            'vrf_id': self.vrf_id,
             f'prefix__{lookup}': self.prefix
         })
 

+ 17 - 15
netbox/ipam/signals.py

@@ -7,47 +7,49 @@ from virtualization.models import VirtualMachine
 from .models import IPAddress, Prefix
 
 
-def update_parents_children(prefix):
+def update_parents_children(prefix, using=None):
     """
     Update depth on prefix & containing prefixes
     """
-    parents = prefix.get_parents(include_self=True).annotate_hierarchy()
+    parents = prefix.get_parents(include_self=True).using(using).annotate_hierarchy()
     for parent in parents:
         parent._children = parent.hierarchy_children
-    Prefix.objects.bulk_update(parents, ['_children'], batch_size=100)
+    Prefix.objects.using(using).bulk_update(parents, ['_children'], batch_size=100)
 
 
-def update_children_depth(prefix):
+def update_children_depth(prefix, using=None):
     """
     Update children count on prefix & contained prefixes
     """
-    children = prefix.get_children(include_self=True).annotate_hierarchy()
+    children = prefix.get_children(include_self=True).using(using).annotate_hierarchy()
     for child in children:
         child._depth = child.hierarchy_depth
-    Prefix.objects.bulk_update(children, ['_depth'], batch_size=100)
+    Prefix.objects.using(using).bulk_update(children, ['_depth'], batch_size=100)
 
 
 @receiver(post_save, sender=Prefix)
-def handle_prefix_saved(instance, created, **kwargs):
-
+def handle_prefix_saved(instance, created, using=None, **kwargs):
+    """
+    Recompute the cached hierarchy counters for the prefixes surrounding this one.
+    """
     # Prefix has changed (or new instance has been created)
     if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix:
 
-        update_parents_children(instance)
-        update_children_depth(instance)
+        update_parents_children(instance, using)
+        update_children_depth(instance, using)
 
         # If this is not a new prefix, clean up parent/children of previous prefix
         if not created:
             old_prefix = Prefix(vrf_id=instance._vrf_id, prefix=instance._prefix)
-            update_parents_children(old_prefix)
-            update_children_depth(old_prefix)
+            update_parents_children(old_prefix, using)
+            update_children_depth(old_prefix, using)
 
 
 @receiver(post_delete, sender=Prefix)
-def handle_prefix_deleted(instance, **kwargs):
+def handle_prefix_deleted(instance, using=None, **kwargs):
 
-    update_parents_children(instance)
-    update_children_depth(instance)
+    update_parents_children(instance, using)
+    update_children_depth(instance, using)
 
 
 @receiver(pre_delete, sender=IPAddress)

+ 57 - 2
netbox/ipam/tests/test_signals.py

@@ -1,14 +1,16 @@
 import uuid
 
 from django.contrib.contenttypes.models import ContentType
-from django.test import RequestFactory, TestCase
+from django.test import RequestFactory, TestCase, override_settings
 
 from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange
 from dcim.models import Location, Region, Site, SiteGroup
-from ipam.models import IPAddress, Prefix
+from ipam import signals
+from ipam.models import VRF, IPAddress, Prefix
 from netbox.context_managers import event_tracking
 from users.models import User
+from utilities.testing import PinnedConnectionRouter
 from utilities.testing.utils import create_test_device, create_test_virtualmachine
 
 
@@ -307,3 +309,56 @@ class PrefixDenormalizationTriggerTestCase(TestCase):
 
         prefix.refresh_from_db()
         self.assertEqual(prefix._region, region_b)
+
+
+class PrefixHierarchySignalConnectionTestCase(TestCase):
+    """
+    Verify the prefix hierarchy handlers issue every query against the connection the saved
+    Prefix was written to, rather than letting DATABASE_ROUTERS select one. On an
+    installation with routers configured (e.g. netbox_branching), a routed query would
+    recount the hierarchy against one database and write the result to another.
+
+    These handlers are invoked directly rather than through save()/delete(): every query
+    they make is against Prefix, which is also the model being written, so a router which
+    fails routed Prefix queries would trip on the save itself.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.vrf = VRF.objects.create(name='VRF 1')
+
+    def test_prefix_saved_handler_pins_queries_to_given_connection(self):
+        parent = Prefix.objects.create(prefix='10.0.0.0/16', vrf=self.vrf)
+        child = Prefix.objects.create(prefix='10.0.1.0/24', vrf=self.vrf)
+
+        # Re-fetch and move the child, leaving the vrf relation uncached: a lookup which
+        # filters on self.vrf rather than self.vrf_id fetches it over a routed connection,
+        # which the VRF entry below catches. The same applies to the throwaway Prefix the
+        # handler builds to clean up the child's previous position. The instance is not
+        # re-fetched after the save, as that would reset the _prefix snapshot the handler
+        # compares against and it would decline to do any work at all.
+        child = Prefix.objects.get(pk=child.pk)
+        child.prefix = '10.0.2.0/24'
+        child.save()
+        self.assertNotEqual(child.prefix, child._prefix)
+
+        router = PinnedConnectionRouter(Prefix, VRF)
+        with override_settings(DATABASE_ROUTERS=[router]):
+            signals.handle_prefix_saved(instance=child, created=False, using='default')
+
+        parent.refresh_from_db()
+        child.refresh_from_db()
+        self.assertEqual(parent._children, 1)
+        self.assertEqual(child._depth, 1)
+
+    def test_prefix_deleted_handler_pins_queries_to_given_connection(self):
+        parent = Prefix.objects.create(prefix='10.0.0.0/16', vrf=self.vrf)
+        child = Prefix.objects.create(prefix='10.0.1.0/24', vrf=self.vrf)
+
+        child = Prefix.objects.get(pk=child.pk)
+        router = PinnedConnectionRouter(Prefix, VRF)
+        with override_settings(DATABASE_ROUTERS=[router]):
+            signals.handle_prefix_deleted(instance=child, using='default')
+
+        parent.refresh_from_db()
+        self.assertEqual(parent._children, 1)

+ 8 - 3
netbox/netbox/api/viewsets/mixins.py

@@ -258,9 +258,14 @@ def discard_events_on_rollback(sender, using=None):
     UI's views send the same signal when they abandon a transaction.
 
     Must be entered *inside* the transaction whose rollback it guards, so that the rollback flag is
-    still set when this block exits. Nesting is safe: the bulk actions guard the whole batch while
-    the per-object perform_*() calls they make guard each write, and clearing an already-empty
-    queue is a no-op.
+    still set when this block exits.
+
+    Note that this discards the entire request's queue, not only the events queued within the
+    guarded block. Nesting is therefore safe only because every rollback guarded here aborts the
+    whole request, making the two equivalent: the bulk actions guard the whole batch while the
+    per-object perform_*() calls they make guard each write, and a failure in either case abandons
+    the request. Do not use this in a loop which catches a per-object failure and continues, as
+    the events for objects which were successfully written would be discarded as well.
     """
     try:
         yield

+ 4 - 4
netbox/netbox/config/parameters.py

@@ -24,7 +24,7 @@ PARAMS = (
         description=_("Additional content to display on the login page"),
         field_kwargs={
             'widget': forms.Textarea(
-                attrs={'class': 'vLargeTextField'}
+                attrs={'class': 'font-monospace'}
             ),
         },
     ),
@@ -35,7 +35,7 @@ PARAMS = (
         description=_('Additional content to display when in maintenance mode'),
         field_kwargs={
             'widget': forms.Textarea(
-                attrs={'class': 'vLargeTextField'}
+                attrs={'class': 'font-monospace'}
             ),
         },
     ),
@@ -46,7 +46,7 @@ PARAMS = (
         description=_("Additional content to display at the top of every page"),
         field_kwargs={
             'widget': forms.Textarea(
-                attrs={'class': 'vLargeTextField'}
+                attrs={'class': 'font-monospace'}
             ),
         },
     ),
@@ -57,7 +57,7 @@ PARAMS = (
         description=_("Additional content to display at the bottom of every page"),
         field_kwargs={
             'widget': forms.Textarea(
-                attrs={'class': 'vLargeTextField'}
+                attrs={'class': 'font-monospace'}
             ),
         },
     ),

+ 5 - 1
netbox/netbox/forms/bulk_edit.py

@@ -59,7 +59,11 @@ class NetBoxModelBulkEditForm(ChangelogMessageMixin, CustomFieldsMixin, BulkEdit
         return customfield.to_form_field(set_initial=False, enforce_required=False)
 
     def _extend_nullable_fields(self):
-        nullable_common_fields = ['owner']
+        # The bulk edit template always renders a Set Null control for these
+        nullable_common_fields = [
+            name for name in ('owner', 'comments')
+            if name in self.fields and name not in self.nullable_fields
+        ]
         nullable_custom_fields = [
             name for name, customfield in self.custom_fields.items()
             if (not customfield.required and customfield.ui_editable == CustomFieldUIEditableChoices.YES)

+ 73 - 0
netbox/netbox/tests/test_base_classes.py

@@ -1,4 +1,5 @@
 from django.apps import apps
+from django.core.exceptions import FieldDoesNotExist
 from django.test import TestCase
 from django.utils.module_loading import import_string
 
@@ -135,6 +136,16 @@ class FormClassesTestCase(TestCase):
             return NetBoxModelFilterSetForm
         return None
 
+    @classmethod
+    def get_bulk_edit_form_for_model(cls, model):
+        """
+        Return the bulk edit form class for a given model, or None if it has none.
+        """
+        try:
+            return cls.get_form_for_model(model, prefix='BulkEdit')
+        except ImportError:
+            return None
+
     def test_model_form_base_classes(self):
         """
         Check that each model form inherits from the appropriate base class.
@@ -153,6 +164,68 @@ class FormClassesTestCase(TestCase):
                 form_class = self.get_form_for_model(model, prefix='BulkEdit')
                 self.assertTrue(issubclass(form_class, base_class), f"{form_class} does not inherit from {base_class}")
 
+    def test_bulk_edit_nullable_fields(self):
+        """
+        Check that every name in a bulk edit form's nullable_fields is a field on the form, and that no
+        name is listed twice. A name with no matching field is inert: neither the rendered form nor the
+        update handler acts on it.
+        """
+        for model in apps.get_models():
+            if (form_class := self.get_bulk_edit_form_for_model(model)) is None:
+                continue
+            # Read the class attribute, which excludes the fields added per instance at runtime
+            declared = tuple(form_class.nullable_fields)
+            for name in declared:
+                self.assertIn(
+                    name,
+                    form_class.base_fields,
+                    f"{form_class.__name__}.nullable_fields lists '{name}', which is not a field on the form",
+                )
+                # The update handler reads model_field.null when nullifying, so a form-only field crashes
+                try:
+                    model._meta.get_field(name)
+                except FieldDoesNotExist:
+                    self.fail(
+                        f"{form_class.__name__}.nullable_fields lists '{name}', "
+                        f"which is not a field on {model.__name__}"
+                    )
+            duplicates = sorted({name for name in declared if declared.count(name) > 1})
+            self.assertEqual(
+                duplicates,
+                [],
+                f"{form_class.__name__}.nullable_fields lists duplicate entries: {duplicates}",
+            )
+
+    def test_bulk_edit_hardcoded_nullable_fields(self):
+        """
+        Check that forms which declare fieldsets mark their owner and comments fields as nullable. The
+        bulk edit template renders a Set Null control for both outside the declared fieldsets, so a form
+        which omits them offers a control that does nothing.
+        """
+        for model in apps.get_models():
+            if (form_class := self.get_bulk_edit_form_for_model(model)) is None:
+                continue
+            if not getattr(form_class, 'fieldsets', None):
+                continue
+            # Instantiate so that fields added per instance by _extend_nullable_fields() are included
+            form = form_class({'pk': []}, initial={})
+            declared_in_fieldsets = {
+                item for fieldset in form_class.fieldsets for item in fieldset.items
+            }
+            for name in ('owner', 'comments'):
+                if name not in form.fields:
+                    continue
+                self.assertIn(
+                    name,
+                    form.nullable_fields,
+                    f"{form_class.__name__} renders a Set Null control for '{name}' without marking it nullable",
+                )
+                self.assertNotIn(
+                    name,
+                    declared_in_fieldsets,
+                    f"{form_class.__name__} lists '{name}' in a fieldset, which renders the field twice",
+                )
+
     def test_import_form_base_classes(self):
         """
         Check that each bulk import form inherits from the appropriate base class.

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

@@ -1,5 +1,6 @@
 from unittest import skipIf
 
+from django.apps import apps
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
@@ -9,7 +10,8 @@ from core.models import AutoSyncRecord, DataSource
 from dcim.models import Site
 from extras.models import CustomLink
 from ipam.models import Prefix
-from netbox.models.features import get_model_features, has_feature, model_is_public
+from netbox.constants import CORE_APPS
+from netbox.models.features import CloningMixin, get_model_features, has_feature, model_is_public
 
 
 class ModelFeaturesTestCase(TestCase):
@@ -63,6 +65,23 @@ class ModelFeaturesTestCase(TestCase):
         self.assertIn('cloning', features)
         self.assertNotIn('bookmarks', features)
 
+    def test_clone_fields_requires_cloning_support(self):
+        """
+        Check that only models which support the cloning feature declare clone_fields.
+        """
+        declaring = [
+            model for model in apps.get_models()
+            if model._meta.app_label in CORE_APPS and hasattr(model, 'clone_fields')
+        ]
+
+        # Sanity checking
+        self.assertIn(Prefix, declaring, "Invalid test?")
+
+        offenders = sorted(
+            model._meta.label for model in declaring if not issubclass(model, CloningMixin)
+        )
+        self.assertEqual(offenders, [], "clone_fields is inert on models which do not inherit CloningMixin")
+
     def test_cloningmixin_emits_gfk_subwidget_params(self):
         """A cloned GFK is exposed as the GenericObjectChoiceField subwidget params."""
         site = Site.objects.create(name='Test Site', slug='test-site')

+ 35 - 0
netbox/netbox/tests/test_ui.py

@@ -371,6 +371,41 @@ class RelatedObjectAttrTestCase(TestCase):
         self.assertTrue(context['linkify'])
 
 
+class NestedObjectAttrTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.root = Region.objects.create(name='Root', slug='root')
+        cls.parent = Region.objects.create(name='Parent', slug='parent', parent=cls.root)
+        cls.child = Region.objects.create(name='Child', slug='child', parent=cls.parent)
+
+    def test_get_context_includes_ancestors_and_self(self):
+        attr = attrs.NestedObjectAttr('region')
+        context = attr.get_context(SimpleNamespace(region=self.child), 'region', self.child, {})
+
+        # Without max_depth the MPTT queryset is passed through unmodified
+        self.assertEqual(list(context['nodes']), [self.root, self.parent, self.child])
+
+    def test_get_context_max_depth_keeps_closest_ancestors(self):
+        attr = attrs.NestedObjectAttr('region', max_depth=2)
+        context = attr.get_context(SimpleNamespace(region=self.child), 'region', self.child, {})
+
+        self.assertEqual(context['nodes'], [self.parent, self.child])
+
+    def test_get_context_null_value_yields_no_nodes(self):
+        attr = attrs.NestedObjectAttr('region')
+        context = attr.get_context(SimpleNamespace(region=None), 'region', None, {})
+
+        self.assertEqual(context['nodes'], [])
+
+    def test_get_context_linkify_and_colored(self):
+        attr = attrs.NestedObjectAttr('region', linkify=True, colored=True)
+        context = attr.get_context(SimpleNamespace(region=self.root), 'region', self.root, {})
+
+        self.assertTrue(context['linkify'])
+        self.assertTrue(context['colored'])
+
+
 class GenericForeignKeyAttrTestCase(TestCase):
 
     class TreeNode:

+ 233 - 2
netbox/netbox/tests/test_views.py

@@ -1,16 +1,24 @@
 import urllib.parse
+from contextlib import contextmanager
 from unittest.mock import patch
 
 from django.contrib.contenttypes.models import ContentType
 from django.http import HttpResponse
-from django.test import Client, override_settings
+from django.test import Client, TransactionTestCase, override_settings
 from django.urls import reverse
 
-from dcim.models import DeviceType, Manufacturer, Site
+from dcim.choices import DeviceStatusChoices, InterfaceTypeChoices, SiteStatusChoices
+from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site, VirtualChassis
+from extras.events import enqueue_event
 from extras.models import ImageAttachment
+from extras.validators import CustomValidator
+from ipam.choices import VLANStatusChoices
+from ipam.models import VLAN, VLANGroup
 from netbox.constants import EMPTY_TABLE_TEXT
 from netbox.search.backends import search_backend
+from users.models import User
 from utilities.testing import TestCase
+from utilities.views import get_action_url
 
 
 class HomeViewTestCase(TestCase):
@@ -159,3 +167,226 @@ class MediaViewTestCase(TestCase):
         url = reverse('media', kwargs={'path': traversal_path})
         response = self.client.get(url)
         self.assertHttpStatus(response, 404)
+
+
+class DeleteAtomicityMixin:
+    """
+    Scenario shared by the transactional and autocommit variants of the ObjectDeleteView
+    atomicity test (Ref: #22934).
+
+    VirtualChassis.delete() clears vc_position/vc_priority on each member device and saves them
+    before calling super().delete(), so an aborted deletion must roll those writes back. Otherwise
+    they commit while their queued events are discarded, leaving a committed change which
+    dispatches no events.
+    """
+    PROTECTION_RULES = {'dcim.virtualchassis': [CustomValidator({'name': {'eq': 'Nonexistent'}})]}
+
+    def create_virtual_chassis(self):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+        )
+        virtual_chassis = VirtualChassis.objects.create(name='Virtual Chassis 1')
+        device = Device.objects.create(
+            name='Device 1', site=site, role=role, device_type=device_type,
+            virtual_chassis=virtual_chassis, vc_position=1, vc_priority=10
+        )
+
+        return virtual_chassis, device
+
+    def delete_virtual_chassis(self, virtual_chassis):
+        return self.client.post(
+            get_action_url(VirtualChassis, action='delete', kwargs={'pk': virtual_chassis.pk}),
+            data={'confirm': True}
+        )
+
+    def assertDeleteRolledBack(self, virtual_chassis, device):
+        self.assertTrue(VirtualChassis.objects.filter(pk=virtual_chassis.pk).exists())
+        device.refresh_from_db()
+        self.assertEqual(device.vc_position, 1)
+        self.assertEqual(device.vc_priority, 10)
+
+
+class RolledBackWriteEventQueueTestCase(DeleteAtomicityMixin, TestCase):
+    """
+    Verify that UI views which roll back a write transaction and return a normal response
+    discard any events queued during the aborted transaction. (Ref: #22934)
+    """
+    @contextmanager
+    def assertNoEventsDispatched(self, expect_queued=True):
+        """
+        Assert that no queued events are flushed to the events pipeline while within this block.
+
+        Unless expect_queued is disabled, also assert that at least one event *was* queued, so a
+        test cannot pass merely because nothing ever reached the queue (e.g. if the order in which
+        objects are processed changes, and the abort comes to precede the first event).
+        """
+        with patch('core.signals.enqueue_event', side_effect=enqueue_event) as enqueue:
+            with patch('netbox.context_managers.flush_events') as flush_events:
+                yield
+        flush_events.assert_not_called()
+        if expect_queued:
+            self.assertGreater(enqueue.call_count, 0, "No events were queued; nothing was cleared")
+
+    def test_bulk_create_view_rollback(self):
+        # A bulk create which trips a uniqueness constraint partway through must not dispatch
+        # created events for the objects written before the abort.
+        group = VLANGroup.objects.create(name='Test Group', slug='test-group')
+        VLAN.objects.create(group=group, vid=101, name='VLAN-101')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlan')
+
+        with self.assertNoEventsDispatched():
+            response = self.client.post(reverse('ipam:vlan_bulk_add'), data={
+                'pattern': '100,101',
+                'group': group.pk,
+                'name': 'VLAN-{vid}',
+                'status': VLANStatusChoices.STATUS_ACTIVE,
+            })
+        self.assertHttpStatus(response, 200)
+
+        # The transaction was rolled back, so VID 100 must not exist
+        self.assertFalse(VLAN.objects.filter(group=group, vid=100).exists())
+
+    @override_settings(
+        PROTECTION_RULES={'dcim.site': [CustomValidator({'status': {'eq': SiteStatusChoices.STATUS_DECOMMISSIONING}})]}
+    )
+    def test_bulk_delete_view_rollback_abortrequest(self):
+        # A bulk delete aborted by a protection rule must not dispatch deleted events for the
+        # objects deleted before the abort.
+        site_a = Site.objects.create(
+            name='Site A', slug='site-a', status=SiteStatusChoices.STATUS_DECOMMISSIONING
+        )
+        site_b = Site.objects.create(name='Site B', slug='site-b', status=SiteStatusChoices.STATUS_ACTIVE)
+        self.add_permissions('dcim.delete_site', 'dcim.view_site')
+
+        with self.assertNoEventsDispatched():
+            self.client.post(reverse('dcim:site_bulk_delete'), data={
+                'pk': [site_a.pk, site_b.pk],
+                'confirm': True,
+                '_confirm': True,
+            })
+
+        # Both sites must still exist
+        self.assertEqual(Site.objects.filter(pk__in=[site_a.pk, site_b.pk]).count(), 2)
+
+    def test_bulk_delete_view_rollback_protectederror(self):
+        # A bulk delete aborted by a dependent object must not dispatch deleted events for the
+        # objects deleted before the abort.
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        Device.objects.create(name='Device 1', site=site, role=role, device_type=device_types[1])
+        self.add_permissions('dcim.delete_devicetype', 'dcim.view_devicetype')
+
+        with self.assertNoEventsDispatched():
+            self.client.post(reverse('dcim:devicetype_bulk_delete'), data={
+                'pk': [dt.pk for dt in device_types],
+                'confirm': True,
+                '_confirm': True,
+            })
+
+        # Both device types must still exist
+        self.assertEqual(DeviceType.objects.count(), 2)
+
+    @override_settings(
+        PROTECTION_RULES={'dcim.device': [CustomValidator({'status': {'eq': DeviceStatusChoices.STATUS_OFFLINE}})]}
+    )
+    def test_object_delete_view_rollback_abortrequest(self):
+        # A cascading delete aborted by a protection rule on the parent must not dispatch deleted
+        # events for the children collected before the abort. Django's collector deletes dependent
+        # objects first, so the interface's pre_delete handler queues an event before the device's
+        # protection rule trips.
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+        )
+        device = Device.objects.create(
+            name='Device 1', site=site, role=role, device_type=device_type,
+            status=DeviceStatusChoices.STATUS_ACTIVE
+        )
+        interface = Interface.objects.create(device=device, name='eth0', type=InterfaceTypeChoices.TYPE_VIRTUAL)
+        self.add_permissions('dcim.delete_device', 'dcim.view_device')
+
+        with self.assertNoEventsDispatched():
+            self.client.post(get_action_url(Device, action='delete', kwargs={'pk': device.pk}), data={
+                'confirm': True,
+            })
+
+        # Both the device and its interface must still exist
+        self.assertTrue(Device.objects.filter(pk=device.pk).exists())
+        self.assertTrue(Interface.objects.filter(pk=interface.pk).exists())
+
+    @override_settings(PROTECTION_RULES=DeleteAtomicityMixin.PROTECTION_RULES)
+    def test_object_delete_view_rollback_is_atomic(self):
+        # An aborted delete must not leave behind writes performed by the model's delete() before
+        # super().delete(). Note what this detects: every test here runs inside the harness'
+        # transaction, so an escaped write is rolled back with the test regardless, and the
+        # assertions below cannot see it. Without the view's atomic block, AbortRequest instead
+        # escapes the collector's atomic(savepoint=False) and marks the harness' transaction as
+        # needing rollback, so this test fails with TransactionManagementError. The committed
+        # symptom itself is covered by DeleteAtomicityTestCase below.
+        virtual_chassis, device = self.create_virtual_chassis()
+        self.add_permissions('dcim.delete_virtualchassis', 'dcim.view_virtualchassis')
+
+        with self.assertNoEventsDispatched():
+            self.delete_virtual_chassis(virtual_chassis)
+
+        self.assertDeleteRolledBack(virtual_chassis, device)
+
+    def test_object_delete_view_rollback_protectederror(self):
+        # A delete aborted by a dependent object must not dispatch any events. Nothing is queued
+        # today, as the collector raises before any pre_delete signal fires; assert the queue is
+        # clear regardless, so correctness does not rest on Django's collection order.
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+        )
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        Device.objects.create(name='Device 1', site=site, role=role, device_type=device_type)
+        self.add_permissions('dcim.delete_devicetype', 'dcim.view_devicetype')
+
+        with self.assertNoEventsDispatched(expect_queued=False):
+            self.client.post(get_action_url(DeviceType, action='delete', kwargs={'pk': device_type.pk}), data={
+                'confirm': True,
+            })
+
+        self.assertTrue(DeviceType.objects.filter(pk=device_type.pk).exists())
+
+
+class DeleteAtomicityTestCase(DeleteAtomicityMixin, TransactionTestCase):
+    """
+    Exercise the autocommit path, which TestCase cannot reach: it wraps every test in a
+    transaction, so a write which escapes the view's rollback is rolled back with the test and
+    stays invisible to the assertions. Outside a transaction, a write performed by delete() before
+    super().delete() genuinely commits unless the view opens its own atomic block. (Ref: #22934)
+
+    Note: TransactionTestCase teardown flushes all tables, which removes rows seeded by data
+    migrations from a --keepdb database (e.g. the dcim.0206 ModuleTypeProfiles). A fresh test
+    database restores them.
+    """
+    def setUp(self):
+        # A superuser, rather than the object permissions granted by utilities.testing.TestCase:
+        # this case covers transaction handling, not permission enforcement.
+        self.user = User.objects.create_user(username='testuser', is_superuser=True)
+        self.client = Client()
+        self.client.force_login(self.user)
+
+    @override_settings(PROTECTION_RULES=DeleteAtomicityMixin.PROTECTION_RULES)
+    def test_object_delete_view_rollback_is_atomic(self):
+        virtual_chassis, device = self.create_virtual_chassis()
+
+        self.delete_virtual_chassis(virtual_chassis)
+
+        # Without the view's atomic block, the member device's cleared position and priority commit
+        # here, leaving a change which dispatches no events
+        self.assertDeleteRolledBack(virtual_chassis, device)

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

@@ -457,7 +457,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
                 return redirect(self.get_return_url(request))
 
             except (AbortTransaction, IntegrityError):
-                pass
+                clear_events.send(sender=self)
 
             except (AbortRequest, PermissionsViolation) as e:
                 logger.debug(e.message)
@@ -1263,6 +1263,7 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
 
                 except (ProtectedError, RestrictedError) as e:
                     logger.warning(f"Caught {type(e)} while attempting to delete objects")
+                    clear_events.send(sender=self)
                     if is_background_request(request):
                         request.job.logger.error(
                             _("Deletion failed due to the presence of one or more dependent objects.")
@@ -1272,6 +1273,7 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
 
                 except AbortRequest as e:
                     logger.debug(e.message)
+                    clear_events.send(sender=self)
                     if is_background_request(request):
                         request.job.logger.error(e.message)
                         raise JobFailed

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

@@ -495,13 +495,16 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
 
             # Delete the object
             try:
-                obj.delete()
+                with transaction.atomic(using=router.db_for_write(self.queryset.model)):
+                    obj.delete()
             except (ProtectedError, RestrictedError) as e:
                 logger.info(f"Caught {type(e)} while attempting to delete objects")
+                clear_events.send(sender=self)
                 handle_protectederror([obj], request, e)
                 return redirect(obj.get_absolute_url())
             except AbortRequest as e:
                 logger.debug(e.message)
+                clear_events.send(sender=self)
                 messages.error(request, mark_safe(e.message))
                 return redirect(obj.get_absolute_url())
 

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/dist/netbox.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/dist/netbox.js.map


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

@@ -33,7 +33,7 @@
     "markdown-it": "^14.3.0",
     "picomatch": "4.0.5",
     "query-string": "9.5.0",
-    "sass": "1.102.0",
+    "sass": "1.103.1",
     "tom-select": "2.6.2",
     "typeface-inter": "3.18.1",
     "typeface-roboto-mono": "1.1.13"
@@ -45,16 +45,16 @@
     "@types/bootstrap": "5.2.11",
     "@types/cookie": "^1.0.0",
     "@types/node": "^24.10.1",
-    "@typescript-eslint/eslint-plugin": "^8.67.0",
-    "@typescript-eslint/parser": "^8.67.0",
+    "@typescript-eslint/eslint-plugin": "^8.68.0",
+    "@typescript-eslint/parser": "^8.68.0",
     "esbuild": "^0.28.2",
     "esbuild-sass-plugin": "^3.7.0",
-    "eslint": "^10.8.1",
+    "eslint": "^10.9.1",
     "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.9.0",
+    "globals": "^17.11.0",
     "prettier": "^3.9.6",
     "typescript": "^5.9.3"
   },

+ 16 - 78
netbox/project-static/src/sidenav.ts

@@ -1,33 +1,24 @@
-import { Collapse } from 'bootstrap';
 import { StateManager } from './state';
 import { getElements, isElement } from './util';
 
 type NavState = { pinned: boolean };
 type BodyAttr = 'show' | 'hide' | 'hidden' | 'pinned';
-type Section = [HTMLAnchorElement, InstanceType<typeof Collapse>];
+
+// Keep in sync with Bootstrap's `lg` breakpoint and `navbar-expand-lg` in base/layout.html.
+const SIDENAV_DESKTOP_MEDIA = '(min-width: 992px)';
 
 class SideNav {
   /**
    * Sidenav container element.
    */
-  private base: HTMLDivElement;
+  private base: HTMLElement;
 
   /**
    * SideNav internal state manager.
    */
   private state: StateManager<NavState>;
 
-  /**
-   * The currently active parent nav-link controlling a section.
-   */
-  private activeLink: Nullable<HTMLAnchorElement> = null;
-
-  /**
-   * All collapsible sections and their controlling nav-links.
-   */
-  private sections: Section[] = [];
-
-  constructor(base: HTMLDivElement) {
+  constructor(base: HTMLElement) {
     this.base = base;
     this.state = new StateManager<NavState>(
       { pinned: true },
@@ -35,7 +26,6 @@ class SideNav {
     );
 
     this.init();
-    this.initSectionLinks();
     this.initLinks();
   }
 
@@ -76,22 +66,17 @@ class SideNav {
       toggler.addEventListener('click', event => this.onMobileToggle(event));
     }
 
-    if (window.innerWidth > 1200) {
+    if (window.matchMedia(SIDENAV_DESKTOP_MEDIA).matches) {
       if (this.state.get('pinned')) {
         this.pin();
-      }
-
-      if (!this.state.get('pinned')) {
+      } else {
         this.unpin();
       }
-      window.addEventListener('resize', () => this.onResize());
-    }
-
-    if (window.innerWidth < 1200) {
+    } else {
       this.bodyRemove('hide');
       this.bodyAdd('hidden');
-      window.addEventListener('resize', () => this.onResize());
     }
+    window.addEventListener('resize', () => this.onResize());
 
     this.base.addEventListener('mouseenter', () => this.onEnter());
     this.base.addEventListener('mouseleave', () => this.onLeave());
@@ -119,7 +104,7 @@ class SideNav {
   }
 
   /**
-   * Hide the sidenav and collapse all active nav sections.
+   * Hide the sidenav and close any nested collapse elements.
    */
   private hide(): void {
     this.bodyAdd('hidden');
@@ -151,55 +136,8 @@ class SideNav {
   }
 
   /**
-   * When a section's controlling nav-link is clicked, update this instance's `activeLink`
-   * attribute and close all other sections.
-   */
-  private handleSectionClick(event: Event): void {
-    event.preventDefault();
-    const element = event.target as HTMLAnchorElement;
-    this.activeLink = element;
-    this.closeInactiveSections();
-  }
-
-  /**
-   * Close all sections that are not associated with the currently active link (`activeLink`).
-   */
-  private closeInactiveSections(): void {
-    for (const [link, collapse] of this.sections) {
-      if (link !== this.activeLink) {
-        link.classList.add('collapsed');
-        link.setAttribute('aria-expanded', 'false');
-        collapse.hide();
-      }
-    }
-  }
-
-  /**
-   * Initialize `bootstrap.Collapse` instances on all section collapse elements and add event
-   * listeners to the controlling nav-links.
-   */
-  private initSectionLinks(): void {
-    for (const section of getElements<HTMLAnchorElement>(
-      '.navbar-nav .nav-item .nav-link[data-bs-toggle]',
-    )) {
-      if (section.parentElement !== null) {
-        const collapse = section.parentElement.querySelector<HTMLDivElement>('.collapse');
-        if (collapse !== null) {
-          const collapseInstance = new Collapse(collapse, {
-            toggle: false, // Don't automatically open the collapse element on invocation.
-          });
-          this.sections.push([section, collapseInstance]);
-          section.addEventListener('click', event => this.handleSectionClick(event));
-        }
-      }
-    }
-  }
-
-  /**
-   * Starting from the bottom-most active link in the element tree, work backwards to determine the
-   * link's containing `.collapse` element and the `.collapse` element's containing `.nav-link`
-   * element. Once found, expand (or collapse) the `.collapse` element and add (or remove) the
-   * `.active` class to the the parent `.nav-link` element.
+   * Expand or collapse the `.dropdown-menu` containing an active link, and toggle the `active`
+   * class on the link and on its containing `.nav-item`.
    *
    * @param link Active nav link
    * @param action Expand or Collapse
@@ -238,7 +176,7 @@ class SideNav {
     for (const menuitem of this.base.querySelectorAll<HTMLDivElement>(
       'ul.navbar-nav .nav-item .dropdown-item',
     )) {
-      const link = menuitem.querySelector<HTMLAnchorElement>('a')
+      const link = menuitem.querySelector<HTMLAnchorElement>('a');
       if (link) {
         const href = new RegExp(link.href, 'gi');
         if (window.location.href.match(href)) {
@@ -249,7 +187,7 @@ class SideNav {
   }
 
   /**
-   * Show the sidenav and expand any active sections.
+   * Show the sidenav and expand any active menu groups.
    */
   private onEnter(): void {
     if (!this.bodyHas('pinned')) {
@@ -262,7 +200,7 @@ class SideNav {
   }
 
   /**
-   * Hide the sidenav and collapse any active sections.
+   * Hide the sidenav and collapse any active menu groups.
    */
   private onLeave(): void {
     if (!this.bodyHas('pinned')) {
@@ -314,7 +252,7 @@ class SideNav {
 }
 
 export function initSideNav(): void {
-  for (const sidenav of getElements<HTMLDivElement>('.navbar')) {
+  for (const sidenav of getElements<HTMLElement>('.navbar-vertical')) {
     new SideNav(sidenav);
   }
 }

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

@@ -917,100 +917,100 @@
   dependencies:
     "@types/estree" "*"
 
-"@typescript-eslint/eslint-plugin@^8.67.0":
-  version "8.67.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz#52f9f0e47d5a7571c4336e69bfeea581509ef2cf"
-  integrity sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==
+"@typescript-eslint/eslint-plugin@^8.68.0":
+  version "8.68.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz#a8fbdb1cf49aafaf16071b646daad890151bd149"
+  integrity sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==
   dependencies:
     "@eslint-community/regexpp" "^4.12.2"
-    "@typescript-eslint/scope-manager" "8.67.0"
-    "@typescript-eslint/type-utils" "8.67.0"
-    "@typescript-eslint/utils" "8.67.0"
-    "@typescript-eslint/visitor-keys" "8.67.0"
+    "@typescript-eslint/scope-manager" "8.68.0"
+    "@typescript-eslint/type-utils" "8.68.0"
+    "@typescript-eslint/utils" "8.68.0"
+    "@typescript-eslint/visitor-keys" "8.68.0"
     ignore "^7.0.5"
     natural-compare "^1.4.0"
     ts-api-utils "^2.5.0"
 
-"@typescript-eslint/parser@^8.67.0":
-  version "8.67.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.67.0.tgz#0158022ec9927e0afcd58a8cc2ad57e01d892f5c"
-  integrity sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==
+"@typescript-eslint/parser@^8.68.0":
+  version "8.68.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.68.0.tgz#61de31481354c50457bc9621a7ed746779f09ee7"
+  integrity sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==
   dependencies:
-    "@typescript-eslint/scope-manager" "8.67.0"
-    "@typescript-eslint/types" "8.67.0"
-    "@typescript-eslint/typescript-estree" "8.67.0"
-    "@typescript-eslint/visitor-keys" "8.67.0"
+    "@typescript-eslint/scope-manager" "8.68.0"
+    "@typescript-eslint/types" "8.68.0"
+    "@typescript-eslint/typescript-estree" "8.68.0"
+    "@typescript-eslint/visitor-keys" "8.68.0"
     debug "^4.4.3"
 
-"@typescript-eslint/project-service@8.67.0":
-  version "8.67.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.67.0.tgz#1552db007ca9206a1c6c7acf49e210bd17a8c56f"
-  integrity sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==
+"@typescript-eslint/project-service@8.68.0":
+  version "8.68.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.68.0.tgz#ea4b2869f59165c420cd7a4bbebc38039794e8cc"
+  integrity sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==
   dependencies:
-    "@typescript-eslint/tsconfig-utils" "^8.67.0"
-    "@typescript-eslint/types" "^8.67.0"
+    "@typescript-eslint/tsconfig-utils" "^8.68.0"
+    "@typescript-eslint/types" "^8.68.0"
     debug "^4.4.3"
 
-"@typescript-eslint/scope-manager@8.67.0":
-  version "8.67.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz#4d4c2da09560d10dd7d947cba2d29d14d25af16d"
-  integrity sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==
+"@typescript-eslint/scope-manager@8.68.0":
+  version "8.68.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz#e5a13a1159497faeab4e48279bf07576045b1499"
+  integrity sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==
   dependencies:
-    "@typescript-eslint/types" "8.67.0"
-    "@typescript-eslint/visitor-keys" "8.67.0"
+    "@typescript-eslint/types" "8.68.0"
+    "@typescript-eslint/visitor-keys" "8.68.0"
 
-"@typescript-eslint/tsconfig-utils@8.67.0", "@typescript-eslint/tsconfig-utils@^8.67.0":
-  version "8.67.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz#f45a3eba6b9132fb47141ec03ce2f275f1ea991d"
-  integrity sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==
+"@typescript-eslint/tsconfig-utils@8.68.0", "@typescript-eslint/tsconfig-utils@^8.68.0":
+  version "8.68.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz#594d7a3c5952055b3c431fc563ca7fd1defcce18"
+  integrity sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==
 
-"@typescript-eslint/type-utils@8.67.0":
-  version "8.67.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz#96bed105275559df3bcf0449b73a6414d35c59ce"
-  integrity sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==
+"@typescript-eslint/type-utils@8.68.0":
+  version "8.68.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz#8f3e838dbd740909db27053857468cd037b00220"
+  integrity sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==
   dependencies:
-    "@typescript-eslint/types" "8.67.0"
-    "@typescript-eslint/typescript-estree" "8.67.0"
-    "@typescript-eslint/utils" "8.67.0"
+    "@typescript-eslint/types" "8.68.0"
+    "@typescript-eslint/typescript-estree" "8.68.0"
+    "@typescript-eslint/utils" "8.68.0"
     debug "^4.4.3"
     ts-api-utils "^2.5.0"
 
-"@typescript-eslint/types@8.67.0", "@typescript-eslint/types@^8.67.0":
-  version "8.67.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.67.0.tgz#4a8d00cc1faba5c14feabc60f85b7a32652f34b6"
-  integrity sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==
+"@typescript-eslint/types@8.68.0", "@typescript-eslint/types@^8.68.0":
+  version "8.68.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.68.0.tgz#3f9d4e62fbe5728f09403cdc7b4d58af842ac1af"
+  integrity sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==
 
-"@typescript-eslint/typescript-estree@8.67.0":
-  version "8.67.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz#116c3a47c06119c5a050e8851861d6497dd64bc2"
-  integrity sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==
+"@typescript-eslint/typescript-estree@8.68.0":
+  version "8.68.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz#bf4165029825138ac27231a3ff02923ecd977f38"
+  integrity sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==
   dependencies:
-    "@typescript-eslint/project-service" "8.67.0"
-    "@typescript-eslint/tsconfig-utils" "8.67.0"
-    "@typescript-eslint/types" "8.67.0"
-    "@typescript-eslint/visitor-keys" "8.67.0"
+    "@typescript-eslint/project-service" "8.68.0"
+    "@typescript-eslint/tsconfig-utils" "8.68.0"
+    "@typescript-eslint/types" "8.68.0"
+    "@typescript-eslint/visitor-keys" "8.68.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.67.0":
-  version "8.67.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.67.0.tgz#3e478a3d69d330a1fc50c12746cc2ee0732ccfcd"
-  integrity sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==
+"@typescript-eslint/utils@8.68.0":
+  version "8.68.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.68.0.tgz#00547f2c8de8aca2a3c21752a9711f73206fd36d"
+  integrity sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==
   dependencies:
     "@eslint-community/eslint-utils" "^4.9.1"
-    "@typescript-eslint/scope-manager" "8.67.0"
-    "@typescript-eslint/types" "8.67.0"
-    "@typescript-eslint/typescript-estree" "8.67.0"
+    "@typescript-eslint/scope-manager" "8.68.0"
+    "@typescript-eslint/types" "8.68.0"
+    "@typescript-eslint/typescript-estree" "8.68.0"
 
-"@typescript-eslint/visitor-keys@8.67.0":
-  version "8.67.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz#601d40af9acf82a28da2286f3edafc69bba9017f"
-  integrity sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==
+"@typescript-eslint/visitor-keys@8.68.0":
+  version "8.68.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz#78db3c9bb258a0309d9e2b1b617127c3a8fb1f54"
+  integrity sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==
   dependencies:
-    "@typescript-eslint/types" "8.67.0"
+    "@typescript-eslint/types" "8.68.0"
     eslint-visitor-keys "^5.0.0"
 
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
@@ -1881,10 +1881,10 @@ 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.8.1:
-  version "10.8.1"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.8.1.tgz#fb37d514c19b6dd5b2d6b70169fd26fddfa97967"
-  integrity sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==
+eslint@^10.9.1:
+  version "10.9.1"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.9.1.tgz#409da5c41a5536d5a849f8555a18ca7ef1eb963b"
+  integrity sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==
   dependencies:
     "@eslint-community/eslint-utils" "^4.8.0"
     "@eslint-community/regexpp" "^4.12.2"
@@ -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.9.0:
-  version "17.9.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-17.9.0.tgz#e43f252d6bbe71508da43902a1709c8895a59f70"
-  integrity sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==
+globals@^17.11.0:
+  version "17.11.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
+  integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
 
 globalthis@^1.0.3, globalthis@^1.0.4:
   version "1.0.4"
@@ -3183,10 +3183,10 @@ safe-regex-test@^1.1.0:
     es-errors "^1.3.0"
     is-regex "^1.2.1"
 
-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==
+sass@1.103.1:
+  version "1.103.1"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.103.1.tgz#13b70f5ff69288db956dc27d27c8fb79f3a27c61"
+  integrity sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==
   dependencies:
     chokidar "^5.0.0"
     immutable "^5.1.5"

+ 1 - 1
netbox/tenancy/forms/bulk_edit.py

@@ -122,7 +122,7 @@ class ContactBulkEditForm(PrimaryModelBulkEditForm):
     )
 
     nullable_fields = (
-        'add_groups', 'remove_groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments'
+        'title', 'phone', 'email', 'address', 'link', 'description', 'comments'
     )
 
 

+ 0 - 2
netbox/tenancy/models/contacts.py

@@ -155,8 +155,6 @@ class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, Chan
         null=True
     )
 
-    clone_fields = ('object_type', 'object_id', 'role', 'priority')
-
     class Meta:
         ordering = ('contact', 'priority', 'role', 'pk')
         indexes = (

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

@@ -4,7 +4,7 @@ from netbox.ui import attrs, panels
 
 
 class TenantPanel(panels.ObjectAttributesPanel):
-    group = attrs.RelatedObjectAttr('group', linkify=True)
+    group = attrs.NestedObjectAttr('group', linkify=True)
     description = attrs.TextAttr('description')
 
 

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


Разлика између датотеке није приказан због своје велике величине
+ 171 - 171
netbox/translations/cs/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 171 - 171
netbox/translations/da/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 172 - 172
netbox/translations/de/LC_MESSAGES/django.po


Разлика између датотеке није приказан због своје велике величине
+ 178 - 178
netbox/translations/en/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 171 - 171
netbox/translations/es/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 171 - 171
netbox/translations/fr/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 171 - 171
netbox/translations/it/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 172 - 172
netbox/translations/ja/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 172 - 172
netbox/translations/ko/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 171 - 171
netbox/translations/lv/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 171 - 171
netbox/translations/nl/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 171 - 171
netbox/translations/pl/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 171 - 171
netbox/translations/pt/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 171 - 171
netbox/translations/ru/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 172 - 172
netbox/translations/tr/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 172 - 172
netbox/translations/uk/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 172 - 172
netbox/translations/zh/LC_MESSAGES/django.po


+ 3 - 0
netbox/utilities/constants.py

@@ -110,6 +110,9 @@ HTML_ALLOWED_ATTRIBUTES = {
     "th": {"align"},
 }
 
+# Allowed URL schemes for image sources (img[src]); applied in addition to ALLOWED_URL_SCHEMES
+IMAGE_URL_SCHEMES = {'http', 'https'}
+
 HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS = ['socks4', 'socks4a', 'socks4h', 'socks5', 'socks5a', 'socks5h']
 HTTP_PROXY_SOCK_RDNS_SCHEMAS = ['socks4h', 'socks4a', 'socks5h', 'socks5a']
 HTTP_PROXY_SUPPORTED_SCHEMAS = ['http', 'https', 'socks4', 'socks4a', 'socks4h', 'socks5', 'socks5a', 'socks5h']

+ 9 - 9
netbox/utilities/counters.py

@@ -15,12 +15,12 @@ def get_counters_for_model(model):
     return registry['counter_fields'][model].items()
 
 
-def update_counter(model, pk, counter_name, value):
+def update_counter(model, pk, counter_name, value, using=None):
     """
     Increment or decrement a counter field on an object identified by its model and primary key (PK). Positive values
     will increment; negative values will decrement.
     """
-    model.objects.filter(pk=pk).update(
+    model.objects.using(using).filter(pk=pk).update(
         **{counter_name: F(counter_name) + value}
     )
 
@@ -47,7 +47,7 @@ def update_counts(model, field_name, related_query):
 # Signal handlers
 #
 
-def post_save_receiver(sender, instance, created, **kwargs):
+def post_save_receiver(sender, instance, created, using=None, **kwargs):
     """
     Update counter fields on related objects when a TrackingModelMixin subclass is created or modified.
     """
@@ -59,9 +59,9 @@ def post_save_receiver(sender, instance, created, **kwargs):
 
         # Update the counters on the old and/or new parents as needed
         if old_pk is not None:
-            update_counter(parent_model, old_pk, counter_name, -1)
+            update_counter(parent_model, old_pk, counter_name, -1, using=using)
         if new_pk is not None and (has_old_field or created):
-            update_counter(parent_model, new_pk, counter_name, 1)
+            update_counter(parent_model, new_pk, counter_name, 1, using=using)
 
 
 def _parent_is_being_deleted(origin, parent_model, parent_pk):
@@ -86,7 +86,7 @@ def _parent_is_being_deleted(origin, parent_model, parent_pk):
     return isinstance(origin, parent_model) and origin.pk == parent_pk
 
 
-def pre_delete_receiver(sender, instance, origin, **kwargs):
+def pre_delete_receiver(sender, instance, origin, using=None, **kwargs):
     """
     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
@@ -99,12 +99,12 @@ def pre_delete_receiver(sender, instance, origin, **kwargs):
         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():
+        if not sender.objects.using(using).filter(pk=instance.pk).exists():
             instance._previously_removed = True
         return
 
 
-def post_delete_receiver(sender, instance, origin, **kwargs):
+def post_delete_receiver(sender, instance, origin, using=None, **kwargs):
     """
     Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
     """
@@ -117,7 +117,7 @@ def post_delete_receiver(sender, instance, origin, **kwargs):
 
         # 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)
+            update_counter(parent_model, parent_pk, counter_name, -1, using=using)
 
 
 #

+ 21 - 2
netbox/utilities/html.py

@@ -3,7 +3,7 @@ import re
 import nh3
 from django.utils.html import escape
 
-from .constants import HTML_ALLOWED_ATTRIBUTES, HTML_ALLOWED_TAGS
+from .constants import HTML_ALLOWED_ATTRIBUTES, HTML_ALLOWED_TAGS, IMAGE_URL_SCHEMES
 
 __all__ = (
     'clean_html',
@@ -11,17 +11,36 @@ __all__ = (
     'highlight',
 )
 
+SCHEME_RE = re.compile(r'^([a-zA-Z][a-zA-Z0-9+.-]*):')
+
+# Per the URL spec, browsers ignore leading/trailing C0 control characters & space, and strip any tab or
+# newline characters appearing within a URL. We must normalize accordingly before checking the scheme.
+URL_STRIP_CHARS = ''.join(chr(c) for c in range(0x21))
+URL_REMOVE_CHARS = str.maketrans('', '', '\t\r\n')
+
+
+def _attribute_filter(tag, attr, value):
+    """Returns str to keep/modify attribute, None to remove it."""
+    if tag == 'img' and attr == 'src':
+        match = SCHEME_RE.match(value.strip(URL_STRIP_CHARS).translate(URL_REMOVE_CHARS))
+        if match and match.group(1).lower() not in IMAGE_URL_SCHEMES:
+            return None
+    return value
+
 
 def clean_html(html, schemes):
     """
     Sanitizes HTML based on a whitelist of allowed tags and attributes.
     Also takes a list of allowed URI schemes.
     """
+    url_schemes = set(schemes)
+    attribute_filter = None if url_schemes <= IMAGE_URL_SCHEMES else _attribute_filter
     return nh3.clean(
         html,
         tags=HTML_ALLOWED_TAGS,
         attributes=HTML_ALLOWED_ATTRIBUTES,
-        url_schemes=set(schemes)
+        url_schemes=url_schemes,
+        attribute_filter=attribute_filter,
     )
 
 

+ 14 - 0
netbox/utilities/testing/base.py

@@ -125,6 +125,20 @@ class TestCase(_TestCase):
             err_message = f"Expected HTTP status {expected_status}; received {response.status_code}: {err}"
         self.assertEqual(response.status_code, expected_status, err_message)
 
+    def assertNotCacheable(self, response):
+        """
+        TestCase method. Assert that a response instructs the browser not to persist its content
+        to the local cache. Views which render potentially sensitive content (e.g. the contents of
+        a synced data file) must not be written to the browser's cache, where they would remain
+        readable after the session has ended.
+        """
+        cache_control = response.headers.get('Cache-Control', '')
+        self.assertIn(
+            'no-store',
+            cache_control,
+            f"Expected a no-store cache directive; received Cache-Control: '{cache_control}'"
+        )
+
 
 class ModelTestCase(TestCase):
     """

+ 27 - 0
netbox/utilities/testing/utils.py

@@ -197,3 +197,30 @@ def get_random_string(length, charset=None):
     """
     characters = string.ascii_letters + string.digits  # a-z, A-Z, 0-9
     return ''.join(random.choice(characters) for __ in range(length))
+
+
+#
+# Database routing
+#
+
+class UnpinnedQuery(Exception):
+    """Raised when a query which should have been pinned to a connection is routed instead."""
+
+
+class PinnedConnectionRouter:
+    """
+    Fails any read or write of the given models which is not pinned to an explicit database
+    alias. Django consults DATABASE_ROUTERS only for queries which name no connection, so a
+    signal handler which threads through the alias supplied by the signal never reaches
+    this router. Each test leaves out the model being saved, as Django routes that save
+    itself.
+    """
+    def __init__(self, *models):
+        self.models = models
+
+    def _check(self, model, **hints):
+        if model in self.models:
+            raise UnpinnedQuery(f"{model.__name__} query was routed rather than pinned to a connection")
+
+    db_for_read = _check
+    db_for_write = _check

+ 98 - 1
netbox/utilities/tests/test_counters.py

@@ -1,9 +1,17 @@
 from unittest.mock import patch
 
+from django.db.utils import ConnectionDoesNotExist
+from django.test import override_settings
 from django.urls import reverse
 
 from dcim.models import *
-from utilities.counters import connect_counters, update_counter
+from utilities.counters import (
+    connect_counters,
+    post_delete_receiver,
+    post_save_receiver,
+    pre_delete_receiver,
+    update_counter,
+)
 from utilities.testing.base import TestCase
 from utilities.testing.utils import create_test_device
 
@@ -192,3 +200,92 @@ class CountersTestCase(TestCase):
         vc.refresh_from_db()
         self.assertEqual(device1.device_type.device_count, 2, 'device_count should decrement exactly once')
         self.assertEqual(vc.member_count, 0, 'member_count should decrement exactly once')
+
+
+class UnpinnedQuery(Exception):
+    """Raised when a query which should have been pinned to a connection is routed instead."""
+
+
+class PinnedConnectionRouter:
+    """
+    Fails any read or write of the given models which is not pinned to an explicit database alias.
+    Django consults DATABASE_ROUTERS only for queries which name no connection, so a signal handler
+    which threads through the alias supplied by the signal never reaches this router. Each test
+    leaves out the model being written, as Django routes that write itself.
+    """
+    def __init__(self, *models):
+        self.models = models
+
+    def _check(self, model, **hints):
+        if model in self.models:
+            raise UnpinnedQuery(f"{model.__name__} query was routed rather than pinned to a connection")
+
+    db_for_read = _check
+    db_for_write = _check
+
+
+class CounterConnectionTestCase(TestCase):
+    """
+    Validate that the counter cache handlers issue their queries against the connection the
+    triggering object was written to, rather than letting DATABASE_ROUTERS select one. A routed
+    query updates a counter in a different database than the one holding the change which triggered
+    it, leaving the cached count silently wrong.
+    """
+    @classmethod
+    def setUpTestData(cls):
+        cls.device = create_test_device('Device 1')
+
+    def test_create_pins_counter_increment(self):
+        with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(Device)]):
+            Interface.objects.create(device=self.device, name='Interface 1')
+
+        self.device.refresh_from_db()
+        self.assertEqual(self.device.interface_count, 1)
+
+    def test_move_pins_both_counter_updates(self):
+        other = create_test_device('Device 2')
+        interface = Interface.objects.create(device=self.device, name='Interface 1')
+
+        interface = Interface.objects.get(pk=interface.pk)
+        interface.device = other
+        with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(Device)]):
+            interface.save()
+
+        self.device.refresh_from_db()
+        other.refresh_from_db()
+        self.assertEqual(self.device.interface_count, 0)
+        self.assertEqual(other.interface_count, 1)
+
+    def test_receivers_use_the_alias_supplied_by_the_signal(self):
+        """
+        The tests above prove the queries name *an* alias, but with one configured database that
+        alias is always 'default' — they would pass just as well against a hardcoded
+        .using('default'). Invoking each receiver with an alias that does not exist distinguishes
+        "threaded through from the signal" from "happens to be the default".
+        """
+        interface = Interface.objects.create(device=self.device, name='Interface 1')
+        interface = Interface.objects.get(pk=interface.pk)
+
+        with self.assertRaises(ConnectionDoesNotExist):
+            post_save_receiver(Interface, interface, created=True, using='nonexistent')
+
+        # origin=None: the parent is not itself being deleted, so the existence guard runs
+        with self.assertRaises(ConnectionDoesNotExist):
+            pre_delete_receiver(Interface, interface, origin=None, using='nonexistent')
+
+        with self.assertRaises(ConnectionDoesNotExist):
+            post_delete_receiver(Interface, interface, origin=None, using='nonexistent')
+
+    def test_delete_pins_counter_decrement(self):
+        interface = Interface.objects.create(device=self.device, name='Interface 1')
+        self.device.refresh_from_db()
+        self.assertEqual(self.device.interface_count, 1)
+
+        # The delete itself is pinned, as Django routes an unpinned one; that leaves
+        # pre_delete_receiver's existence guard as the only Interface query in scope, and that read
+        # decides whether the decrement below happens at all.
+        with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(Device, Interface)]):
+            Interface.objects.using('default').filter(pk=interface.pk).delete()
+
+        self.device.refresh_from_db()
+        self.assertEqual(self.device.interface_count, 0)

+ 101 - 0
netbox/utilities/tests/test_html.py

@@ -0,0 +1,101 @@
+from django.test import SimpleTestCase, tag
+
+from utilities.html import clean_html
+
+TEST_SCHEMES = ['file', 'ftp', 'ssh', 'http', 'https']
+
+
+class CleanHTMLURLPolicyTestCase(SimpleTestCase):
+
+    @tag('regression')
+    def test_img_src_disallowed_schemes(self):
+        """
+        file:/ftp:/ssh: image sources are stripped while non-src attributes survive.
+        (Core regression: these schemes are in ALLOWED_URL_SCHEMES but forbidden for images.)
+        """
+        html = (
+            '<img src="file:///etc" alt="a">'
+            '<img src="ftp://host/i.png" alt="b">'
+            '<img src="ssh://host/i.png" alt="c">'
+        )
+        result = clean_html(html, TEST_SCHEMES)
+        self.assertNotIn('file:///', result)
+        self.assertNotIn('ftp://', result)
+        self.assertNotIn('ssh://', result)
+        self.assertIn('alt="a"', result)
+        self.assertIn('alt="b"', result)
+        self.assertIn('alt="c"', result)
+
+    @tag('regression')
+    def test_img_src_leading_whitespace(self):
+        """Disallowed schemes hidden behind leading whitespace are still stripped."""
+        html = '<img src=" ssh://host/i.png">'
+        result = clean_html(html, TEST_SCHEMES)
+        self.assertNotIn('ssh://', result)
+
+    @tag('regression')
+    def test_img_src_embedded_control_characters(self):
+        """Disallowed schemes obscured by tab/newline/control characters are still stripped."""
+        html = (
+            '<img src="ss&#9;h://host/i.png">'
+            '<img src="fi&#13;le:///etc">'
+            '<img src="&#1;ftp://host/i.png">'
+        )
+        result = clean_html(html, TEST_SCHEMES)
+        self.assertNotIn('h://host', result)
+        self.assertNotIn('le:///etc', result)
+        self.assertNotIn('ftp://host', result)
+
+    @tag('regression')
+    def test_img_src_http_https_and_relative(self):
+        """http/https/relative image sources are retained."""
+        html = (
+            '<img src="https://example.com/i1.png">'
+            '<img src="http://example.com/i2.png">'
+            '<img src="/rel.png">'
+            '<img src="rel.png">'
+            '<img src="//cdn.example.com/i3.png">'
+        )
+        result = clean_html(html, TEST_SCHEMES)
+        self.assertIn('example.com/i1.png', result)
+        self.assertIn('example.com/i2.png', result)
+        self.assertIn('src="/rel.png"', result)
+        self.assertIn('src="rel.png"', result)
+        self.assertIn('src="//cdn.example.com/i3.png"', result)
+
+    @tag('regression')
+    def test_link_href_preserves_all_schemes(self):
+        """a[href] behavior is unchanged — all configured schemes remain allowed."""
+        html = (
+            '<a href="ssh://host">s</a>'
+            '<a href="file:///etc">f</a>'
+            '<a href="ftp://host">f</a>'
+            '<a href="https://example.com">h</a>'
+        )
+        result = clean_html(html, TEST_SCHEMES)
+        self.assertIn('href="ssh://host"', result)
+        self.assertIn('href="file:///etc"', result)
+        self.assertIn('href="ftp://host"', result)
+        self.assertIn('href="https://example.com"', result)
+
+    @tag('regression')
+    def test_mixed_content(self):
+        """Different policies for img vs a in the same input."""
+        html = '<a href="ssh://host">L</a><img src="ssh://host/i.png">'
+        result = clean_html(html, TEST_SCHEMES)
+        self.assertIn('href="ssh://host"', result)
+        self.assertNotIn('ssh://host/i.png', result)
+
+    @tag('regression')
+    def test_javascript_url_blocked(self):
+        """nh3's url_schemes filtering still applies with the attribute filter in place."""
+        html = '<a href="javascript:alert(1)">x</a><img src="javascript:alert(1)">'
+        result = clean_html(html, ['https', 'mailto'])
+        self.assertNotIn('javascript:', result)
+
+    @tag('regression')
+    def test_img_src_respects_allowed_url_schemes(self):
+        """Image sources must respect ALLOWED_URL_SCHEMES, not just IMAGE_URL_SCHEMES."""
+        html = '<img src="http://example.com/i.png">'
+        result = clean_html(html, ['https', 'mailto'])
+        self.assertNotIn('http://example.com', result)

+ 7 - 7
netbox/virtualization/signals.py

@@ -8,20 +8,20 @@ from .models import Cluster, VirtualDisk, VirtualMachine
 
 
 @receiver((post_delete, post_save), sender=VirtualDisk)
-def update_virtualmachine_disk(instance, **kwargs):
+def update_virtualmachine_disk(instance, using=None, **kwargs):
     """
     When a VirtualDisk has been modified, update the aggregate disk_size value of its VM.
     """
-    vm = instance.virtual_machine
-    VirtualMachine.objects.filter(pk=vm.pk).update(
-        disk=vm.virtualdisks.aggregate(Sum('size'))['size__sum']
+    disks = VirtualDisk.objects.using(using).filter(virtual_machine_id=instance.virtual_machine_id)
+    VirtualMachine.objects.using(using).filter(pk=instance.virtual_machine_id).update(
+        disk=disks.aggregate(Sum('size'))['size__sum']
     )
 
 
 @receiver(post_save, sender=Cluster)
-def update_virtualmachine_site(instance, **kwargs):
+def update_virtualmachine_site(instance, using=None, **kwargs):
     """
     Update the assigned site for all VMs to match that of the Cluster (if any).
     """
-    if instance._site:
-        chunked_update(VirtualMachine.objects.filter(cluster=instance), site=instance._site)
+    if instance._site_id:
+        chunked_update(VirtualMachine.objects.using(using).filter(cluster=instance), site_id=instance._site_id)

+ 65 - 1
netbox/virtualization/tests/test_signals.py

@@ -1,7 +1,9 @@
 from django.contrib.contenttypes.models import ContentType
-from django.test import TestCase
+from django.test import TestCase, override_settings
 
 from dcim.models import Site
+from utilities.testing import PinnedConnectionRouter
+from virtualization import signals
 from virtualization.models import Cluster, ClusterType, VirtualDisk, VirtualMachine
 
 
@@ -83,3 +85,65 @@ class UpdateVirtualMachineSiteSignalTestCase(TestCase):
 
         vm.refresh_from_db()
         self.assertEqual(vm.site, self.site_a)
+
+
+class VirtualizationSignalConnectionTestCase(TestCase):
+    """
+    Verify the propagation handlers issue every query against the connection the saved
+    object was written to, rather than letting DATABASE_ROUTERS select one. On an
+    installation with routers configured (e.g. netbox_branching), a routed query reads from
+    or writes to a different database than the one being saved.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site_a = Site.objects.create(name='Site A', slug='site-a')
+        cls.site_b = Site.objects.create(name='Site B', slug='site-b')
+        cls.cluster_type = ClusterType.objects.create(name='Cluster Type', slug='cluster-type')
+
+    def test_cluster_save_pins_vm_update_to_saving_connection(self):
+        cluster = Cluster.objects.create(name='Cluster', type=self.cluster_type, scope=self.site_a)
+        vm = VirtualMachine.objects.create(name='VM 1', cluster=cluster)
+
+        # Site is deliberately absent from the router: Cluster.save() resolves its generic
+        # scope through CachedScopeMixin.cache_related_objects(), which is a routed read of
+        # its own and not something this handler controls.
+        cluster = Cluster.objects.get(pk=cluster.pk)
+        cluster.scope_id = self.site_b.pk
+        with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(VirtualMachine)]):
+            cluster.save()
+
+        vm.refresh_from_db()
+        self.assertEqual(vm.site, self.site_b)
+
+    def test_virtualdisk_save_pins_vm_update_to_saving_connection(self):
+        cluster = Cluster.objects.create(name='Cluster', type=self.cluster_type)
+        vm = VirtualMachine.objects.create(name='VM 1', cluster=cluster)
+        disk = VirtualDisk.objects.create(virtual_machine=vm, name='disk0', size=50)
+
+        # Re-fetch so the virtual_machine relation is uncached; resolving it to reach the VM
+        # or its disks is itself a routed read.
+        disk = VirtualDisk.objects.get(pk=disk.pk)
+        disk.size = 80
+        with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(VirtualMachine)]):
+            disk.save()
+
+        vm.refresh_from_db()
+        self.assertEqual(vm.disk, 80)
+
+    def test_virtualdisk_handler_pins_disk_aggregate_to_given_connection(self):
+        # VirtualDisk cannot be listed in the router above, as Django routes the save of the
+        # disk itself; calling the handler directly leaves the aggregate over the sibling
+        # disks as the only VirtualDisk query in scope.
+        cluster = Cluster.objects.create(name='Cluster', type=self.cluster_type)
+        vm = VirtualMachine.objects.create(name='VM 1', cluster=cluster)
+        disk = VirtualDisk.objects.create(virtual_machine=vm, name='disk0', size=50)
+        VirtualDisk.objects.create(virtual_machine=vm, name='disk1', size=75)
+
+        disk = VirtualDisk.objects.get(pk=disk.pk)
+        router = PinnedConnectionRouter(VirtualDisk, VirtualMachine)
+        with override_settings(DATABASE_ROUTERS=[router]):
+            signals.update_virtualmachine_disk(instance=disk, using='default')
+
+        vm.refresh_from_db()
+        self.assertEqual(vm.disk, 125)

+ 49 - 1
netbox/virtualization/tests/test_views.py

@@ -6,7 +6,7 @@ from django.urls import reverse
 
 from dcim.choices import InterfaceModeChoices
 from dcim.models import DeviceRole, MACAddress, Platform, Site
-from extras.models import ConfigTemplate
+from extras.models import ConfigContext, ConfigTemplate
 from ipam.models import VLAN, VRF
 from utilities.testing import ViewTestCases, create_tags, create_test_device, create_test_virtualmachine, post_data
 from virtualization.choices import *
@@ -557,6 +557,54 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertHttpStatus(response, 200)
         self.assertIn(b'Error rendering template', response.content)
 
+    def test_virtualmachine_configcontext_is_not_cacheable(self):
+        """
+        The config context tab renders the merged context data, which may contain sensitive
+        values, so the response must not be cached by the browser.
+        """
+        ConfigContext.objects.create(name='Config Context 1', data={'password': 'super-secret-password'})
+        vm = VirtualMachine.objects.first()
+
+        self.add_permissions('virtualization.view_virtualmachine', 'extras.view_configcontext')
+        url = reverse('virtualization:virtualmachine_configcontext', kwargs={'pk': vm.pk})
+        response = self.client.get(url)
+        self.assertHttpStatus(response, 200)
+
+        # Confirm the context data is in fact rendered in the response
+        self.assertIn(b'super-secret-password', response.content)
+
+        self.assertNotCacheable(response)
+
+    def test_virtualmachine_renderconfig_is_not_cacheable(self):
+        """
+        The render config tab renders the config template with context data substituted into it,
+        which may contain sensitive values, so the response must not be cached by the browser.
+        """
+        configtemplate = ConfigTemplate.objects.create(
+            name='Test Config Template',
+            template_code='enable secret super-secret-password'
+        )
+        vm = VirtualMachine.objects.first()
+        vm.config_template = configtemplate
+        vm.save()
+
+        self.add_permissions('virtualization.view_virtualmachine', 'virtualization.render_config_virtualmachine')
+        url = reverse('virtualization:virtualmachine_render-config', kwargs={'pk': vm.pk})
+
+        response = self.client.get(url)
+        self.assertHttpStatus(response, 200)
+
+        # Confirm the rendered config is in fact present in the response
+        self.assertIn(b'super-secret-password', response.content)
+
+        self.assertNotCacheable(response)
+
+        # The direct export of the rendered config must not be cached either
+        response = self.client.get(url, {'export': 1})
+        self.assertHttpStatus(response, 200)
+        self.assertIn(b'super-secret-password', response.content)
+        self.assertNotCacheable(response)
+
 
 class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = VMInterface

+ 2 - 2
netbox/virtualization/ui/panels.py

@@ -24,7 +24,7 @@ class ClusterPanel(panels.ObjectAttributesPanel):
 
 class VirtualMachineTypePanel(panels.ObjectAttributesPanel):
     name = attrs.TextAttr('name')
-    default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True)
+    default_platform = attrs.NestedObjectAttr('default_platform', linkify=True, max_depth=3)
     default_vcpus = attrs.TextAttr('default_vcpus', label=_('Default vCPUs'))
     default_memory = attrs.TemplatedAttr(
         'default_memory',
@@ -44,7 +44,7 @@ class VirtualMachinePanel(panels.ObjectAttributesPanel):
     virtual_machine_type = attrs.RelatedObjectAttr('virtual_machine_type', linkify=True, label=_('Type'))
     status = attrs.ChoiceAttr('status')
     start_on_boot = attrs.ChoiceAttr('start_on_boot')
-    role = attrs.RelatedObjectAttr('role', linkify=True, colored=True)
+    role = attrs.NestedObjectAttr('role', linkify=True, max_depth=3, colored=True)
     platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3)
     description = attrs.TextAttr('description')
     serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)

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

@@ -9,7 +9,7 @@ class WirelessLANGroupPanel(panels.NestedGroupObjectPanel):
 
 class WirelessLANPanel(panels.ObjectAttributesPanel):
     ssid = attrs.TextAttr('ssid', label=_('SSID'))
-    group = attrs.RelatedObjectAttr('group', linkify=True)
+    group = attrs.NestedObjectAttr('group', linkify=True)
     status = attrs.ChoiceAttr('status')
     scope = attrs.GenericForeignKeyAttr('scope', linkify=True, nested=True, max_depth=3)
     description = attrs.TextAttr('description')

+ 7 - 7
requirements.txt

@@ -1,7 +1,7 @@
 colorama==0.4.6
 Django==6.1
 django-cors-headers==4.9.0
-django-debug-toolbar==7.1.0
+django-debug-toolbar==7.1.1
 django-filter==26.1
 django-graphiql-debug-toolbar==0.2.0
 django-htmx==1.29.0
@@ -19,28 +19,28 @@ djangorestframework==3.18.0
 drf-spectacular==0.30.0
 drf-spectacular-sidecar==2026.8.1
 feedparser==6.0.14
-gunicorn==26.0.0
+gunicorn==26.2.0
 Jinja2==3.1.6
 jsonschema==4.26.0
 Markdown==3.10.3
 mkdocs==1.6.1
 mkdocs-material==9.7.7
 mkdocstrings==1.0.6
-mkdocstrings-python==2.0.5
+mkdocstrings-python==2.0.7
 netaddr==1.3.0
-nh3==0.3.6
+nh3==0.3.7
 Pillow==12.3.0
 psycopg[c,pool]==3.3.4
 PyYAML==6.0.3
 redis==7.4.1
 requests==2.34.2
-rq==2.10.0
+rq==2.11.0
 social-auth-app-django==6.0.1
 social-auth-core==5.1.0
-sorl-thumbnail==13.0.0
+sorl-thumbnail==13.1.0
 strawberry-graphql==0.324.0
 strawberry-graphql-django==0.87.0
 svgwrite==1.4.3
 tablib==3.10.0
 tzdata==2026.3
-zensical==0.0.53
+zensical==0.0.57

Неке датотеке нису приказане због велике количине промена