Răsfoiți Sursa

Merge branch 'main' into 22750-convert-raw-IDs-to-ObjectVars

mburggraf 4 zile în urmă
părinte
comite
3c85219534
100 a modificat fișierele cu 5422 adăugiri și 724 ștergeri
  1. 1 1
      .claude/skills/add-config-param/SKILL.md
  2. 12 0
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 37 0
      .github/workflows/enforce-milestone.yml
  4. 37 2
      .github/workflows/release.yml
  5. 11 0
      contrib/generated_schema.json
  6. 45 152
      contrib/openapi.json
  7. 3 0
      docs/configuration/security.md
  8. 9 0
      docs/customization/custom-fields.md
  9. 8 3
      docs/customization/custom-scripts.md
  10. 6 0
      docs/development/building-the-package.md
  11. 6 2
      docs/models/dcim/frontport.md
  12. 2 0
      docs/models/tenancy/tenantgroup.md
  13. 64 0
      docs/release-notes/version-4.6.md
  14. 25 2
      netbox/circuits/graphql/types.py
  15. 21 21
      netbox/circuits/tests/query_counts.json
  16. 2 2
      netbox/core/forms/bulk_edit.py
  17. 0 6
      netbox/core/forms/model_forms.py
  18. 3 3
      netbox/core/tests/query_counts.json
  19. 25 0
      netbox/core/tests/test_forms.py
  20. 19 0
      netbox/core/tests/test_views.py
  21. 3 0
      netbox/core/views.py
  22. 32 3
      netbox/dcim/choices.py
  23. 15 1
      netbox/dcim/forms/bulk_edit.py
  24. 15 8
      netbox/dcim/forms/mixins.py
  25. 2 2
      netbox/dcim/forms/model_forms.py
  26. 14 12
      netbox/dcim/forms/object_create.py
  27. 133 25
      netbox/dcim/graphql/types.py
  28. 10 6
      netbox/dcim/models/cables.py
  29. 3 2
      netbox/dcim/models/device_components.py
  30. 4 3
      netbox/dcim/models/devices.py
  31. 15 10
      netbox/dcim/models/modules.py
  32. 252 89
      netbox/dcim/signals.py
  33. 69 69
      netbox/dcim/tests/query_counts.json
  34. 114 0
      netbox/dcim/tests/test_api.py
  35. 39 0
      netbox/dcim/tests/test_cablepaths.py
  36. 125 0
      netbox/dcim/tests/test_forms.py
  37. 124 1
      netbox/dcim/tests/test_models.py
  38. 613 6
      netbox/dcim/tests/test_signals.py
  39. 131 10
      netbox/dcim/tests/test_views.py
  40. 1 1
      netbox/dcim/ui/panels.py
  41. 9 3
      netbox/dcim/views.py
  42. 34 0
      netbox/extras/api/routers.py
  43. 2 1
      netbox/extras/api/serializers_/customfields.py
  44. 2 3
      netbox/extras/api/urls.py
  45. 61 24
      netbox/extras/api/views.py
  46. 2 0
      netbox/extras/events.py
  47. 98 0
      netbox/extras/filters.py
  48. 7 2
      netbox/extras/forms/bulk_edit.py
  49. 11 2
      netbox/extras/graphql/mixins.py
  50. 4 3
      netbox/extras/management/commands/runscript.py
  51. 40 12
      netbox/extras/models/customfields.py
  52. 0 2
      netbox/extras/models/models.py
  53. 26 2
      netbox/extras/models/scripts.py
  54. 1 1
      netbox/extras/scripts.py
  55. 24 12
      netbox/extras/signals.py
  56. 7 7
      netbox/extras/tests/query_counts.json
  57. 258 2
      netbox/extras/tests/test_api.py
  58. 52 0
      netbox/extras/tests/test_api_routers.py
  59. 517 41
      netbox/extras/tests/test_customfields.py
  60. 318 6
      netbox/extras/tests/test_event_rules.py
  61. 14 15
      netbox/extras/tests/test_management_commands.py
  62. 30 0
      netbox/extras/tests/test_scripts.py
  63. 306 0
      netbox/extras/tests/test_scripts_deletion.py
  64. 2 2
      netbox/extras/tests/test_signals.py
  65. 37 0
      netbox/extras/tests/test_templatetags.py
  66. 104 0
      netbox/extras/tests/test_views.py
  67. 8 0
      netbox/extras/views.py
  68. 3 2
      netbox/ipam/api/views.py
  69. 9 9
      netbox/ipam/filtersets.py
  70. 1 1
      netbox/ipam/forms/model_forms.py
  71. 66 6
      netbox/ipam/graphql/types.py
  72. 0 2
      netbox/ipam/models/fhrp.py
  73. 2 2
      netbox/ipam/models/ip.py
  74. 17 15
      netbox/ipam/signals.py
  75. 32 32
      netbox/ipam/tests/query_counts.json
  76. 14 0
      netbox/ipam/tests/test_api.py
  77. 92 8
      netbox/ipam/tests/test_filtersets.py
  78. 57 2
      netbox/ipam/tests/test_signals.py
  79. 4 1
      netbox/ipam/tests/test_views.py
  80. 14 3
      netbox/netbox/api/viewsets/__init__.py
  81. 48 3
      netbox/netbox/api/viewsets/mixins.py
  82. 4 4
      netbox/netbox/config/parameters.py
  83. 7 0
      netbox/netbox/constants.py
  84. 14 11
      netbox/netbox/context_managers.py
  85. 6 3
      netbox/netbox/denormalized.py
  86. 5 1
      netbox/netbox/forms/bulk_edit.py
  87. 26 0
      netbox/netbox/forms/bulk_import.py
  88. 43 0
      netbox/netbox/graphql/optimization.py
  89. 66 1
      netbox/netbox/graphql/pagination.py
  90. 66 0
      netbox/netbox/models/deletion.py
  91. 43 2
      netbox/netbox/models/features.py
  92. 1 4
      netbox/netbox/settings.py
  93. 38 21
      netbox/netbox/settings_utils.py
  94. 41 2
      netbox/netbox/tables/columns.py
  95. 63 0
      netbox/netbox/tables/tables.py
  96. 73 0
      netbox/netbox/tests/test_base_classes.py
  97. 87 0
      netbox/netbox/tests/test_context_managers.py
  98. 103 6
      netbox/netbox/tests/test_forms.py
  99. 333 0
      netbox/netbox/tests/test_graphql.py
  100. 20 1
      netbox/netbox/tests/test_model_features.py

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

@@ -43,7 +43,7 @@ ConfigParam(
     field=forms.BooleanField,   # or IntegerField, CharField, JSONField, SimpleArrayField
     field=forms.BooleanField,   # or IntegerField, CharField, JSONField, SimpleArrayField
     # field_kwargs only when extra widget/validation config is needed:
     # field_kwargs only when extra widget/validation config is needed:
     field_kwargs={
     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
       placeholder: A TypeError exception was raised
     validations:
     validations:
       required: true
       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).

+ 37 - 0
.github/workflows/enforce-milestone.yml

@@ -0,0 +1,37 @@
+name: Enforce milestone on close
+
+on:
+  issues:
+    types:
+      - closed
+
+permissions:
+  issues: write
+
+jobs:
+  check-milestone:
+    name: Check Milestone
+    if: github.repository == 'netbox-community/netbox' && github.event.issue.state_reason == 'completed'
+    runs-on: ubuntu-slim
+
+    steps:
+      - name: Reopen issues completed without a milestone
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GH_REPO: ${{ github.repository }}
+          ISSUE: ${{ github.event.issue.number }}
+        run: |
+          # Grace period, in case the milestone is assigned immediately after closure
+          sleep 90
+
+          # Re-check the issue: bail out if it has been reopened or a milestone has since been set
+          DATA=$(gh issue view "$ISSUE" --json state,milestone)
+          STATE=$(jq -r '.state' <<< "$DATA")
+          MILESTONE=$(jq -r '.milestone.title // ""' <<< "$DATA")
+          if [ "$STATE" != "CLOSED" ] || [ -n "$MILESTONE" ]; then
+            echo "Nothing to do (state=$STATE, milestone=${MILESTONE:-none})"
+            exit 0
+          fi
+
+          gh issue reopen "$ISSUE" --comment \
+            "This issue was closed as completed without a milestone assigned, and has been reopened automatically. Please assign the milestone for the upcoming release, then close the issue again."

+ 37 - 2
.github/workflows/release.yml

@@ -31,6 +31,11 @@ jobs:
     name: Build package artifacts
     name: Build package artifacts
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
+    # Match the validator versions bundled by the pinned publishing action.
+    env:
+      EXPECTED_TWINE_VERSION: '7.0.0'
+      EXPECTED_PACKAGING_VERSION: '26.2'
+
     steps:
     steps:
       - name: Check out repository
       - name: Check out repository
         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -44,11 +49,39 @@ jobs:
           cache: pip
           cache: pip
 
 
       - name: Install build tooling
       - name: Install build tooling
-        run: python -m pip install --upgrade build twine
+        run: >-
+          python -m pip install --upgrade
+          build
+          "twine==$EXPECTED_TWINE_VERSION"
+          "packaging==$EXPECTED_PACKAGING_VERSION"
 
 
       - name: Install documentation toolchain
       - name: Install documentation toolchain
         run: python -m pip install -r requirements.txt
         run: python -m pip install -r requirements.txt
 
 
+      - name: Verify pre-publication tool versions
+        # Assert after all installation steps so twine check uses the expected
+        # validator, and reject any incompatible shared dependency constraints.
+        run: |
+          python - <<'PY'
+          import os
+          from importlib.metadata import version
+
+          expected = {
+              'twine': os.environ['EXPECTED_TWINE_VERSION'],
+              'packaging': os.environ['EXPECTED_PACKAGING_VERSION'],
+          }
+
+          for package, expected_version in expected.items():
+              installed_version = version(package)
+              print(f'{package}=={installed_version}')
+              if installed_version != expected_version:
+                  raise SystemExit(f'{package}=={installed_version} is installed, expected {expected_version}')
+
+          print(f'build=={version("build")}')
+          PY
+
+          python -m pip check
+
       - name: Render the documentation
       - name: Render the documentation
         # -c = clean cache, -s = strict (abort on warnings); verify_wheel_contents.py
         # -c = clean cache, -s = strict (abort on warnings); verify_wheel_contents.py
         # additionally guards against a partial render reaching the wheel.
         # additionally guards against a partial render reaching the wheel.
@@ -323,6 +356,8 @@ jobs:
         run: python scripts/verify_release_tag.py "${{ github.ref_name }}" dist/*.whl
         run: python scripts/verify_release_tag.py "${{ github.ref_name }}" dist/*.whl
 
 
       - name: Publish package distributions to Test PyPI
       - name: Publish package distributions to Test PyPI
-        uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
+        # Bundles twine 7.0.0 and packaging 26.2 (requirements/runtime.txt).
+        # Keep EXPECTED_TWINE_VERSION and EXPECTED_PACKAGING_VERSION aligned when updating this action.
+        uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
         with:
         with:
           repository-url: https://test.pypi.org/legacy/
           repository-url: https://test.pypi.org/legacy/

+ 11 - 0
contrib/generated_schema.json

@@ -439,6 +439,7 @@
                         "100gbase-x-dsfp",
                         "100gbase-x-dsfp",
                         "100gbase-x-qsfp28",
                         "100gbase-x-qsfp28",
                         "100gbase-x-qsfpdd",
                         "100gbase-x-qsfpdd",
+                        "100gbase-x-sfp112",
                         "100gbase-x-sfpdd",
                         "100gbase-x-sfpdd",
                         "200gbase-x-cfp2",
                         "200gbase-x-cfp2",
                         "200gbase-x-qsfp56",
                         "200gbase-x-qsfp56",
@@ -510,6 +511,15 @@
                         "infiniband-hdr",
                         "infiniband-hdr",
                         "infiniband-ndr",
                         "infiniband-ndr",
                         "infiniband-xdr",
                         "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",
                         "t1",
                         "e1",
                         "e1",
                         "t3",
                         "t3",
@@ -540,6 +550,7 @@
                         "extreme-summitstack-128",
                         "extreme-summitstack-128",
                         "extreme-summitstack-256",
                         "extreme-summitstack-256",
                         "extreme-summitstack-512",
                         "extreme-summitstack-512",
+                        "hpe-synergy-interconnect-link",
                         "other"
                         "other"
                     ]
                     ]
                 },
                 },

Fișier diff suprimat deoarece este prea mare
+ 45 - 152
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. 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).
 A list of permitted URL schemes referenced when rendering links within NetBox. 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
 ## AUTH_PASSWORD_VALIDATORS

+ 9 - 0
docs/customization/custom-fields.md

@@ -30,6 +30,15 @@ Marking a field as required will force the user to provide a value for the field
 
 
 A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields.
 A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields.
 
 
+!!! info "This behavior changed in NetBox v4.6.8."
+    To improve performance when creating custom fields, empty field values are no longer pre-provisioned.
+
+Unless the field has been assigned a default value, creating a custom field does not write a value to the objects which already exist. An object which has never been assigned a value simply stores nothing for the field, and reports the field as having no value in the web UI, REST API, GraphQL API, and exports, exactly as if it stored an explicit null.
+
+This matters only if you query the underlying `custom_field_data` JSON directly, for example in a custom script. The field's key is absent from an object's data until a value is assigned to it, so read it with `obj.cf['field_name']` or `obj.custom_field_data.get('field_name')` rather than by direct subscript.
+
+Assigning a default value, by contrast, does write that value to every existing object at the time the field is created, so that objects can be filtered by it immediately. On a model with a very large number of objects, this can take some time. Note that a default added to a field which already exists is _not_ backfilled: objects with no value continue to report none until they are next saved.
+
 ### Filtering
 ### Filtering
 
 
 The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely.
 The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely.

+ 8 - 3
docs/customization/custom-scripts.md

@@ -23,6 +23,9 @@ Custom scripts are Python code which exists outside the NetBox code base, so the
 
 
 ## Writing Custom Scripts
 ## Writing Custom Scripts
 
 
+!!! warning "Choose a unique file name"
+    A script file's name (without the `.py` extension) becomes its Python module name when the script is loaded. A script file must not share its name with a NetBox application (e.g. `circuits.py` or `dcim.py`) or any other installed Python module: the script will shadow that module in Python's import system and can break unrelated functionality. Choose a unique, descriptive file name, such as `circuit_maintenance.py`.
+
 All custom scripts must inherit from the `extras.scripts.Script` base class. This class provides the functionality necessary to generate forms and log activity.
 All custom scripts must inherit from the `extras.scripts.Script` base class. This class provides the functionality necessary to generate forms and log activity.
 
 
 ```python
 ```python
@@ -105,7 +108,7 @@ class MyScript(Script):
 
 
 ### `commit_default`
 ### `commit_default`
 
 
-The checkbox to commit database changes when executing a script is checked by default. Set `commit_default` to False under the script's Meta class to leave this option unchecked by default.
+The checkbox to commit database changes when executing a script is checked by default. Set `commit_default` to False under the script's Meta class to leave this option unchecked by default. This setting controls only the initial state of the execution form.
 
 
 ```python
 ```python
 commit_default = False
 commit_default = False
@@ -117,7 +120,9 @@ By default, a script can be scheduled for execution at a later time. Setting `sc
 
 
 ### `notifications_default`
 ### `notifications_default`
 
 
-By default, a notification is generated for the requesting user each time a script finishes running. This attribute sets the initial value for the notifications field when running a script. Valid values are `always` (default), `on_failure`, and `never`.
+By default, a notification is generated for the user associated with the script's job each time the script finishes running. This attribute sets the initial value for the notifications field when running a script. Valid values are `always` (default), `on_failure`, and `never`.
+
+Scripts run from an event rule or the `runscript` management command use this value as their notification policy. For an event rule, the notification goes to the user associated with the triggering event, if there is one.
 
 
 ```python
 ```python
 notifications_default = 'on_failure'
 notifications_default = 'on_failure'
@@ -131,7 +136,7 @@ notifications_default = 'on_failure'
 
 
 ### `job_timeout`
 ### `job_timeout`
 
 
-Set the maximum allowed runtime for the script. If not set, `RQ_DEFAULT_TIMEOUT` will be used.
+Set the maximum allowed runtime for the script. If not set, `RQ_DEFAULT_TIMEOUT` will be used. Scripts run from an event rule use this value as their execution timeout.
 
 
 ## Accessing Request Data
 ## Accessing Request Data
 
 

+ 6 - 0
docs/development/building-the-package.md

@@ -53,6 +53,12 @@ Check the built artifacts for valid package metadata and README rendering:
 twine check dist/*
 twine check dist/*
 ```
 ```
 
 
+The wheel and sdist deliberately use Core Metadata 2.4, the lowest version required by NetBox's current project metadata. Both build targets pin this format as `core-metadata-version` in `pyproject.toml`, and CI verifies the emitted `METADATA` and `PKG-INFO` values against those pins (`verify_wheel_metadata.py` and `verify_sdist_contents.py`).
+
+The release workflow's build job pins `twine` and `packaging` to the versions bundled by the pinned `pypa/gh-action-pypi-publish` revision (its `requirements/runtime.txt`), so the pre-publication check uses the same Core Metadata validator as the publisher. Hatchling remains lower-bounded rather than pinned. The explicit Core Metadata setting prevents changes to its default from changing the artifact format.
+
+Review these settings together when updating the packaging toolchain. Keep the `twine` and `packaging` pins aligned with the publishing action, but change the Core Metadata version only when NetBox needs a newer format and the complete publishing path supports it.
+
 Confirm the wheel's version, dependency metadata, and extras match `netbox/release.yaml`, the pinned `requirements.txt`, and the declared optional-dependency groups:
 Confirm the wheel's version, dependency metadata, and extras match `netbox/release.yaml`, the pinned `requirements.txt`, and the declared optional-dependency groups:
 
 
 ```no-highlight
 ```no-highlight

+ 6 - 2
docs/models/dcim/frontport.md

@@ -1,6 +1,6 @@
 # Front Ports
 # Front Ports
 
 
-Front ports are pass-through ports which represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. Each port is assigned a physical type, and must be mapped to a specific [rear port](./rearport.md) on the same device. A single rear port may be mapped to multiple front ports, using numeric positions to annotate the specific alignment of each.
+Front ports are pass-through ports which represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. Each port is assigned a physical type, and must be mapped to one or more [rear port](./rearport.md) positions on the same device. A single rear port may be mapped to multiple front ports, using numeric positions to annotate the specific alignment of each.
 
 
 !!! tip
 !!! tip
     Like most device components, front ports are instantiated automatically from [front port templates](./frontporttemplate.md) assigned to the selected device type when a device is created.
     Like most device components, front ports are instantiated automatically from [front port templates](./frontporttemplate.md) assigned to the selected device type when a device is created.
@@ -27,12 +27,16 @@ An alternative physical label identifying the port.
 
 
 The port's termination type.
 The port's termination type.
 
 
+### Positions
+
+The number of [rear port](./rearport.md) positions to which this front port maps. For a front port which passes through to a single rear port position, set this to `1`.
+
 ### Rear Ports
 ### Rear Ports
 
 
 The rear port and position to which this front port maps.
 The rear port and position to which this front port maps.
 
 
 !!! tip
 !!! tip
-    When creating multiple front ports using a patterned name (e.g. `Port [1-12]`), you may select the equivalent number of rear port-position mappings from the list.
+    When creating multiple front ports using a patterned name (e.g. `Port [1-12]`), select one rear port-position mapping for every position of every front port being created. For example, 12 front ports with two positions each requires 24 mappings, which are assigned to the generated ports in order.
 
 
 ### Color
 ### Color
 
 

+ 2 - 0
docs/models/tenancy/tenantgroup.md

@@ -4,6 +4,8 @@
 
 
 Tenant groups may be nested recursively to achieve a multi-level hierarchy. For example, you might have a group called "Customers" containing subgroups of individual tenants grouped by product or account team.
 Tenant groups may be nested recursively to achieve a multi-level hierarchy. For example, you might have a group called "Customers" containing subgroups of individual tenants grouped by product or account team.
 
 
+A tenant group cannot be deleted if ungrouping its tenants, including those of any nested groups, would result in duplicate tenant names or slugs among ungrouped tenants.
+
 ## Fields
 ## Fields
 
 
 ### Parent
 ### Parent

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

@@ -1,5 +1,69 @@
 # NetBox v4.6
 # 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
+
+* [#22787](https://github.com/netbox-community/netbox/issues/22787) - Avoid N+1 queries when resolving generic relations (e.g. assigned objects) via the GraphQL API
+* [#22835](https://github.com/netbox-community/netbox/issues/22835) - Improve performance when provisioning new custom fields
+* [#22837](https://github.com/netbox-community/netbox/issues/22837) - Omit implicit pagination when prefetching to-one relations via the GraphQL API
+* [#22877](https://github.com/netbox-community/netbox/issues/22877) - Improve caching logic when retrieving custom fields via `get_for_model()`
+
+### Bug Fixes
+
+* [#22694](https://github.com/netbox-community/netbox/issues/22694) - Clear a device's stale rack assignment when changing its site
+* [#22745](https://github.com/netbox-community/netbox/issues/22745) - Enforce object permissions on custom script write operations via the REST API
+* [#22805](https://github.com/netbox-community/netbox/issues/22805) - Avoid re-executing the LDAP configuration file on every permission check
+* [#22821](https://github.com/netbox-community/netbox/issues/22821) - Prevent the deletion of a tenant group from creating duplicate tenant names or slugs
+* [#22825](https://github.com/netbox-community/netbox/issues/22825) - Fix cable path tracing for paths which originate from a circuit termination and traverse only pass-through ports
+* [#22828](https://github.com/netbox-community/netbox/issues/22828) - Validate that a webhook's payload URL is a valid URL or Jinja2 template when saving
+* [#22844](https://github.com/netbox-community/netbox/issues/22844) - Allow a null value for `base_choices` when creating a custom field choice set via the REST API
+* [#22848](https://github.com/netbox-community/netbox/issues/22848) - Ensure deterministic ordering of duplicate IP addresses to avoid repeating an object across paginated REST API results
+* [#22852](https://github.com/netbox-community/netbox/issues/22852) - Honor a custom script's `notifications_default` setting when the script is run from an event rule
+* [#22865](https://github.com/netbox-community/netbox/issues/22865) - Reference the appropriate component template types on the GraphQL type for inventory item templates
+* [#22879](https://github.com/netbox-community/netbox/issues/22879) - Improve the contrast of unselected radio buttons and checkboxes in dark mode
+* [#22882](https://github.com/netbox-community/netbox/issues/22882) - Fix support for the `DISTINCT` filter on nested GraphQL list fields
+* [#22894](https://github.com/netbox-community/netbox/issues/22894) - Sanitize the error message rendered when an exception occurs in `CustomLinkColumn`
+
+---
+
 ## v4.6.7 (2026-07-30)
 ## v4.6.7 (2026-07-30)
 
 
 ### Performance Improvements
 ### Performance Improvements

+ 25 - 2
netbox/circuits/graphql/types.py

@@ -5,7 +5,9 @@ import strawberry_django
 
 
 from circuits import models
 from circuits import models
 from dcim.graphql.mixins import CabledObjectMixin
 from dcim.graphql.mixins import CabledObjectMixin
+from dcim.models import Location, Region, Site, SiteGroup
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
+from netbox.graphql.optimization import build_gfk_prefetch
 from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType
 from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType
 from tenancy.graphql.types import TenantType
 from tenancy.graphql.types import TenantType
 
 
@@ -74,7 +76,19 @@ class ProviderNetworkType(PrimaryObjectType):
 class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
 class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
     circuit: Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]
     circuit: Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]
 
 
-    @strawberry_django.field(prefetch_related='termination')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'termination',
+            [
+                Location,
+                Region,
+                SiteGroup,
+                Site,
+                models.ProviderNetwork,
+            ],
+        ),
+        only=['termination_type', 'termination_id'],
+    )
     def termination(self) -> Annotated[
     def termination(self) -> Annotated[
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
@@ -133,7 +147,16 @@ class CircuitGroupType(OrganizationalObjectType):
 class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
 class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
     group: Annotated['CircuitGroupType', strawberry.lazy('circuits.graphql.types')]
     group: Annotated['CircuitGroupType', strawberry.lazy('circuits.graphql.types')]
 
 
-    @strawberry_django.field(prefetch_related='member')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'member',
+            [
+                models.Circuit,
+                models.VirtualCircuit,
+            ],
+        ),
+        only=['member_type', 'member_id'],
+    )
     def member(self) -> Annotated[
     def member(self) -> Annotated[
         Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]
         Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]
         | Annotated['VirtualCircuitType', strawberry.lazy('circuits.graphql.types')],
         | Annotated['VirtualCircuitType', strawberry.lazy('circuits.graphql.types')],

+ 21 - 21
netbox/circuits/tests/query_counts.json

@@ -1,24 +1,24 @@
 {
 {
-  "circuit:api_list_objects": 16,
-  "circuit:list_objects_with_permission": 22,
-  "circuitgroup:api_list_objects": 13,
-  "circuitgroup:list_objects_with_permission": 20,
+  "circuit:api_list_objects": 15,
+  "circuit:list_objects_with_permission": 19,
+  "circuitgroup:api_list_objects": 12,
+  "circuitgroup:list_objects_with_permission": 17,
   "circuitgroupassignment:api_list_objects": 17,
   "circuitgroupassignment:api_list_objects": 17,
-  "circuitgroupassignment:list_objects_with_permission": 26,
-  "circuittermination:api_list_objects": 18,
-  "circuittermination:list_objects_with_permission": 24,
-  "circuittype:api_list_objects": 13,
-  "circuittype:list_objects_with_permission": 20,
-  "provider:api_list_objects": 15,
-  "provider:list_objects_with_permission": 20,
-  "provideraccount:api_list_objects": 14,
-  "provideraccount:list_objects_with_permission": 21,
-  "providernetwork:api_list_objects": 14,
-  "providernetwork:list_objects_with_permission": 21,
-  "virtualcircuit:api_list_objects": 16,
-  "virtualcircuit:list_objects_with_permission": 24,
-  "virtualcircuittermination:api_list_objects": 17,
-  "virtualcircuittermination:list_objects_with_permission": 23,
-  "virtualcircuittype:api_list_objects": 13,
-  "virtualcircuittype:list_objects_with_permission": 20
+  "circuitgroupassignment:list_objects_with_permission": 23,
+  "circuittermination:api_list_objects": 17,
+  "circuittermination:list_objects_with_permission": 21,
+  "circuittype:api_list_objects": 12,
+  "circuittype:list_objects_with_permission": 17,
+  "provider:api_list_objects": 14,
+  "provider:list_objects_with_permission": 17,
+  "provideraccount:api_list_objects": 13,
+  "provideraccount:list_objects_with_permission": 18,
+  "providernetwork:api_list_objects": 13,
+  "providernetwork:list_objects_with_permission": 18,
+  "virtualcircuit:api_list_objects": 15,
+  "virtualcircuit:list_objects_with_permission": 21,
+  "virtualcircuittermination:api_list_objects": 16,
+  "virtualcircuittermination:list_objects_with_permission": 20,
+  "virtualcircuittype:api_list_objects": 12,
+  "virtualcircuittype:list_objects_with_permission": 17
 }
 }

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

@@ -41,8 +41,8 @@ class DataSourceBulkEditForm(PrimaryModelBulkEditForm):
 
 
     model = DataSource
     model = DataSource
     fieldsets = (
     fieldsets = (
-        FieldSet('type', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules', 'comments'),
+        FieldSet('type', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules'),
     )
     )
     nullable_fields = (
     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

@@ -168,12 +168,6 @@ class ConfigRevisionForm(forms.ModelForm, metaclass=ConfigFormMetaclass):
         model = ConfigRevision
         model = ConfigRevision
         fields = '__all__'
         fields = '__all__'
         widgets = {
         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(),
             'comment': forms.Textarea(),
         }
         }
 
 

+ 3 - 3
netbox/core/tests/query_counts.json

@@ -1,8 +1,8 @@
 {
 {
   "datafile:api_list_objects": 10,
   "datafile:api_list_objects": 10,
-  "datafile:list_objects_with_permission": 18,
-  "datasource:api_list_objects": 12,
-  "datasource:list_objects_with_permission": 20,
+  "datafile:list_objects_with_permission": 17,
+  "datasource:api_list_objects": 11,
+  "datasource:list_objects_with_permission": 17,
   "job:api_list_objects": 12,
   "job:api_list_objects": 12,
   "job:list_objects_with_permission": 19
   "job:list_objects_with_permission": 19
 }
 }

+ 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)
         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(
 class JobTestCase(
     ViewTestCases.GetObjectViewTestCase,
     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.http import Http404, HttpResponse, HttpResponseForbidden
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.decorators import method_decorator
 from django.utils.http import content_disposition_header
 from django.utils.http import content_disposition_header
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
+from django.views.decorators.cache import never_cache
 from django.views.generic import View
 from django.views.generic import View
 from django_rq.queues import get_queue_by_index, get_redis_connection
 from django_rq.queues import get_queue_by_index, get_redis_connection
 from django_rq.settings import get_queues_list, get_queues_map
 from django_rq.settings import get_queues_list, get_queues_map
@@ -195,6 +197,7 @@ class DataFileListView(generic.ObjectListView):
 
 
 
 
 @register_model_view(DataFile)
 @register_model_view(DataFile)
+@method_decorator(never_cache, name='dispatch')
 class DataFileView(generic.ObjectView):
 class DataFileView(generic.ObjectView):
     queryset = DataFile.objects.all()
     queryset = DataFile.objects.all()
     actions = (DeleteObject,)
     actions = (DeleteObject,)

+ 32 - 3
netbox/dcim/choices.py

@@ -1027,6 +1027,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_100GE_CXP = '100gbase-x-cxp'
     TYPE_100GE_CXP = '100gbase-x-cxp'
     TYPE_100GE_CPAK = '100gbase-x-cpak'
     TYPE_100GE_CPAK = '100gbase-x-cpak'
     TYPE_100GE_DSFP = '100gbase-x-dsfp'
     TYPE_100GE_DSFP = '100gbase-x-dsfp'
+    TYPE_100GE_SFP112 = '100gbase-x-sfp112'
     TYPE_100GE_SFP_DD = '100gbase-x-sfpdd'
     TYPE_100GE_SFP_DD = '100gbase-x-sfpdd'
     TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
     TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
     TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd'
     TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd'
@@ -1102,7 +1103,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_64GFC_SFP_PLUS = '64gfc-sfpp'
     TYPE_64GFC_SFP_PLUS = '64gfc-sfpp'
     TYPE_128GFC_QSFP28 = '128gfc-qsfp28'
     TYPE_128GFC_QSFP28 = '128gfc-qsfp28'
 
 
-    # InfiniBand
+    # InfiniBand 1X
     TYPE_INFINIBAND_SDR = 'infiniband-sdr'
     TYPE_INFINIBAND_SDR = 'infiniband-sdr'
     TYPE_INFINIBAND_DDR = 'infiniband-ddr'
     TYPE_INFINIBAND_DDR = 'infiniband-ddr'
     TYPE_INFINIBAND_QDR = 'infiniband-qdr'
     TYPE_INFINIBAND_QDR = 'infiniband-qdr'
@@ -1113,6 +1114,17 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_INFINIBAND_NDR = 'infiniband-ndr'
     TYPE_INFINIBAND_NDR = 'infiniband-ndr'
     TYPE_INFINIBAND_XDR = 'infiniband-xdr'
     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
     # Serial
     TYPE_T1 = 't1'
     TYPE_T1 = 't1'
     TYPE_E1 = 'e1'
     TYPE_E1 = 'e1'
@@ -1152,6 +1164,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
     TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
     TYPE_SUMMITSTACK256 = 'extreme-summitstack-256'
     TYPE_SUMMITSTACK256 = 'extreme-summitstack-256'
     TYPE_SUMMITSTACK512 = 'extreme-summitstack-512'
     TYPE_SUMMITSTACK512 = 'extreme-summitstack-512'
+    TYPE_HPE_SYNERGY_INTERCONNECT = 'hpe-synergy-interconnect-link'
 
 
     # Other
     # Other
     TYPE_OTHER = 'other'
     TYPE_OTHER = 'other'
@@ -1339,6 +1352,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_100GE_DSFP, 'DSFP (100GE)'),
                 (TYPE_100GE_DSFP, 'DSFP (100GE)'),
                 (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
                 (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
                 (TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
                 (TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
+                (TYPE_100GE_SFP112, 'SFP112 (100GE)'),
                 (TYPE_100GE_SFP_DD, 'SFP-DD (100GE)'),
                 (TYPE_100GE_SFP_DD, 'SFP-DD (100GE)'),
                 (TYPE_200GE_CFP2, 'CFP2 (200GE)'),
                 (TYPE_200GE_CFP2, 'CFP2 (200GE)'),
                 (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
                 (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
@@ -1429,7 +1443,7 @@ class InterfaceTypeChoices(ChoiceSet):
             )
             )
         ),
         ),
         (
         (
-            'InfiniBand',
+            'InfiniBand 1X',
             (
             (
                 (TYPE_INFINIBAND_SDR, 'SDR (2 Gbps)'),
                 (TYPE_INFINIBAND_SDR, 'SDR (2 Gbps)'),
                 (TYPE_INFINIBAND_DDR, 'DDR (4 Gbps)'),
                 (TYPE_INFINIBAND_DDR, 'DDR (4 Gbps)'),
@@ -1439,7 +1453,21 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_INFINIBAND_EDR, 'EDR (25 Gbps)'),
                 (TYPE_INFINIBAND_EDR, 'EDR (25 Gbps)'),
                 (TYPE_INFINIBAND_HDR, 'HDR (50 Gbps)'),
                 (TYPE_INFINIBAND_HDR, 'HDR (50 Gbps)'),
                 (TYPE_INFINIBAND_NDR, 'NDR (100 Gbps)'),
                 (TYPE_INFINIBAND_NDR, 'NDR (100 Gbps)'),
-                (TYPE_INFINIBAND_XDR, 'XDR (250 Gbps)'),
+                (TYPE_INFINIBAND_XDR, 'XDR (200 Gbps)'),
+            )
+        ),
+        (
+            'InfiniBand 4X',
+            (
+                (TYPE_INFINIBAND_SDR_4X, 'SDR 4X (8 Gbps)'),
+                (TYPE_INFINIBAND_DDR_4X, 'DDR 4X (16 Gbps)'),
+                (TYPE_INFINIBAND_QDR_4X, 'QDR 4X (32 Gbps)'),
+                (TYPE_INFINIBAND_FDR10_4X, 'FDR10 4X (40 Gbps)'),
+                (TYPE_INFINIBAND_FDR_4X, 'FDR 4X (56 Gbps)'),
+                (TYPE_INFINIBAND_EDR_4X, 'EDR 4X (100 Gbps)'),
+                (TYPE_INFINIBAND_HDR_4X, 'HDR 4X (200 Gbps)'),
+                (TYPE_INFINIBAND_NDR_4X, 'NDR 4X (400 Gbps)'),
+                (TYPE_INFINIBAND_XDR_4X, 'XDR 4X (800 Gbps)'),
             )
             )
         ),
         ),
         (
         (
@@ -1495,6 +1523,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
                 (TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
                 (TYPE_SUMMITSTACK256, 'Extreme SummitStack-256'),
                 (TYPE_SUMMITSTACK256, 'Extreme SummitStack-256'),
                 (TYPE_SUMMITSTACK512, 'Extreme SummitStack-512'),
                 (TYPE_SUMMITSTACK512, 'Extreme SummitStack-512'),
+                (TYPE_HPE_SYNERGY_INTERCONNECT, 'HPE Synergy Interconnect Link'),
             )
             )
         ),
         ),
         (
         (

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

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

+ 15 - 8
netbox/dcim/forms/mixins.py

@@ -183,19 +183,26 @@ class FrontPortFormMixin(forms.Form):
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
-        # Check that the total number of FrontPorts and positions matches the selected number of RearPort:position
-        # mappings. Note that `name` will be a list under FrontPortCreateForm, in which cases we multiply the number of
-        # FrontPorts being creation by the number of positions.
-        positions = self.cleaned_data['positions']
-        frontport_count = len(self.cleaned_data['name']) if type(self.cleaned_data['name']) is list else 1
-        rearport_count = len(self.cleaned_data['rear_ports'])
-        if frontport_count * positions != rearport_count:
+        # All three are required fields, so bail out if any of them failed its own validation
+        positions = self.cleaned_data.get('positions')
+        name = self.cleaned_data.get('name')
+        rear_ports = self.cleaned_data.get('rear_ports')
+        if not (positions and name and rear_ports):
+            return
+
+        # `name` is a list under FrontPortCreateForm, and each generated FrontPort consumes `positions` mappings
+        frontport_count = len(name) if isinstance(name, list) else 1
+        frontport_position_count = frontport_count * positions
+        rearport_count = len(rear_ports)
+
+        # {frontport_count} receives the position total. Its name is unchanged to keep existing translations valid.
+        if frontport_position_count != rearport_count:
             raise forms.ValidationError({
             raise forms.ValidationError({
                 'rear_ports': _(
                 'rear_ports': _(
                     "The total number of front port positions ({frontport_count}) must match the selected number of "
                     "The total number of front port positions ({frontport_count}) must match the selected number of "
                     "rear port positions ({rearport_count})."
                     "rear port positions ({rearport_count})."
                 ).format(
                 ).format(
-                    frontport_count=frontport_count,
+                    frontport_count=frontport_position_count,
                     rearport_count=rearport_count
                     rearport_count=rearport_count
                 )
                 )
             })
             })

+ 2 - 2
netbox/dcim/forms/model_forms.py

@@ -1517,7 +1517,7 @@ class PowerOutletForm(ModularDeviceComponentForm):
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected',
             'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected',
-            'description', 'owner', 'tags',
+            'description', 'tags',
         ),
         ),
     )
     )
 
 
@@ -1525,7 +1525,7 @@ class PowerOutletForm(ModularDeviceComponentForm):
         model = PowerOutlet
         model = PowerOutlet
         fields = [
         fields = [
             'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected',
             'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected',
-            'description', 'tags',
+            'description', 'owner', 'tags',
         ]
         ]
 
 
 
 

+ 14 - 12
netbox/dcim/forms/object_create.py

@@ -62,18 +62,20 @@ class ComponentCreateForm(forms.Form):
             return
             return
         pattern_count = len(patterns)
         pattern_count = len(patterns)
         for field_name in self.replication_fields:
         for field_name in self.replication_fields:
-            value_count = len(self.cleaned_data[field_name])
-            if self.cleaned_data[field_name]:
-                if value_count == 1:
-                    # If the field resolves to a single value (because no pattern was used), multiply it by the number
-                    # of expected values. This allows us to reuse the same label when creating multiple components.
-                    self.cleaned_data[field_name] = self.cleaned_data[field_name] * pattern_count
-                elif value_count != pattern_count:
-                    raise forms.ValidationError({
-                        field_name: _(
-                            "The provided pattern specifies {value_count} values, but {pattern_count} are expected."
-                        ).format(value_count=value_count, pattern_count=pattern_count)
-                    }, code='label_pattern_mismatch')
+            # A field is absent from cleaned_data if it failed its own validation, e.g. an inverted numeric range
+            if not (values := self.cleaned_data.get(field_name)):
+                continue
+            value_count = len(values)
+            if value_count == 1:
+                # If the field resolves to a single value (because no pattern was used), multiply it by the number
+                # of expected values. This allows us to reuse the same label when creating multiple components.
+                self.cleaned_data[field_name] = values * pattern_count
+            elif value_count != pattern_count:
+                raise forms.ValidationError({
+                    field_name: _(
+                        "The provided pattern specifies {value_count} values, but {pattern_count} are expected."
+                    ).format(value_count=value_count, pattern_count=pattern_count)
+                }, code='label_pattern_mismatch')
 
 
 
 
 #
 #

+ 133 - 25
netbox/dcim/graphql/types.py

@@ -2,13 +2,15 @@ from typing import TYPE_CHECKING, Annotated
 
 
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
-from django.db.models import Func, IntegerField
+from django.db.models import Func, IntegerField, Prefetch
 
 
 from circuits.models import CircuitTermination
 from circuits.models import CircuitTermination
 from core.graphql.mixins import ChangelogMixin
 from core.graphql.mixins import ChangelogMixin
 from dcim import models
 from dcim import models
+from dcim.choices import CableEndChoices
 from extras.graphql.mixins import ConfigContextMixin, ContactsMixin, ImageAttachmentsMixin
 from extras.graphql.mixins import ConfigContextMixin, ContactsMixin, ImageAttachmentsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
+from netbox.graphql.optimization import build_gfk_prefetch
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import (
 from netbox.graphql.types import (
     BaseObjectType,
     BaseObjectType,
@@ -19,7 +21,7 @@ from netbox.graphql.types import (
 )
 )
 from users.graphql.mixins import OwnerMixin
 from users.graphql.mixins import OwnerMixin
 from utilities.querysets import RestrictedPrefetch
 from utilities.querysets import RestrictedPrefetch
-from virtualization.models import Cluster
+from virtualization.models import Cluster, VMInterface
 
 
 from .filters import *
 from .filters import *
 from .mixins import CabledObjectMixin, PathEndpointMixin
 from .mixins import CabledObjectMixin, PathEndpointMixin
@@ -93,6 +95,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
 # Base types
 #
 #
@@ -150,7 +203,12 @@ class CableBundleType(PrimaryObjectType):
 )
 )
 class CableTerminationType(NetBoxObjectType):
 class CableTerminationType(NetBoxObjectType):
     cable: Annotated['CableType', strawberry.lazy('dcim.graphql.types')] | None
     cable: Annotated['CableType', strawberry.lazy('dcim.graphql.types')] | None
-    termination: Annotated[
+
+    @strawberry_django.field(
+        prefetch_related=_termination_gfk_prefetch,
+        only=['termination_type', 'termination_id'],
+    )
+    def termination(self) -> Annotated[
         Annotated['CircuitTerminationType', strawberry.lazy('circuits.graphql.types')]
         Annotated['CircuitTerminationType', strawberry.lazy('circuits.graphql.types')]
         | Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
@@ -161,7 +219,8 @@ class CableTerminationType(NetBoxObjectType):
         | Annotated['PowerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['PowerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         strawberry.union('CableTerminationTerminationType'),
         strawberry.union('CableTerminationTerminationType'),
-    ] | None
+    ] | None:
+        return self.termination
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(
@@ -177,7 +236,10 @@ class CableType(PrimaryObjectType):
 
 
     terminations: list[CableTerminationType]
     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['CircuitTerminationType', strawberry.lazy('circuits.graphql.types')]
         | Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
@@ -188,9 +250,13 @@ class CableType(PrimaryObjectType):
         | Annotated['PowerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['PowerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         strawberry.union('CableTerminationTerminationType'),
         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['CircuitTerminationType', strawberry.lazy('circuits.graphql.types')]
         | Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
@@ -201,7 +267,8 @@ class CableType(PrimaryObjectType):
         | Annotated['PowerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['PowerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         strawberry.union('CableTerminationTerminationType'),
         strawberry.union('CableTerminationTerminationType'),
-    ]]
+    ]]:
+        return _resolve_cable_terminations(self, CableEndChoices.SIDE_B, '_prefetched_b_terminations')
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(
@@ -330,22 +397,38 @@ class InventoryItemTemplateType(ComponentTemplateType):
     role: Annotated['InventoryItemRoleType', strawberry.lazy('dcim.graphql.types')] | None
     role: Annotated['InventoryItemRoleType', strawberry.lazy('dcim.graphql.types')] | None
     manufacturer: Annotated['ManufacturerType', strawberry.lazy('dcim.graphql.types')]
     manufacturer: Annotated['ManufacturerType', strawberry.lazy('dcim.graphql.types')]
 
 
-    @strawberry_django.field(prefetch_related='parent')
+    @strawberry_django.field(prefetch_related='parent', only=['parent_id'])
     def parent(self) -> Annotated['InventoryItemTemplateType', strawberry.lazy('dcim.graphql.types')] | None:
     def parent(self) -> Annotated['InventoryItemTemplateType', strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 
     child_items: list[Annotated['InventoryItemTemplateType', strawberry.lazy('dcim.graphql.types')]]
     child_items: list[Annotated['InventoryItemTemplateType', strawberry.lazy('dcim.graphql.types')]]
 
 
-    component: Annotated[
-        Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
-        | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
-        | Annotated['FrontPortType', strawberry.lazy('dcim.graphql.types')]
-        | Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
-        | Annotated['PowerOutletType', strawberry.lazy('dcim.graphql.types')]
-        | Annotated['PowerPortType', strawberry.lazy('dcim.graphql.types')]
-        | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'component',
+            [
+                models.ConsolePortTemplate,
+                models.ConsoleServerPortTemplate,
+                models.FrontPortTemplate,
+                models.InterfaceTemplate,
+                models.PowerOutletTemplate,
+                models.PowerPortTemplate,
+                models.RearPortTemplate,
+            ],
+        ),
+        only=['component_type', 'component_id'],
+    )
+    def component(self) -> Annotated[
+        Annotated['ConsolePortTemplateType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['ConsoleServerPortTemplateType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['FrontPortTemplateType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['InterfaceTemplateType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['PowerOutletTemplateType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['PowerPortTemplateType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['RearPortTemplateType', strawberry.lazy('dcim.graphql.types')],
         strawberry.union('InventoryItemTemplateComponentType'),
         strawberry.union('InventoryItemTemplateComponentType'),
-    ] | None
+    ] | None:
+        return self.component
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(
@@ -433,7 +516,16 @@ class FrontPortTemplateType(ModularComponentTemplateType):
 class MACAddressType(PrimaryObjectType):
 class MACAddressType(PrimaryObjectType):
     mac_address: str
     mac_address: str
 
 
-    @strawberry_django.field(prefetch_related='assigned_object')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'assigned_object',
+            [
+                models.Interface,
+                VMInterface,
+            ],
+        ),
+        only=['assigned_object_type', 'assigned_object_id'],
+    )
     def assigned_object(self) -> Annotated[
     def assigned_object(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.graphql.types')],
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.graphql.types')],
@@ -497,11 +589,26 @@ class InventoryItemType(ComponentType):
 
 
     child_items: list[Annotated['InventoryItemType', strawberry.lazy('dcim.graphql.types')]]
     child_items: list[Annotated['InventoryItemType', strawberry.lazy('dcim.graphql.types')]]
 
 
-    @strawberry_django.field(prefetch_related='parent')
+    @strawberry_django.field(prefetch_related='parent', only=['parent_id'])
     def parent(self) -> Annotated['InventoryItemType', strawberry.lazy('dcim.graphql.types')] | None:
     def parent(self) -> Annotated['InventoryItemType', strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 
-    component: Annotated[
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'component',
+            [
+                models.ConsolePort,
+                models.ConsoleServerPort,
+                models.FrontPort,
+                models.Interface,
+                models.PowerOutlet,
+                models.PowerPort,
+                models.RearPort,
+            ],
+        ),
+        only=['component_type', 'component_id'],
+    )
+    def component(self) -> Annotated[
         Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['FrontPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['FrontPortType', strawberry.lazy('dcim.graphql.types')]
@@ -510,7 +617,8 @@ class InventoryItemType(ComponentType):
         | Annotated['PowerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['PowerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         strawberry.union('InventoryItemComponentType'),
         strawberry.union('InventoryItemComponentType'),
-    ] | None
+    ] | None:
+        return self.component
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(
@@ -611,7 +719,7 @@ class ModuleBayType(ModularComponentType):
     installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None
     installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None
     children: list[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]]
 
 
-    @strawberry_django.field(prefetch_related='parent')
+    @strawberry_django.field(prefetch_related='parent', only=['parent_id'])
     def parent(self) -> Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')] | None:
     def parent(self) -> Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 
@@ -888,7 +996,7 @@ class RegionType(VLANGroupsMixin, ContactsMixin, NestedGroupObjectType):
     sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
     sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["RegionType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["RegionType", strawberry.lazy('dcim.graphql.types')]]
 
 
-    @strawberry_django.field(prefetch_related='parent')
+    @strawberry_django.field(prefetch_related='parent', only=['parent_id'])
     def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None:
     def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 
@@ -965,7 +1073,7 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, NestedGroupObjectType):
     sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
     sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')]]
 
 
-    @strawberry_django.field(prefetch_related='parent')
+    @strawberry_django.field(prefetch_related='parent', only=['parent_id'])
     def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None:
     def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 

+ 10 - 6
netbox/dcim/models/cables.py

@@ -780,18 +780,22 @@ class CablePath(models.Model):
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
-        # Record a direct reference to this CablePath on its originating object(s)
+        # Record a direct reference to this CablePath on its originating object(s). Only PathEndpoint
+        # subclasses carry the denormalized `_path` back-reference; other valid origins (e.g.
+        # CircuitTermination) do not, so skip the update for them.
         origin_model = self.origin_type.model_class()
         origin_model = self.origin_type.model_class()
-        origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
-        origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
+        if issubclass(origin_model, PathEndpoint):
+            origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
+            origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
 
 
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
         # Mirror save() - clear _path on origins to prevent stale references
         # Mirror save() - clear _path on origins to prevent stale references
-        # in table views that render _path.destinations
+        # in table views that render _path.destinations. Only PathEndpoint subclasses carry `_path`.
         if self.path:
         if self.path:
             origin_model = self.origin_type.model_class()
             origin_model = self.origin_type.model_class()
-            origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
-            origin_model.objects.filter(pk__in=origin_ids, _path=self.pk).update(_path=None)
+            if issubclass(origin_model, PathEndpoint):
+                origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
+                origin_model.objects.filter(pk__in=origin_ids, _path=self.pk).update(_path=None)
 
 
         super().delete(*args, **kwargs)
         super().delete(*args, **kwargs)
 
 

+ 3 - 2
netbox/dcim/models/device_components.py

@@ -4,7 +4,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
-from django.db import models
+from django.db import models, router
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
 
 
@@ -1432,7 +1432,8 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
         # root insert (NB-2800). Children still go through MPTT, which keeps
         # root insert (NB-2800). Children still go through MPTT, which keeps
         # siblings in name order via the same order_insertion_by setting.
         # siblings in name order via the same order_insertion_by setting.
         if self._state.adding and self.parent_id is None and not self.lft and not self.rght:
         if self._state.adding and self.parent_id is None and not self.lft and not self.rght:
-            max_tree_id = ModuleBay._objects_raw.aggregate(
+            using = kwargs.get('using') or router.db_for_write(ModuleBay, instance=self)
+            max_tree_id = ModuleBay._objects_raw.using(using).aggregate(
                 models.Max('tree_id')
                 models.Max('tree_id')
             )['tree_id__max'] or 0
             )['tree_id__max'] or 0
             self.tree_id = max_tree_id + 1
             self.tree_id = max_tree_id + 1

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

@@ -987,6 +987,7 @@ class Device(
                          (default). Otherwise, save() will be called on each instance individually.
                          (default). Otherwise, save() will be called on each instance individually.
         """
         """
         model = queryset.model.component_model
         model = queryset.model.component_model
+        using = self._state.db
 
 
         if bulk_create:
         if bulk_create:
             components = [obj.instantiate(device=self) for obj in queryset]
             components = [obj.instantiate(device=self) for obj in queryset]
@@ -1005,7 +1006,7 @@ class Device(
                 component._site = self.site
                 component._site = self.site
                 component._location = self.location
                 component._location = self.location
                 component._rack = self.rack
                 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 related objects to minimize queries needed during post_save
             prefetch_fields = get_prefetchable_fields(model)
             prefetch_fields = get_prefetchable_fields(model)
             prefetch_related_objects(components, *prefetch_fields)
             prefetch_related_objects(components, *prefetch_fields)
@@ -1016,7 +1017,7 @@ class Device(
                     instance=component,
                     instance=component,
                     created=True,
                     created=True,
                     raw=False,
                     raw=False,
-                    using='default',
+                    using=using,
                     update_fields=None
                     update_fields=None
                 )
                 )
         else:
         else:
@@ -1031,7 +1032,7 @@ class Device(
                 # Set default values for any applicable custom fields
                 # Set default values for any applicable custom fields
                 if cf_defaults := CustomField.objects.get_defaults_for_model(model):
                 if cf_defaults := CustomField.objects.get_defaults_for_model(model):
                     component.custom_field_data = cf_defaults
                     component.custom_field_data = cf_defaults
-                component.save()
+                component.save(using=using)
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         is_new = not bool(self.pk)
         is_new = not bool(self.pk)

+ 15 - 10
netbox/dcim/models/modules.py

@@ -334,16 +334,18 @@ class Module(TrackingModelMixin, PrimaryModel):
         old_module_bay_id = None
         old_module_bay_id = None
 
 
         if not is_new:
         if not is_new:
-            old_module_bay_id = Module.objects.filter(pk=self.pk).values_list(
+            old_module_bay_id = Module.objects.using(self._state.db).filter(pk=self.pk).values_list(
                 'module_bay_id', flat=True
                 'module_bay_id', flat=True
             ).first()
             ).first()
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
+        using = self._state.db
+
         if old_module_bay_id is not None and old_module_bay_id != self.module_bay_id:
         if old_module_bay_id is not None and old_module_bay_id != self.module_bay_id:
-            for child_bay in self.modulebays.select_related('module__module_bay'):
+            for child_bay in self.modulebays.db_manager(using).select_related('module__module_bay'):
                 child_bay.snapshot()
                 child_bay.snapshot()
-                child_bay.save()
+                child_bay.save(using=using)
 
 
         adopt_components = getattr(self, '_adopt_components', False)
         adopt_components = getattr(self, '_adopt_components', False)
         disable_replication = getattr(self, '_disable_replication', False)
         disable_replication = getattr(self, '_disable_replication', False)
@@ -370,7 +372,9 @@ class Module(TrackingModelMixin, PrimaryModel):
             # Prefetch installed components
             # Prefetch installed components
             installed_components = {
             installed_components = {
                 component.name: component
                 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.
             # Get the template for the module type.
@@ -404,7 +408,7 @@ class Module(TrackingModelMixin, PrimaryModel):
 
 
             # we handle create and update separately - this is for create
             # we handle create and update separately - this is for create
             if not issubclass(component_model, MPTTModel):
             if not issubclass(component_model, MPTTModel):
-                component_model.objects.bulk_create(create_instances)
+                component_model.objects.using(using).bulk_create(create_instances)
                 # Emit the post_save signal for each newly created object
                 # Emit the post_save signal for each newly created object
                 for component in create_instances:
                 for component in create_instances:
                     post_save.send(
                     post_save.send(
@@ -412,18 +416,18 @@ class Module(TrackingModelMixin, PrimaryModel):
                         instance=component,
                         instance=component,
                         created=True,
                         created=True,
                         raw=False,
                         raw=False,
-                        using='default',
+                        using=using,
                         update_fields=None
                         update_fields=None
                     )
                     )
             else:
             else:
                 # MPTT models must be saved individually to maintain tree structure
                 # MPTT models must be saved individually to maintain tree structure
                 for instance in create_instances:
                 for instance in create_instances:
-                    instance.save()
+                    instance.save(using=using)
 
 
             update_fields = ['module']
             update_fields = ['module']
 
 
             # we handle create and update separately - this is for update
             # we handle create and update separately - this is for update
-            component_model.objects.bulk_update(update_instances, update_fields)
+            component_model.objects.using(using).bulk_update(update_instances, update_fields)
             # Emit the post_save signal for each updated object
             # Emit the post_save signal for each updated object
             for component in update_instances:
             for component in update_instances:
                 post_save.send(
                 post_save.send(
@@ -431,13 +435,14 @@ class Module(TrackingModelMixin, PrimaryModel):
                     instance=component,
                     instance=component,
                     created=False,
                     created=False,
                     raw=False,
                     raw=False,
-                    using='default',
+                    using=using,
                     update_fields=update_fields
                     update_fields=update_fields
                 )
                 )
 
 
             # Rebuild MPTT tree if needed (bulk_update bypasses model save)
             # Rebuild MPTT tree if needed (bulk_update bypasses model save)
             if issubclass(component_model, MPTTModel) and update_instances:
             if issubclass(component_model, MPTTModel) and update_instances:
-                component_model.objects.rebuild()
+                # db_manager() is used in place of using(), as rebuild() is a manager method
+                component_model.objects.db_manager(using).rebuild()
 
 
         # Replicate any front/rear port mappings from the ModuleType
         # Replicate any front/rear port mappings from the ModuleType
         create_port_mappings(self.device, self.module_type, self)
         create_port_mappings(self.device, self.module_type, self)

+ 252 - 89
netbox/dcim/signals.py

@@ -6,6 +6,7 @@ from django.db.models import F, Q
 from django.db.models.signals import post_delete, post_save, pre_save
 from django.db.models.signals import post_delete, post_save, pre_save
 from django.dispatch import receiver
 from django.dispatch import receiver
 
 
+from circuits.models import CircuitTermination
 from dcim.choices import CableEndChoices, LinkStatusChoices
 from dcim.choices import CableEndChoices, LinkStatusChoices
 from ipam.models import Prefix
 from ipam.models import Prefix
 from netbox.search.backends import search_backend
 from netbox.search.backends import search_backend
@@ -52,13 +53,21 @@ COMPONENT_MODELS = (
     RearPort,
     RearPort,
 )
 )
 
 
+# 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.
+STASHED_SCOPE_FIELDS = {
+    Site: ('region_id', 'group_id'),
+    Location: ('site_id',),
+    Rack: ('site_id', 'location_id'),
+    Device: ('site_id', 'location_id', 'rack_id'),
+}
+
 
 
 #
 #
 # Location/rack/device assignment
 # Location/rack/device assignment
 #
 #
 
 
-@receiver(pre_save, sender=Location)
-@receiver(pre_save, sender=Site)
 def cache_presave_scope_fields(instance, raw=False, using=None, **kwargs):
 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
     Stash the scope-relevant field values currently in the database so that the post_save
@@ -66,107 +75,239 @@ def cache_presave_scope_fields(instance, raw=False, using=None, **kwargs):
     locks the row, so overlapping saves of the same object serialize here and the
     locks the row, so overlapping saves of the same object serialize here and the
     comparison always runs against the final committed state.
     comparison always runs against the final committed state.
 
 
-    Outside of a transaction no stash is taken (and any stash left by a previous
-    transactional save of the same instance is cleared): in autocommit, this read and the
-    subsequent UPDATE would run in separate transactions, so the comparison could race a
-    concurrent save. The post_save handlers treat a missing stash as "the values may have
-    changed" and rebuild or repair unconditionally.
+    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:
-        return
-    if not transaction.get_connection(using).in_atomic_block:
+    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
         instance._presave_scope_fields = None
         return
         return
-    fields = ('region_id', 'group_id') if isinstance(instance, Site) else ('site_id',)
+    fields = STASHED_SCOPE_FIELDS[instance.__class__]
     instance._presave_scope_fields = (
     instance._presave_scope_fields = (
         instance.__class__.objects.using(using)
         instance.__class__.objects.using(using)
         .filter(pk=instance.pk)
         .filter(pk=instance.pk)
-        # no_key: serializes overlapping saves of this object without blocking foreign
-        # key inserts that reference it
-        .select_for_update(no_key=True)
+        .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)
         .values(*fields)
         .first()
         .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)
 @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):
     """
     """
     Update child objects when a Location is saved. All updates are queryset update() calls,
     Update child objects when a Location is saved. All updates are queryset update() calls,
     which fire no signals and generate no change records for the affected objects.
     which fire no signals and generate no change records for the affected objects.
+
+    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 created:
+    if created or raw:
+        return
+
+    # Skip the propagation when this save left the Site assignment untouched.
+    if _scope_fields_unchanged(instance, update_fields):
         return
         return
-    with transaction.atomic(savepoint=False):
-        instance.get_descendants().update(site=instance.site)
+
+    with transaction.atomic(using=using, savepoint=False):
+        scope = _scope_values(instance, update_fields, using)
+        if scope is None:
+            return
+        site_id = scope['site_id']
+        instance.get_descendants().using(using).update(site_id=site_id)
         # Materialized once so every statement below sees the same membership, even if a
         # Materialized once so every statement below sees the same membership, even if a
         # concurrent commit renumbers the tree mid-handler.
         # concurrent commit renumbers the tree mid-handler.
-        locations = list(instance.get_descendants(include_self=True).values_list('pk', flat=True))
-        Rack.objects.filter(location__in=locations).update(site=instance.site)
-        Device.objects.filter(location__in=locations).update(site=instance.site)
-        PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
-        CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
+        locations = list(instance.get_descendants(include_self=True).using(using).values_list('pk', flat=True))
+        Rack.objects.using(using).filter(location__in=locations).update(site_id=site_id)
+        Device.objects.using(using).filter(location__in=locations).update(site_id=site_id)
+        PowerPanel.objects.using(using).filter(location__in=locations).update(site_id=site_id)
+        CableTermination.objects.using(using).filter(_location__in=locations).update(_site_id=site_id)
         # Update component models for devices in these locations
         # Update component models for devices in these locations
         for model in COMPONENT_MODELS:
         for model in COMPONENT_MODELS:
-            model.objects.filter(device__location__in=locations).update(_site=instance.site)
+            model.objects.using(using).filter(device__location__in=locations).update(_site_id=site_id)
 
 
         # Objects scoped to descendant Locations receive no post_save of their own from the
         # Objects scoped to descendant Locations receive no post_save of their own from the
-        # queryset updates above, so their cached scope fields are updated here whenever the
-        # Site assignment has actually changed. (Objects scoped to this Location itself are
-        # recomputed by sync_cached_scope_fields on this same save.) Values are read fresh
-        # from the database rather than taken from the saved instance, whose cached site
-        # relation may be stale.
-        prev = getattr(instance, '_presave_scope_fields', None)
-        if prev is None or prev['site_id'] != instance.site_id:
-            # Lock the destination Site (without blocking FK inserts that reference it) so
-            # a concurrent scope change on that Site serializes against this move; an
-            # unlocked read could stamp region/group values from before that change.
-            site = (
-                Site.objects.filter(pk=instance.site_id)
-                .select_for_update(no_key=True)
-                .values('region_id', 'group_id')
-                .first()
+        # queryset updates above, so their cached scope fields are updated here.
+        site = (
+            Site.objects.using(using)
+            .filter(pk=site_id)
+            .select_for_update(no_key=True)  # Lock the destination Site (without blocking FK inserts that reference it)
+            .values('region_id', 'group_id')
+            .first()
+        )
+        if site is not None:
+            location_ct = ContentType.objects.db_manager(using).get_for_model(Location)
+            for model in (Prefix, Cluster, WirelessLAN):
+                model.objects.using(using).filter(scope_type=location_ct, scope_id__in=locations).update(
+                    _location_id=F('scope_id'),
+                    _site_id=site_id,
+                    _region_id=site['region_id'],
+                    _site_group_id=site['group_id'],
+                )
+
+            # CircuitTermination caches the same ancestry under its own generic
+            # termination field rather than CachedScopeMixin.scope, so it is invisible to
+            # both the loop above and sync_cached_scope_fields().
+            CircuitTermination.objects.using(using).filter(
+                termination_type=location_ct, termination_id__in=locations
+            ).update(
+                _location_id=F('termination_id'),
+                _site_id=site_id,
+                _region_id=site['region_id'],
+                _site_group_id=site['group_id'],
             )
             )
-            if site is not None:
-                # Select rows through the authoritative scope rather than the cached
-                # _location, which may itself be stale; scope_id doubles as the correct
-                # _location value for Location-scoped rows.
-                location_ct = ContentType.objects.get_for_model(Location)
-                for model in (Prefix, Cluster, WirelessLAN):
-                    model.objects.filter(scope_type=location_ct, scope_id__in=locations).update(
-                        _location_id=F('scope_id'),
-                        _site_id=instance.site_id,
-                        _region_id=site['region_id'],
-                        _site_group_id=site['group_id'],
-                    )
 
 
 
 
 @receiver(post_save, sender=Rack)
 @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):
     """
     """
-    Update child Devices if Site or Location assignment has changed.
+    Update child Devices if Site or Location assignment has changed. 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:
-        Device.objects.filter(rack=instance).update(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
+        Device.objects.using(using).filter(rack=instance).update(
+            site_id=scope['site_id'],
+            location_id=scope['location_id'],
+        )
         # Update component models for devices in this rack
         # Update component models for devices in this rack
         for model in COMPONENT_MODELS:
         for model in COMPONENT_MODELS:
-            model.objects.filter(device__rack=instance).update(
-                _site=instance.site,
-                _location=instance.location,
+            model.objects.using(using).filter(device__rack=instance).update(
+                _site_id=scope['site_id'],
+                _location_id=scope['location_id'],
             )
             )
 
 
 
 
 @receiver(post_save, sender=Device)
 @receiver(post_save, sender=Device)
-def handle_device_site_change(instance, created, **kwargs):
+def handle_device_site_change(instance, created, raw=False, using=None, update_fields=None, **kwargs):
     """
     """
     Update child components to update the parent Site, Location, and Rack when a Device is saved.
     Update child components to update the parent Site, Location, and Rack when a Device is saved.
+    Queries are pinned to the connection the Device 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 none of the three assignments propagates nothing and is skipped,
+    as does a raw save.
     """
     """
-    if not created:
+    if created or raw:
+        return
+
+    # Skip the propagation when this save left the Site, Location, and Rack 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
         for model in COMPONENT_MODELS:
         for model in COMPONENT_MODELS:
-            model.objects.filter(device=instance).update(
-                _site=instance.site,
-                _location=instance.location,
-                _rack=instance.rack,
+            model.objects.using(using).filter(device=instance).update(
+                _site_id=scope['site_id'],
+                _location_id=scope['location_id'],
+                _rack_id=scope['rack_id'],
             )
             )
 
 
 
 
@@ -305,20 +446,43 @@ def update_mac_address_interface(instance, created, raw, **kwargs):
         instance.primary_mac_address.save()
         instance.primary_mac_address.save()
 
 
 
 
+def _get_scope_object(scope_type_id, scope_id, using):
+    """
+    Return the object referenced by a CachedScopeMixin generic scope, read on the given
+    database connection. The ancestors which cache_related_objects() traverses are selected
+    in the same query, so recomputing the cached fields from the returned object issues no
+    further reads. Returns None if the scope is unset or dangling.
+    """
+    if scope_type_id is None or scope_id is None:
+        return None
+    scope_type = ContentType.objects.db_manager(using).get_for_id(scope_type_id)
+    scope_model = scope_type.model_class()
+    if scope_model is None:
+        return None
+    queryset = scope_model._base_manager.using(using)
+    if scope_model is Location:
+        queryset = queryset.select_related('site__region', 'site__group')
+    elif scope_model is Site:
+        queryset = queryset.select_related('region', 'group')
+    return queryset.filter(pk=scope_id).first()
+
+
 @receiver(post_save, sender=Location)
 @receiver(post_save, sender=Location)
 @receiver(post_save, sender=Site)
 @receiver(post_save, sender=Site)
-def sync_cached_scope_fields(instance, created, **kwargs):
+def sync_cached_scope_fields(instance, created, raw=False, using=None, update_fields=None, **kwargs):
     """
     """
     Rebuild cached scope fields for all CachedScopeMixin-based models
     Rebuild cached scope fields for all CachedScopeMixin-based models
     affected by a change to a Site or Location.
     affected by a change to a Site or Location.
 
 
     When the values read from the database immediately before this save
     When the values read from the database immediately before this save
     show that no scope-relevant field has changed, the rebuild is
     show that no scope-relevant field has changed, the rebuild is
-    skipped. Otherwise, cached fields are recomputed from each object's
-    authoritative scope relationships — never copied from the saved
-    instance — so rows holding stale cached values are also repaired.
+    skipped, as is a raw save. Otherwise, cached fields are recomputed
+    from each object's authoritative scope relationships — never copied
+    from the saved instance — so rows holding stale cached values are
+    also repaired. A partial save is judged on the fields it actually
+    wrote.
     """
     """
-    if created:
+    if created or raw:
         return
         return
 
 
     if isinstance(instance, Location):
     if isinstance(instance, Location):
@@ -328,25 +492,16 @@ def sync_cached_scope_fields(instance, created, **kwargs):
     else:
     else:
         return
         return
 
 
-    # Skip the rebuild when this save changed no scope-relevant field. The pre-save values
-    # are read from the database by cache_presave_scope_fields() immediately before the
-    # write, with the row locked, so the comparison holds even when overlapping saves race
-    # on the same object. The stash exists only for saves made inside a transaction; when
-    # it's absent (autocommit saves), rebuild unconditionally.
-    prev = getattr(instance, '_presave_scope_fields', None)
-    if prev is not None:
-        if isinstance(instance, Site):
-            if prev['region_id'] == instance.region_id and prev['group_id'] == instance.group_id:
-                return
-        # The dispatch above ensures the instance can only be a Location here
-        elif prev['site_id'] == instance.site_id:
-            return
+    # Skip the rebuild when this save changed no scope-relevant field. The rebuild reads
+    # each row's own scope rather than the instance, so it needs no _scope_values() here.
+    if _scope_fields_unchanged(instance, update_fields):
+        return
 
 
     # These models are explicitly listed because they all subclass CachedScopeMixin
     # These models are explicitly listed because they all subclass CachedScopeMixin
     # and therefore require their cached scope fields to be recomputed.
     # and therefore require their cached scope fields to be recomputed.
-    with transaction.atomic(savepoint=False):
+    with transaction.atomic(using=using, savepoint=False):
         for model in (Prefix, Cluster, WirelessLAN):
         for model in (Prefix, Cluster, WirelessLAN):
-            qs = model.objects.filter(**filters)
+            qs = model.objects.using(using).filter(**filters)
 
 
             # Recompute the cached fields once per distinct scope, then apply each result with a
             # Recompute the cached fields once per distinct scope, then apply each result with a
             # single UPDATE. This avoids loading every object into memory as well as the per-row
             # single UPDATE. This avoids loading every object into memory as well as the per-row
@@ -358,11 +513,19 @@ def sync_cached_scope_fields(instance, created, **kwargs):
             # all-or-nothing outside a request transaction.
             # all-or-nothing outside a request transaction.
             scopes = qs.values_list('scope_type_id', 'scope_id').order_by('scope_type_id', 'scope_id').distinct()
             scopes = qs.values_list('scope_type_id', 'scope_id').order_by('scope_type_id', 'scope_id').distinct()
             for scope_type_id, scope_id in scopes:
             for scope_type_id, scope_id in scopes:
-                ref = model(scope_type_id=scope_type_id, scope_id=scope_id)
+                # Resolve the scope (and the ancestors cache_related_objects() traverses) on
+                # the saving connection, then hand it to a throwaway reference object with
+                # its relations already populated, so that recomputing the cached fields
+                # reads nothing further. Assigning ref._state.db alone would not suffice:
+                # Django consults DATABASE_ROUTERS first for related-object lookups and only
+                # falls back to the instance's recorded database when every router declines.
+                ref = model()
+                ref._state.db = using
+                ref.scope = _get_scope_object(scope_type_id, scope_id, using)
                 ref.cache_related_objects()
                 ref.cache_related_objects()
                 qs.filter(scope_type_id=scope_type_id, scope_id=scope_id).update(
                 qs.filter(scope_type_id=scope_type_id, scope_id=scope_id).update(
-                    _location=ref._location,
-                    _site=ref._site,
-                    _site_group=ref._site_group,
-                    _region=ref._region,
+                    _location_id=ref._location_id,
+                    _site_id=ref._site_id,
+                    _site_group_id=ref._site_group_id,
+                    _region_id=ref._region_id,
                 )
                 )

+ 69 - 69
netbox/dcim/tests/query_counts.json

@@ -1,86 +1,86 @@
 {
 {
-  "cable:api_list_objects": 24,
-  "cable:list_objects_with_permission": 24,
-  "cablebundle:api_list_objects": 13,
-  "cablebundle:list_objects_with_permission": 20,
+  "cable:api_list_objects": 23,
+  "cable:list_objects_with_permission": 21,
+  "cablebundle:api_list_objects": 12,
+  "cablebundle:list_objects_with_permission": 17,
   "cabletermination:api_list_objects": 16,
   "cabletermination:api_list_objects": 16,
   "consoleconnection:list_objects_with_permission": 29,
   "consoleconnection:list_objects_with_permission": 29,
-  "consoleport:api_list_objects": 14,
-  "consoleport:list_objects_with_permission": 21,
+  "consoleport:api_list_objects": 13,
+  "consoleport:list_objects_with_permission": 18,
   "consoleporttemplate:api_list_objects": 11,
   "consoleporttemplate:api_list_objects": 11,
-  "consoleserverport:api_list_objects": 14,
-  "consoleserverport:list_objects_with_permission": 21,
+  "consoleserverport:api_list_objects": 13,
+  "consoleserverport:list_objects_with_permission": 18,
   "consoleserverporttemplate:api_list_objects": 11,
   "consoleserverporttemplate:api_list_objects": 11,
-  "device:api_list_objects": 20,
-  "device:list_objects_with_permission": 25,
-  "devicebay:api_list_objects": 14,
-  "devicebay:list_objects_with_permission": 21,
+  "device:api_list_objects": 19,
+  "device:list_objects_with_permission": 22,
+  "devicebay:api_list_objects": 13,
+  "devicebay:list_objects_with_permission": 18,
   "devicebaytemplate:api_list_objects": 11,
   "devicebaytemplate:api_list_objects": 11,
-  "devicerole:api_list_objects": 13,
-  "devicerole:list_objects_with_permission": 20,
-  "devicetype:api_list_objects": 14,
-  "devicetype:list_objects_with_permission": 21,
-  "frontport:api_list_objects": 15,
-  "frontport:list_objects_with_permission": 25,
+  "devicerole:api_list_objects": 12,
+  "devicerole:list_objects_with_permission": 17,
+  "devicetype:api_list_objects": 13,
+  "devicetype:list_objects_with_permission": 18,
+  "frontport:api_list_objects": 14,
+  "frontport:list_objects_with_permission": 22,
   "frontporttemplate:api_list_objects": 12,
   "frontporttemplate:api_list_objects": 12,
   "interface:api_list_objects": 23,
   "interface:api_list_objects": 23,
-  "interface:list_objects_with_permission": 21,
+  "interface:list_objects_with_permission": 18,
   "interfaceconnection:list_objects_with_permission": 41,
   "interfaceconnection:list_objects_with_permission": 41,
   "interfacetemplate:api_list_objects": 11,
   "interfacetemplate:api_list_objects": 11,
-  "inventoryitem:api_list_objects": 20,
-  "inventoryitem:list_objects_with_permission": 23,
-  "inventoryitemrole:api_list_objects": 13,
-  "inventoryitemrole:list_objects_with_permission": 20,
+  "inventoryitem:api_list_objects": 19,
+  "inventoryitem:list_objects_with_permission": 20,
+  "inventoryitemrole:api_list_objects": 12,
+  "inventoryitemrole:list_objects_with_permission": 17,
   "inventoryitemtemplate:api_list_objects": 13,
   "inventoryitemtemplate:api_list_objects": 13,
-  "location:api_list_objects": 15,
-  "location:list_objects_with_permission": 22,
-  "macaddress:api_list_objects": 17,
-  "macaddress:list_objects_with_permission": 24,
-  "manufacturer:api_list_objects": 13,
-  "manufacturer:list_objects_with_permission": 20,
-  "module:api_list_objects": 18,
-  "module:list_objects_with_permission": 24,
-  "modulebay:api_list_objects": 15,
-  "modulebay:list_objects_with_permission": 21,
+  "location:api_list_objects": 14,
+  "location:list_objects_with_permission": 19,
+  "macaddress:api_list_objects": 16,
+  "macaddress:list_objects_with_permission": 21,
+  "manufacturer:api_list_objects": 12,
+  "manufacturer:list_objects_with_permission": 17,
+  "module:api_list_objects": 17,
+  "module:list_objects_with_permission": 21,
+  "modulebay:api_list_objects": 14,
+  "modulebay:list_objects_with_permission": 18,
   "modulebaytemplate:api_list_objects": 11,
   "modulebaytemplate:api_list_objects": 11,
-  "moduletype:api_list_objects": 14,
-  "moduletype:list_objects_with_permission": 22,
-  "moduletypeprofile:api_list_objects": 13,
-  "moduletypeprofile:list_objects_with_permission": 20,
-  "platform:api_list_objects": 13,
-  "platform:list_objects_with_permission": 21,
+  "moduletype:api_list_objects": 13,
+  "moduletype:list_objects_with_permission": 19,
+  "moduletypeprofile:api_list_objects": 12,
+  "moduletypeprofile:list_objects_with_permission": 17,
+  "platform:api_list_objects": 12,
+  "platform:list_objects_with_permission": 18,
   "powerconnection:list_objects_with_permission": 29,
   "powerconnection:list_objects_with_permission": 29,
-  "powerfeed:api_list_objects": 15,
-  "powerfeed:list_objects_with_permission": 22,
-  "poweroutlet:api_list_objects": 14,
-  "poweroutlet:list_objects_with_permission": 22,
+  "powerfeed:api_list_objects": 14,
+  "powerfeed:list_objects_with_permission": 19,
+  "poweroutlet:api_list_objects": 13,
+  "poweroutlet:list_objects_with_permission": 19,
   "poweroutlettemplate:api_list_objects": 11,
   "poweroutlettemplate:api_list_objects": 11,
-  "powerpanel:api_list_objects": 15,
-  "powerpanel:list_objects_with_permission": 22,
-  "powerport:api_list_objects": 14,
-  "powerport:list_objects_with_permission": 21,
+  "powerpanel:api_list_objects": 14,
+  "powerpanel:list_objects_with_permission": 19,
+  "powerport:api_list_objects": 13,
+  "powerport:list_objects_with_permission": 18,
   "powerporttemplate:api_list_objects": 11,
   "powerporttemplate:api_list_objects": 11,
-  "rack:api_list_objects": 17,
-  "rack:list_objects_with_permission": 29,
-  "rackgroup:api_list_objects": 13,
-  "rackgroup:list_objects_with_permission": 20,
-  "rackreservation:api_list_objects": 15,
-  "rackreservation:list_objects_with_permission": 23,
-  "rackrole:api_list_objects": 13,
-  "rackrole:list_objects_with_permission": 20,
-  "racktype:api_list_objects": 14,
-  "racktype:list_objects_with_permission": 21,
-  "rearport:api_list_objects": 15,
-  "rearport:list_objects_with_permission": 22,
+  "rack:api_list_objects": 16,
+  "rack:list_objects_with_permission": 26,
+  "rackgroup:api_list_objects": 12,
+  "rackgroup:list_objects_with_permission": 17,
+  "rackreservation:api_list_objects": 14,
+  "rackreservation:list_objects_with_permission": 20,
+  "rackrole:api_list_objects": 12,
+  "rackrole:list_objects_with_permission": 17,
+  "racktype:api_list_objects": 13,
+  "racktype:list_objects_with_permission": 18,
+  "rearport:api_list_objects": 14,
+  "rearport:list_objects_with_permission": 19,
   "rearporttemplate:api_list_objects": 12,
   "rearporttemplate:api_list_objects": 12,
-  "region:api_list_objects": 13,
-  "region:list_objects_with_permission": 20,
-  "site:api_list_objects": 17,
-  "site:list_objects_with_permission": 22,
-  "sitegroup:api_list_objects": 13,
-  "sitegroup:list_objects_with_permission": 20,
-  "virtualchassis:api_list_objects": 16,
-  "virtualchassis:list_objects_with_permission": 21,
-  "virtualdevicecontext:api_list_objects": 14,
-  "virtualdevicecontext:list_objects_with_permission": 20
+  "region:api_list_objects": 12,
+  "region:list_objects_with_permission": 17,
+  "site:api_list_objects": 16,
+  "site:list_objects_with_permission": 19,
+  "sitegroup:api_list_objects": 12,
+  "sitegroup:list_objects_with_permission": 17,
+  "virtualchassis:api_list_objects": 15,
+  "virtualchassis:list_objects_with_permission": 18,
+  "virtualdevicecontext:api_list_objects": 13,
+  "virtualdevicecontext:list_objects_with_permission": 17
 }
 }

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

@@ -1,7 +1,9 @@
 import json
 import json
 
 
 from django.conf import settings
 from django.conf import settings
+from django.db import connection
 from django.test import tag
 from django.test import tag
+from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from rest_framework import status
 from rest_framework import status
@@ -9,6 +11,7 @@ from rest_framework import status
 from core.models import ObjectType
 from core.models import ObjectType
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
+from dcim.graphql.types import _CABLE_TERMINATION_MODELS
 from dcim.models import *
 from dcim.models import *
 from extras.models import ConfigTemplate, Tag
 from extras.models import ConfigTemplate, Tag
 from ipam.choices import VLANQinQRoleChoices
 from ipam.choices import VLANQinQRoleChoices
@@ -2708,6 +2711,8 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
             VirtualDeviceContext(name='VDC 2', identifier=2, device=device)
             VirtualDeviceContext(name='VDC 2', identifier=2, device=device)
         )
         )
         VirtualDeviceContext.objects.bulk_create(vdcs)
         VirtualDeviceContext.objects.bulk_create(vdcs)
+        for interface in interfaces:
+            interface.vdcs.set(vdcs)
 
 
         vlans = (
         vlans = (
             VLAN(name='VLAN 1', vid=1),
             VLAN(name='VLAN 1', vid=1),
@@ -3593,6 +3598,115 @@ class CableTestCase(APIViewTestCases.APIViewTestCase):
 
 
                 self.assertSetEqual(set(ids), expected)
                 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(
 class CableTerminationTestCase(
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.GetObjectViewTestCase,

+ 39 - 0
netbox/dcim/tests/test_cablepaths.py

@@ -2680,6 +2680,45 @@ class LegacyCablePathTestCase(BaseCablePathTestCase):
         )
         )
         self.assertEqual(CablePath.objects.count(), 2)
         self.assertEqual(CablePath.objects.count(), 2)
 
 
+    def test_225_circuittermination_origin_passive_network(self):
+        """
+        [CT1] --C1-- [RP1] [FP1]
+
+        A CircuitTermination cabled into a passive (FrontPort/RearPort-only) device can become a
+        CablePath origin. Unlike PathEndpoint origins, CircuitTermination has no `_path` back-reference
+        field, so saving and deleting such a path must not attempt to write it (see #22825).
+        """
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        PortMapping.objects.create(
+            device=self.device, front_port=frontport1, front_port_position=1,
+            rear_port=rearport1, rear_port_position=1,
+        )
+        circuittermination1 = CircuitTermination.objects.create(
+            circuit=self.circuit,
+            termination=self.site,
+            term_side='A'
+        )
+        cable1 = Cable(
+            a_terminations=[circuittermination1],
+            b_terminations=[rearport1]
+        )
+        cable1.save()
+
+        # Re-fetch so the in-memory instance reflects the cable set above (from_origin reads .cable).
+        circuittermination1.refresh_from_db()
+
+        # A path traced from the CircuitTermination origin must save without raising FieldDoesNotExist
+        # on the missing `_path` field.
+        cablepath = CablePath.from_origin([circuittermination1])
+        cablepath.save()
+        self.assertEqual(cablepath.origin_type.model_class(), CircuitTermination)
+        self.assertEqual(cablepath.origins, [circuittermination1])
+
+        # Deleting the path must likewise not attempt to clear a nonexistent `_path` field.
+        cablepath.delete()
+        self.assertIsNone(CablePath.objects.filter(pk=cablepath.pk).first())
+
     def test_301_create_path_via_existing_cable(self):
     def test_301_create_path_via_existing_cable(self):
         """
         """
         [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
         [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]

+ 125 - 0
netbox/dcim/tests/test_forms.py

@@ -319,6 +319,13 @@ class FrontPortTestCase(TestCase):
             RearPort(name='RearPort4', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
             RearPort(name='RearPort4', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
         )
         )
         RearPort.objects.bulk_create(cls.rear_ports)
         RearPort.objects.bulk_create(cls.rear_ports)
+        cls.rear_port_templates = (
+            RearPortTemplate(name='RearPort1', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(name='RearPort2', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(name='RearPort3', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(name='RearPort4', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
+        )
+        RearPortTemplate.objects.bulk_create(cls.rear_port_templates)
 
 
     def test_front_port_label_count_valid(self):
     def test_front_port_label_count_valid(self):
         """
         """
@@ -353,6 +360,124 @@ class FrontPortTestCase(TestCase):
         self.assertFalse(form.is_valid())
         self.assertFalse(form.is_valid())
         self.assertIn('label', form.errors)
         self.assertIn('label', form.errors)
 
 
+    def test_front_port_position_count_valid(self):
+        """
+        Test that generating front ports with multiple positions each passes form validation.
+        """
+        front_port_data = {
+            'device': self.device.pk,
+            'name': 'FrontPort[1-2]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 2,
+            'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
+        }
+        form = FrontPortCreateForm(front_port_data)
+
+        self.assertTrue(form.is_valid(), form.errors)
+
+    def test_front_port_position_count_mismatch(self):
+        """
+        Check that the mismatch error reports the total number of front port positions, not the port count.
+        """
+        bad_front_port_data = {
+            'device': self.device.pk,
+            'name': 'FrontPort[1-2]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 2,
+            'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports[:2]],
+        }
+        form = FrontPortCreateForm(bad_front_port_data)
+
+        self.assertFalse(form.is_valid())
+        self.assertIn(
+            'The total number of front port positions (4) must match the selected number of rear port '
+            'positions (2).',
+            form.errors['rear_ports']
+        )
+
+    def test_front_port_template_position_count_mismatch(self):
+        """
+        Check that the front port template form reports the same corrected position total.
+        """
+        bad_front_port_template_data = {
+            'device_type': self.device.device_type.pk,
+            'name': 'FrontPort[1-2]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 2,
+            'rear_ports': [f'{rear_port_template.pk}:1' for rear_port_template in self.rear_port_templates[:2]],
+        }
+        form = FrontPortTemplateCreateForm(bad_front_port_template_data)
+
+        self.assertFalse(form.is_valid())
+        self.assertIn(
+            'The total number of front port positions (4) must match the selected number of rear port '
+            'positions (2).',
+            form.errors['rear_ports']
+        )
+
+    def test_front_port_missing_rear_ports(self):
+        """
+        Check that omitting the rear port selection reports a field error rather than raising an exception.
+        """
+        bad_front_port_data = {
+            'device': self.device.pk,
+            'name': 'FrontPort[1-2]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 1,
+        }
+        form = FrontPortCreateForm(bad_front_port_data)
+
+        self.assertFalse(form.is_valid())
+        self.assertIn('rear_ports', form.errors)
+
+    def test_front_port_invalid_positions(self):
+        """
+        Check that a non-numeric position count reports a field error rather than raising an exception.
+        """
+        bad_front_port_data = {
+            'device': self.device.pk,
+            'name': 'FrontPort[1-2]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 'two',
+            'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports[:2]],
+        }
+        form = FrontPortCreateForm(bad_front_port_data)
+
+        self.assertFalse(form.is_valid())
+        self.assertIn('positions', form.errors)
+
+    def test_front_port_template_missing_rear_ports(self):
+        """
+        Check that the front port template form also reports a field error rather than raising an exception.
+        """
+        bad_front_port_template_data = {
+            'device_type': self.device.device_type.pk,
+            'name': 'FrontPort[1-2]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 1,
+        }
+        form = FrontPortTemplateCreateForm(bad_front_port_template_data)
+
+        self.assertFalse(form.is_valid())
+        self.assertIn('rear_ports', form.errors)
+
+    def test_front_port_invalid_label_range(self):
+        """
+        Check that an inverted label range reports a field error rather than raising an exception.
+        """
+        bad_front_port_data = {
+            'device': self.device.pk,
+            'name': 'FrontPort[1-2]',
+            'label': 'Port[2-1]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 1,
+            'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports[:2]],
+        }
+        form = FrontPortCreateForm(bad_front_port_data)
+
+        self.assertFalse(form.is_valid())
+        self.assertIn('label', form.errors)
+
 
 
 class InterfaceTestCase(TestCase):
 class InterfaceTestCase(TestCase):
 
 

+ 124 - 1
netbox/dcim/tests/test_models.py

@@ -1,9 +1,10 @@
 from decimal import Decimal
 from decimal import Decimal
+from unittest.mock import patch
 
 
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db.models import ProtectedError
 from django.db.models import ProtectedError
 from django.db.models.signals import post_save
 from django.db.models.signals import post_save
-from django.test import TestCase, tag
+from django.test import TestCase, override_settings, tag
 
 
 from circuits.models import *
 from circuits.models import *
 from core.models import ObjectType
 from core.models import ObjectType
@@ -15,6 +16,7 @@ from ipam.models import Prefix
 from netbox.choices import WeightUnitChoices
 from netbox.choices import WeightUnitChoices
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.data import drange
 from utilities.data import drange
+from utilities.testing import PinnedConnectionRouter
 from virtualization.models import Cluster, ClusterType
 from virtualization.models import Cluster, ClusterType
 
 
 
 
@@ -2923,3 +2925,124 @@ class PowerPortDrawTestCase(TestCase):
         self.assertEqual(legs_by_name['A']['maximum'], 200)
         self.assertEqual(legs_by_name['A']['maximum'], 200)
         self.assertEqual(legs_by_name['B']['allocated'], 0)
         self.assertEqual(legs_by_name['B']['allocated'], 0)
         self.assertEqual(legs_by_name['C']['allocated'], 0)
         self.assertEqual(legs_by_name['C']['allocated'], 0)
+
+
+class ComponentInstantiationConnectionTestCase(TestCase):
+    """
+    Verify that component instantiation issues its queries against the connection the
+    parent 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 or
+    writes the component in the wrong database.
+
+    Where a path instantiates components, PinnedConnectionRouter cannot be used: Django's
+    own forward-relation descriptor consults the router when a related object is assigned
+    to an unsaved instance. Those paths are checked by capturing the alias handed to the
+    call instead.
+    """
+    @classmethod
+    def setUpTestData(cls):
+        cls.site = Site.objects.create(name='Site 1', slug='site-1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
+        cls.device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
+
+    def _record_module_bay_save_aliases(self):
+        """
+        Patch ModuleBay.save() to record the database alias passed to each call.
+        """
+        aliases = []
+        original_save = ModuleBay.save
+
+        def record_alias(instance, *args, **kwargs):
+            aliases.append(kwargs.get('using'))
+            return original_save(instance, *args, **kwargs)
+
+        return aliases, patch.object(ModuleBay, 'save', record_alias)
+
+    def test_module_bay_tree_id_lookup_pinned_to_saving_connection(self):
+        """
+        Inserting a root ModuleBay looks up the highest existing tree ID, which must be
+        read from the connection the bay is being written to.
+        """
+        device = Device.objects.create(
+            name='Device 1', device_type=self.device_type, role=self.device_role, site=self.site
+        )
+        # Instantiate outside the router, as assigning the Device consults it.
+        module_bay = ModuleBay(device=device, name='Module Bay 1')
+
+        with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(ModuleBay)]):
+            module_bay.save(using='default')
+
+        self.assertTrue(ModuleBay.objects.filter(pk=module_bay.pk).exists())
+
+    def test_device_module_bays_receive_saving_connection(self):
+        """
+        ModuleBays are instantiated individually (rather than in bulk) to maintain the MPTT
+        tree, so each save() must be given the Device's connection.
+        """
+        ModuleBayTemplate.objects.create(device_type=self.device_type, name='Module Bay 1')
+
+        device = Device(
+            name='Device 1', device_type=self.device_type, role=self.device_role, site=self.site
+        )
+        aliases, spy = self._record_module_bay_save_aliases()
+        with spy:
+            device.save()
+
+        self.assertEqual(aliases, [device._state.db])
+        self.assertEqual(ModuleBay.objects.filter(device=device).count(), 1)
+
+    def test_module_module_bays_receive_saving_connection(self):
+        """
+        Replicated MPTT components are likewise saved individually, and must be given the
+        Module's connection.
+        """
+        ModuleBayTemplate.objects.create(module_type=self.module_type, name='Module Bay 1')
+
+        device = Device.objects.create(
+            name='Device 1', device_type=self.device_type, role=self.device_role, site=self.site
+        )
+        parent_bay = ModuleBay.objects.create(device=device, name='Parent Bay')
+
+        module = Module(device=device, module_bay=parent_bay, module_type=self.module_type)
+        aliases, spy = self._record_module_bay_save_aliases()
+        with spy:
+            module.save()
+
+        self.assertEqual(aliases, [module._state.db])
+        self.assertEqual(ModuleBay.objects.filter(module=module).count(), 1)
+
+    def test_module_component_rebuild_uses_saving_connection(self):
+        """
+        Adopting existing components assigns them to the Module via bulk_update(), which
+        bypasses save() and so requires an explicit MPTT tree rebuild. That rebuild must
+        run on the Module's connection.
+        """
+        ModuleBayTemplate.objects.create(module_type=self.module_type, name='Module Bay 1')
+
+        device = Device.objects.create(
+            name='Device 1', device_type=self.device_type, role=self.device_role, site=self.site
+        )
+        parent_bay = ModuleBay.objects.create(device=device, name='Parent Bay')
+        child_bay = ModuleBay.objects.create(device=device, name='Module Bay 1')
+
+        aliases = []
+        manager_class = type(ModuleBay.objects)
+        original_rebuild = manager_class.rebuild
+
+        def record_alias(manager, *args, **kwargs):
+            # Manager.db falls back to the router, so the private attribute is the only
+            # indication of whether an alias was set explicitly.
+            aliases.append(manager._db)
+            return original_rebuild(manager, *args, **kwargs)
+
+        module = Module(device=device, module_bay=parent_bay, module_type=self.module_type)
+        module._adopt_components = True
+        module._disable_replication = True
+        with patch.object(manager_class, 'rebuild', record_alias):
+            module.save()
+
+        child_bay.refresh_from_db()
+        self.assertEqual(child_bay.module, module)
+        self.assertEqual(aliases, [module._state.db])

+ 613 - 6
netbox/dcim/tests/test_signals.py

@@ -3,14 +3,16 @@ from unittest.mock import MagicMock, patch
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db import connection, transaction
 from django.db import connection, transaction
-from django.test import SimpleTestCase, TestCase, TransactionTestCase
+from django.test import SimpleTestCase, TestCase, TransactionTestCase, override_settings
 from django.test.utils import CaptureQueriesContext
 from django.test.utils import CaptureQueriesContext
 
 
+from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
 from dcim import signals
 from dcim import signals
 from dcim.choices import CableEndChoices, CableProfileChoices, LinkStatusChoices
 from dcim.choices import CableEndChoices, CableProfileChoices, LinkStatusChoices
 from dcim.models import (
 from dcim.models import (
     Cable,
     Cable,
     CablePath,
     CablePath,
+    CableTermination,
     Device,
     Device,
     DeviceRole,
     DeviceRole,
     DeviceType,
     DeviceType,
@@ -29,16 +31,48 @@ from dcim.models import (
     VirtualChassis,
     VirtualChassis,
 )
 )
 from ipam.models import Prefix
 from ipam.models import Prefix
+from utilities.testing import PinnedConnectionRouter
 from virtualization.models import Cluster, ClusterType
 from virtualization.models import Cluster, ClusterType
 from wireless.models import WirelessLAN
 from wireless.models import WirelessLAN
 
 
+COMPONENT_TABLES = frozenset(model._meta.db_table for model in signals.COMPONENT_MODELS)
 
 
-class LocationSiteChangeSignalTestCase(TestCase):
+
+class ScopePropagationCaptureMixin:
+    """
+    Helper for asserting whether a save propagated to the tables its post_save handler
+    rewrites.
+
+    dcim_cabletermination is never among them: the denormalized-field registry
+    (netbox.denormalized) rewrites it on Location, Rack, and Device saves alike, so it
+    cannot distinguish a propagation from a plain save. 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
+            if q['sql'].startswith(f'UPDATE "{table}"')
+        }
+
+
+class LocationSiteChangeSignalTestCase(ScopePropagationCaptureMixin, TestCase):
     """
     """
     Verify dcim.signals.handle_location_site_change propagates a Location's new Site to
     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
     every descendant Location, Rack, Device, PowerPanel, and component when the parent
     Location's site assignment changes.
     Location's site assignment changes.
     """
     """
+    propagation_tables = COMPONENT_TABLES | {'dcim_rack', 'dcim_device', 'dcim_powerpanel'}
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -76,16 +110,203 @@ class LocationSiteChangeSignalTestCase(TestCase):
         self.assertEqual(interface._site, self.site_b)
         self.assertEqual(interface._site, self.site_b)
         self.assertEqual(power_panel.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, so sync_cached_scope_fields does
+        # not cover it and the denormalized-field registry refreshes only _site. 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):
     def test_creating_location_does_not_attempt_to_propagate(self):
         # Should not raise — newly-created locations have no descendants.
         # Should not raise — newly-created locations have no descendants.
         Location.objects.create(name='New', slug='new', site=self.site_a)
         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. netbox.denormalized.update_denormalized_fields()
+        # returns early on raw for the same reason.
+        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 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 cached column via a signal-less update; an unconditional propagation
+        # repairs it.
+        Interface.objects.filter(pk=interface.pk).update(_site=other_site)
+
+        location.save()  # Autocommit: no stash, unconditional propagation
+
+        interface.refresh_from_db()
+        self.assertEqual(interface._site, site)
 
 
-class RackSiteChangeSignalTestCase(TestCase):
+
+class RackSiteChangeSignalTestCase(ScopePropagationCaptureMixin, TestCase):
     """
     """
     Verify dcim.signals.handle_rack_site_change propagates a Rack's site/location to its
     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 = COMPONENT_TABLES | {'dcim_device'}
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -118,12 +339,261 @@ class RackSiteChangeSignalTestCase(TestCase):
         self.assertEqual(interface._site, self.site_b)
         self.assertEqual(interface._site, self.site_b)
         self.assertEqual(interface._location, self.location_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):
+        cls.instances = {}
+        site = Site.objects.create(name='Site', slug='site')
+        location = Location.objects.create(name='Location', slug='location', site=site)
+        rack = Rack.objects.create(name='Rack', site=site, location=location)
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
+        cls.instances = {
+            Site: site,
+            Location: location,
+            Rack: rack,
+            Device: Device.objects.create(
+                name='Device',
+                site=site,
+                location=location,
+                rack=rack,
+                device_type=DeviceType.objects.create(manufacturer=manufacturer, model='Device Type'),
+                role=DeviceRole.objects.create(name='Device Role', slug='device-role'),
+            ),
+        }
+
+    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()
 
 
-class DeviceSiteChangeSignalTestCase(TestCase):
+        device.refresh_from_db()
+        interface.refresh_from_db()
+        self.assertEqual(device.site, self.site_b)
+        self.assertEqual(interface._site, self.site_b)
+
+    def test_device_save_pins_queries_to_saving_connection(self):
+        device = Device.objects.create(
+            name='Device',
+            site=self.site_a,
+            device_type=self.device_type,
+            role=self.device_role,
+        )
+        interface = Interface.objects.create(device=device, name='Interface 1')
+
+        device = Device.objects.get(pk=device.pk)
+        device.site_id = self.site_b.pk
+        with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(CableTermination, Interface, Site)]):
+            device.save()
+
+        interface.refresh_from_db()
+        self.assertEqual(interface._site, self.site_b)
+
+    def test_site_save_pins_scope_resync_to_saving_connection(self):
+        region = Region.objects.create(name='Region', slug='region')
+        cluster_type = ClusterType.objects.create(name='Cluster Type', slug='cluster-type')
+        # Scope the Cluster to a Location rather than to the Site itself: the rebuild then
+        # has to resolve the Location behind the object's generic scope, which is the read
+        # that must follow the connection the Site was saved on.
+        location = Location.objects.create(name='Location', slug='location', site=self.site_a)
+        cluster = Cluster.objects.create(name='Cluster', type=cluster_type, scope=location)
+
+        site = Site.objects.get(pk=self.site_a.pk)
+        site.region = region
+        # Region is included to catch the Location's site.region read made while rebuilding
+        # the cached fields; Site itself cannot be, as Django routes the save under test.
+        router = PinnedConnectionRouter(CircuitTermination, Cluster, Location, Prefix, Region, WirelessLAN)
+        with override_settings(DATABASE_ROUTERS=[router]):
+            site.save()
+
+        cluster.refresh_from_db()
+        self.assertEqual(cluster._region, region)
+
+
+class DeviceSiteChangeSignalTestCase(ScopePropagationCaptureMixin, TestCase):
     """
     """
     Verify dcim.signals.handle_device_site_change propagates a Device's site/location/rack
     Verify dcim.signals.handle_device_site_change propagates a Device's site/location/rack
-    to its components on save.
+    to its components on save, and only then.
     """
     """
+    propagation_tables = COMPONENT_TABLES
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -149,6 +619,99 @@ class DeviceSiteChangeSignalTestCase(TestCase):
         interface.refresh_from_db()
         interface.refresh_from_db()
         self.assertEqual(interface._site, self.site_b)
         self.assertEqual(interface._site, self.site_b)
 
 
+    def _seed_device_with_components(self):
+        device = Device.objects.create(
+            name='Device',
+            site=self.site_a,
+            device_type=self.device_type,
+            role=self.device_role,
+        )
+        Interface.objects.create(device=device, name='Interface 1')
+        return device
+
+    def test_unchanged_scope_skips_propagation(self):
+        # Components repopulate _site/_location/_rack from their Device on their own save
+        # (see ComponentModel.save), so a Device save which moved the Device nowhere has
+        # nothing to push down and must not rewrite a single component row.
+        device = self._seed_device_with_components()
+        device.description = 'updated'
+
+        self.assertEqual(self.capture_propagation_updates(device), 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.
+        device = self._seed_device_with_components()
+        device.site = self.site_b
+
+        self.assertEqual(self.capture_propagation_updates(device), self.propagation_tables)
+
+    def test_changed_rack_propagates(self):
+        # A Rack assignment is the third guarded field, and the only one changed here: the
+        # Rack is deliberately left without a Location, so Device.save() does not inherit one
+        # and neither site nor location moves.
+        device = self._seed_device_with_components()
+        rack = Rack.objects.create(name='Rack', site=self.site_a)
+        self.assertIsNone(rack.location)
+        device.rack = rack
+
+        self.assertEqual(self.capture_propagation_updates(device), 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.
+        device = self._seed_device_with_components()
+        device.site = self.site_b
+
+        self.assertEqual(self.capture_propagation_updates(device, raw=True), set())
+
+    def test_stale_partial_save_does_not_propagate_an_unwritten_scope(self):
+        # As for Location and Rack: this instance was loaded before the move below, so its
+        # in-memory site is not one this save writes and must not reach the components.
+        device = self._seed_device_with_components()
+        stale = Device.objects.get(pk=device.pk)
+
+        device.site = self.site_b
+        device.save()
+
+        stale.description = 'updated'
+        self.assertEqual(
+            self.capture_propagation_updates(stale, update_fields=['description']), set()
+        )
+
+        self.assertEqual(Interface.objects.get(device=device)._site, self.site_b)
+
+    def test_stale_partial_save_propagates_written_field_with_database_values(self):
+        # The mixed case, which the skip cannot cover: one guarded field is written, so the
+        # propagation must run — and the two fields the save did not write have to be taken
+        # from the database, not from the stale instance. Assigning the rack alone leaves the
+        # site and location columns untouched, so the components must end up at site_b (where
+        # the device actually is) rather than site_a (which the instance still carries).
+        device = self._seed_device_with_components()
+        stale = Device.objects.get(pk=device.pk)
+
+        device.site = self.site_b
+        device.save()
+
+        # A rack in site_b with no location, so Device.save() inherits no location from it.
+        rack = Rack.objects.create(name='Rack', site=self.site_b)
+        self.assertIsNone(rack.location)
+        stale.rack = rack
+
+        self.assertEqual(
+            self.capture_propagation_updates(stale, update_fields=['rack']),
+            self.propagation_tables,
+        )
+
+        interface = Interface.objects.get(device=device)
+        self.assertEqual(interface._site, self.site_b)
+        self.assertEqual(interface._rack, rack)
+        self.assertIsNone(interface._location)
+        # The device's own site column was never rewritten by the partial save either.
+        device.refresh_from_db()
+        self.assertEqual(device.site, self.site_b)
+
 
 
 class VirtualChassisMasterSignalTestCase(TestCase):
 class VirtualChassisMasterSignalTestCase(TestCase):
     """
     """
@@ -798,6 +1361,50 @@ class SyncCachedScopeFieldsSignalTestCase(TestCase):
         ]
         ]
         self.assertEqual(len(cluster_updates), 1)
         self.assertEqual(len(cluster_updates), 1)
 
 
+    def test_stale_partial_save_skips_resync(self):
+        # A save passing update_fields writes only the fields it names, so an omitted scope
+        # field cannot have changed and the rebuild has nothing to recompute.
+        group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
+        group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
+        site = Site.objects.create(name='Site', slug='site', group=group_a)
+        cluster_type = ClusterType.objects.create(name='CT', slug='ct')
+        cluster = Cluster.objects.create(name='Cluster', type=cluster_type, scope=site)
+
+        stale = Site.objects.get(pk=site.pk)
+        site.group = group_b
+        site.save()
+
+        stale.description = 'updated'
+        with CaptureQueriesContext(connection) as ctx:
+            stale.save(update_fields=['description'])
+
+        self.assertEqual(
+            [q for q in ctx.captured_queries if q['sql'].startswith('UPDATE "virtualization_cluster"')],
+            [],
+        )
+        cluster.refresh_from_db()
+        self.assertEqual(cluster._site_group, group_b)
+
+    def test_raw_save_skips_resync(self):
+        # raw=True is set only by Django's loaddata pathway, whose fixture already carries the
+        # cached scope fields for every object it loads, so the rebuild would recompute the
+        # values the rows already hold.
+        group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
+        group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
+        site = Site.objects.create(name='Site', slug='site', group=group_a)
+        cluster_type = ClusterType.objects.create(name='CT', slug='ct')
+        Cluster.objects.create(name='Cluster', type=cluster_type, scope=site)
+
+        site.group = group_b  # A real scope change, which a non-raw save would resync
+
+        with CaptureQueriesContext(connection) as ctx:
+            site.save_base(raw=True)
+
+        self.assertEqual(
+            [q for q in ctx.captured_queries if q['sql'].startswith('UPDATE "virtualization_cluster"')],
+            [],
+        )
+
 
 
 class SyncCachedScopeFieldsAutocommitTestCase(TransactionTestCase):
 class SyncCachedScopeFieldsAutocommitTestCase(TransactionTestCase):
     """
     """

+ 131 - 10
netbox/dcim/tests/test_views.py

@@ -16,11 +16,11 @@ from core.models import ObjectChange, ObjectType
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
-from extras.models import ConfigTemplate
+from extras.models import ConfigContext, ConfigTemplate
 from ipam.models import ASN, RIR, VLAN, VRF
 from ipam.models import ASN, RIR, VLAN, VRF
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from users.models import ObjectPermission, User
+from users.models import ObjectPermission, Owner, User
 from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
 from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
 from wireless.models import WirelessLAN
 from wireless.models import WirelessLAN
 
 
@@ -1248,13 +1248,13 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         Manufacturer.objects.bulk_create(manufacturers)
         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([
         module_types = ModuleType.objects.bulk_create([
             ModuleType(
             ModuleType(
                 model='Module Type 1',
                 model='Module Type 1',
                 manufacturer=manufacturers[0],
                 manufacturer=manufacturers[0],
-                profile=profile,
+                profile=cls.profile,
                 attribute_data={'media': ['copper', 'qsfp28']},
                 attribute_data={'media': ['copper', 'qsfp28']},
             ),
             ),
             ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
             ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
@@ -1263,8 +1263,6 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
-        fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
-
         cls.form_data = {
         cls.form_data = {
             'manufacturer': manufacturers[1].pk,
             'manufacturer': manufacturers[1].pk,
             'model': 'Device Type X',
             'model': 'Device Type X',
@@ -1280,7 +1278,7 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
         cls.csv_data = (
         cls.csv_data = (
             "manufacturer,model,part_number,comments,profile",
             "manufacturer,model,part_number,comments,profile",
-            f"Manufacturer 1,fan0,generic-fan,,{fan_module_type_profile.name}"
+            f"Manufacturer 1,Module Type 4,module-type-4,,{cls.profile.name}",
         )
         )
 
 
         cls.csv_update_data = (
         cls.csv_update_data = (
@@ -1334,9 +1332,8 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
         def verify_module_type_profile(scenario_name):
         def verify_module_type_profile(scenario_name):
             # TODO: remove extra regression asserts once parent test supports testing all import fields
             # 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
         # run base test
         super().test_bulk_import_objects_with_permission(post_import_callback=verify_module_type_profile)
         super().test_bulk_import_objects_with_permission(post_import_callback=verify_module_type_profile)
@@ -1766,6 +1763,7 @@ class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'type': ConsolePortTypeChoices.TYPE_RJ45,
+            'description': 'Foo bar',
         }
         }
 
 
 
 
@@ -2030,6 +2028,7 @@ class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'description': 'Foo bar',
             'description': 'Foo bar',
+            'position': 'A1',
         }
         }
 
 
 
 
@@ -2111,6 +2110,7 @@ class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTes
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'description': 'Foo bar',
             'description': 'Foo bar',
+            'part_id': 'PN-1',
         }
         }
 
 
 
 
@@ -2541,6 +2541,54 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
         self.assertIn(b'Error rendering template', response.content)
         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):
     def test_device_role_display_colored(self):
         parent_role = DeviceRole.objects.create(name='Parent Role', slug='parent-role', color='111111')
         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')
         child_role = DeviceRole.objects.create(name='Child Role', slug='child-role', parent=parent_role, color='aa00bb')
@@ -3192,6 +3240,8 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
         )
         )
         PowerOutlet.objects.bulk_create(power_outlets)
         PowerOutlet.objects.bulk_create(power_outlets)
 
 
+        owner = Owner.objects.create(name='Owner 1')
+
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
@@ -3202,6 +3252,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'power_port': powerports[1].pk,
             'power_port': powerports[1].pk,
             'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
             'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
             'description': 'A power outlet',
             'description': 'A power outlet',
+            'owner': owner.pk,
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
 
 
@@ -3213,6 +3264,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'power_port': powerports[1].pk,
             'power_port': powerports[1].pk,
             'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
             'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
             'description': 'A power outlet',
             'description': 'A power outlet',
+            'owner': owner.pk,
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
 
 
@@ -3463,6 +3515,38 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         self.assertHttpStatus(response, 302)
         self.assertHttpStatus(response, 302)
         self.assertEqual(Interface.objects.filter(device=device, name__startswith='xe').count(), 37)
         self.assertEqual(Interface.objects.filter(device=device, name__startswith='xe').count(), 37)
 
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_bulk_import_omitted_field_validation_error(self):
+        """Surface omitted-field validation errors during bulk updates."""
+        device = Device.objects.first()
+        wireless_interface = Interface.objects.create(
+            device=device,
+            name='Wireless-22683',
+            type=InterfaceTypeChoices.TYPE_80211AC,
+            rf_channel_width=Decimal('20.0'),
+        )
+        self.add_permissions('dcim.add_interface', 'dcim.change_interface')
+        csv_data = '\n'.join([
+            'id,type',
+            f'{wireless_interface.pk},{InterfaceTypeChoices.TYPE_1GE_GBIC}',
+        ])
+        response = self.client.post(
+            self._get_url('bulk_import'),
+            data={
+                'data': csv_data,
+                'format': ImportFormatChoices.CSV,
+                'csv_delimiter': CSVDelimiterChoices.AUTO,
+            },
+        )
+        self.assertHttpStatus(response, 200)
+        self.assertContains(
+            response,
+            'rf_channel_width: Channel width may be set only on wireless interfaces.',
+        )
+        wireless_interface.refresh_from_db()
+        self.assertEqual(wireless_interface.type, InterfaceTypeChoices.TYPE_80211AC)
+        self.assertEqual(wireless_interface.rf_channel_width, Decimal('20.0'))
+
 
 
 class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = FrontPort
     model = FrontPort
@@ -3578,6 +3662,43 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertFalse(FrontPort.objects.filter(name='Front Port 10').exists())
         self.assertFalse(FrontPort.objects.filter(name='Front Port 10').exists())
 
 
+    def test_create_multiple_objects_with_multiple_positions(self):
+        """
+        Check that bulk creation gives each generated front port its own slice of the selected mappings.
+        """
+        device = Device.objects.get(name='Device 1')
+        rear_ports = (
+            RearPort(device=device, name='Rear Port 7', positions=2),
+            RearPort(device=device, name='Rear Port 8', positions=2),
+        )
+        RearPort.objects.bulk_create(rear_ports)
+        self.add_permissions('dcim.add_frontport')
+
+        response = self.client.post(self._get_url('add'), post_data({
+            'device': device.pk,
+            'name': 'Multi Port [1-2]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 2,
+            'rear_ports': [
+                f'{rear_ports[0].pk}:1',
+                f'{rear_ports[0].pk}:2',
+                f'{rear_ports[1].pk}:1',
+                f'{rear_ports[1].pk}:2',
+            ],
+        }))
+
+        self.assertHttpStatus(response, 302)
+        for front_port_name, rear_port in (('Multi Port 1', rear_ports[0]), ('Multi Port 2', rear_ports[1])):
+            front_port = FrontPort.objects.get(device=device, name=front_port_name)
+            self.assertEqual(front_port.positions, 2)
+            self.assertEqual(
+                [
+                    (m.front_port_position, m.rear_port_id, m.rear_port_position)
+                    for m in front_port.mappings.order_by('front_port_position')
+                ],
+                [(1, rear_port.pk, 1), (2, rear_port.pk, 2)]
+            )
+
     def test_trace(self):
     def test_trace(self):
         self.add_permissions(
         self.add_permissions(
             'dcim.view_frontport',
             'dcim.view_frontport',

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

@@ -150,7 +150,7 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
     manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
     manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
     model = attrs.TextAttr('model')
     model = attrs.TextAttr('model')
     part_number = attrs.TextAttr('part_number')
     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')
     description = attrs.TextAttr('description')
     height = attrs.TemplatedAttr('u_height', template_name='dcim/devicetype/attrs/height.html')
     height = attrs.TemplatedAttr('u_height', template_name='dcim/devicetype/attrs/height.html')
     exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization')
     exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization')

+ 9 - 3
netbox/dcim/views.py

@@ -208,9 +208,15 @@ class PathTraceView(generic.ObjectView):
         # Get the total length of the cable and whether the length is definitive (fully defined)
         # Get the total length of the cable and whether the length is definitive (fully defined)
         total_length, is_definitive = path.get_total_length() if path else (None, False)
         total_length, is_definitive = path.get_total_length() if path else (None, False)
 
 
-        # Determine the path to the SVG trace image
-        api_viewname = f"{path.origin_type.app_label}-api:{path.origin_type.model}-trace"
-        svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origins[0].pk})}?render=svg"
+        # Determine the path to the SVG trace image. The `-trace` API action (and the SVG renderer,
+        # which calls origin.trace()) exist only for PathEndpoint origins. Other valid origins such as
+        # CircuitTermination have no such action, so omit the SVG for them.
+        origin_model = path.origin_type.model_class()
+        if issubclass(origin_model, PathEndpoint):
+            api_viewname = f"{path.origin_type.app_label}-api:{path.origin_type.model}-trace"
+            svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origins[0].pk})}?render=svg"
+        else:
+            svg_url = None
 
 
         return {
         return {
             'path': path,
             'path': path,

+ 34 - 0
netbox/extras/api/routers.py

@@ -0,0 +1,34 @@
+from rest_framework.routers import Route
+
+from netbox.api.routers import NetBoxRouter
+
+from .views import ScriptViewSet
+
+__all__ = (
+    'ScriptRouter',
+)
+
+
+class ScriptRouter(NetBoxRouter):
+    """
+    Extend NetBoxRouter to map POST on the script detail route to ScriptViewSet.run(). DRF's detail route
+    maps only the standard CRUD methods; absent this, run() must be declared as a raw post() method, which
+    binds to every route of the ViewSet and is invisible to per-action permissions & schema generation.
+    """
+    def get_routes(self, viewset):
+        if not issubclass(viewset, ScriptViewSet):
+            return super().get_routes(viewset)
+
+        # Extend the detail route template. Applied before super() expands the templates so that any
+        # @action routes are untouched; _replace() avoids mutating the templates shared by all routers.
+        routes = self.routes
+        self.routes = [
+            route._replace(mapping={**route.mapping, 'post': 'run'})
+            if isinstance(route, Route) and route.detail else route
+            for route in routes
+        ]
+
+        try:
+            return super().get_routes(viewset)
+        finally:
+            self.routes = routes

+ 2 - 1
netbox/extras/api/serializers_/customfields.py

@@ -19,7 +19,8 @@ __all__ = (
 class CustomFieldChoiceSetSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedModelSerializer):
 class CustomFieldChoiceSetSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedModelSerializer):
     base_choices = ChoiceField(
     base_choices = ChoiceField(
         choices=CustomFieldChoiceSetBaseChoices,
         choices=CustomFieldChoiceSetBaseChoices,
-        required=False
+        required=False,
+        allow_null=True,
     )
     )
     extra_choices = serializers.ListField(
     extra_choices = serializers.ListField(
         child=serializers.ListField(
         child=serializers.ListField(

+ 2 - 3
netbox/extras/api/urls.py

@@ -1,10 +1,9 @@
 from django.urls import include, path
 from django.urls import include, path
 
 
-from netbox.api.routers import NetBoxRouter
-
 from . import views
 from . import views
+from .routers import ScriptRouter
 
 
-router = NetBoxRouter()
+router = ScriptRouter()
 router.APIRootView = views.ExtrasRootView
 router.APIRootView = views.ExtrasRootView
 
 
 router.register('event-rules', views.EventRuleViewSet)
 router.register('event-rules', views.EventRuleViewSet)

+ 61 - 24
netbox/extras/api/views.py

@@ -10,7 +10,6 @@ from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveMode
 from rest_framework.renderers import JSONRenderer
 from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 from rest_framework.routers import APIRootView
-from rest_framework.viewsets import ModelViewSet
 
 
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
 from extras import filtersets
 from extras import filtersets
@@ -23,6 +22,7 @@ from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.renderers import TextRenderer
 from netbox.api.renderers import TextRenderer
 from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet
 from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet
 from netbox.api.viewsets.mixins import ObjectValidationMixin
 from netbox.api.viewsets.mixins import ObjectValidationMixin
+from users.models import Token
 from utilities.exceptions import RQWorkerNotRunningException
 from utilities.exceptions import RQWorkerNotRunningException
 from utilities.request import copy_safe_request
 from utilities.request import copy_safe_request
 from utilities.rqworker import any_workers_for_queue
 from utilities.rqworker import any_workers_for_queue
@@ -308,30 +308,44 @@ class ScriptModuleViewSet(ObjectValidationMixin, CreateModelMixin, UpdateModelMi
         return obj
         return obj
 
 
 
 
-@extend_schema_view(
-    update=extend_schema(request=serializers.ScriptInputSerializer),
-    partial_update=extend_schema(request=serializers.ScriptInputSerializer),
-)
-class ScriptViewSet(ModelViewSet):
+class ScriptViewSet(ListModelMixin, RetrieveModelMixin, BaseViewSet):
+    # Individual scripts are created, modified, and deleted through their module (see ScriptModuleViewSet),
+    # so the standard write actions are intentionally omitted here. Only listing/retrieving a script (GET)
+    # and running one (POST to the detail route) are supported.
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
     queryset = Script.objects.all()
     queryset = Script.objects.all()
     serializer_class = serializers.ScriptSerializer
     serializer_class = serializers.ScriptSerializer
     filterset_class = filtersets.ScriptFilterSet
     filterset_class = filtersets.ScriptFilterSet
 
 
-    _ignore_model_permissions = True
     lookup_value_regex = '[^/]+'  # Allow dots
     lookup_value_regex = '[^/]+'  # Allow dots
 
 
-    def initial(self, request, *args, **kwargs):
-        super().initial(request, *args, **kwargs)
+    def get_serializer(self, *args, **kwargs):
+        # A POST to the detail route runs the script, taking ScriptInputSerializer as its request body.
+        # (This is keyed on the request method rather than on self.action, which is unset when generating
+        # OPTIONS metadata.) ScriptInputSerializer is instantiated directly rather than via BaseViewSet,
+        # which would pass it the fields/omit kwargs supported only by BaseModelSerializer.
+        if getattr(self.request, 'method', None) == 'POST':
+            kwargs.setdefault('context', self.get_serializer_context())
+            return serializers.ScriptInputSerializer(*args, **kwargs)
+        return super().get_serializer(*args, **kwargs)
 
 
-        # Restrict the view's QuerySet to allow only the permitted objects
-        if request.user.is_authenticated:
-            action = 'run' if request.method == 'POST' else 'view'
-            self.queryset = self.queryset.restrict(request.user, action)
+    def get_serializer_context(self):
+        context = super().get_serializer_context()
+
+        # ScriptInputSerializer resolves its field defaults and validates scheduling against the script
+        # being run (set by run() below).
+        context['script'] = getattr(self, 'script', None)
+
+        return context
 
 
     def _get_script(self, pk):
     def _get_script(self, pk):
-        # If pk is numeric, retrieve script by ID
-        if pk.isnumeric():
+        # Retrieve the script by ID if the PK is all decimal digits. (isdecimal() rather than isnumeric(),
+        # as the latter also matches characters which cannot be cast to an integer.)
+        if pk.isdecimal():
+            try:
+                pk = int(pk)
+            except ValueError:
+                raise Http404
             return get_object_or_404(self.queryset, pk=pk)
             return get_object_or_404(self.queryset, pk=pk)
 
 
         # Default to retrieval by module & name
         # Default to retrieval by module & name
@@ -342,26 +356,49 @@ class ScriptViewSet(ModelViewSet):
 
 
         return get_object_or_404(self.queryset, module__file_path=f'{module_name}.py', name=script_name)
         return get_object_or_404(self.queryset, module__file_path=f'{module_name}.py', name=script_name)
 
 
-    def retrieve(self, request, pk):
+    def retrieve(self, request, pk, **kwargs):
         script = self._get_script(pk)
         script = self._get_script(pk)
         serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
         serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
 
 
         return Response(serializer.data)
         return Response(serializer.data)
 
 
-    def post(self, request, pk):
+    @extend_schema(
+        operation_id='extras_scripts_run',
+        request=serializers.ScriptInputSerializer,
+        responses={
+            200: OpenApiResponse(
+                response=serializers.ScriptDetailSerializer,
+                description=_("The script has been enqueued for execution."),
+            ),
+        },
+    )
+    def run(self, request, pk, **kwargs):
         """
         """
         Run a Script identified by its numeric PK or module & name and return the pending Job as the result
         Run a Script identified by its numeric PK or module & name and return the pending Job as the result
         """
         """
+        # Bound to POST on the detail route by ScriptRouter
 
 
-        script = self._get_script(pk)
+        # Reject read-only tokens before resolving the script, so that an insufficient token is always
+        # reported as such. (Not via TokenWritePermission, which permits token auth only.)
+        if isinstance(request.auth, Token) and not request.auth.write_enabled:
+            raise PermissionDenied(_("This token does not permit write operations (running a script)."))
 
 
-        if not request.user.has_perm('extras.run_script', obj=script):
-            raise PermissionDenied("This user does not have permission to run this script.")
+        # An unauthenticated user can never run a script; report that explicitly, as restrict() below would
+        # match no scripts and yield a misleading 404.
+        if not request.user.is_authenticated:
+            raise PermissionDenied(_("This user does not have permission to run this script."))
 
 
-        input_serializer = serializers.ScriptInputSerializer(
-            data=request.data,
-            context={'script': script}
-        )
+        # Running a script is a 'run' operation (not the 'add' that BaseViewSet maps to POST), so restrict
+        # the QuerySet on 'run' before resolving the script. A script the user cannot run yields a 404.
+        self.queryset = self.queryset.model.objects.restrict(request.user, 'run')
+        self.script = script = self._get_script(pk)
+
+        # A script whose Python class cannot be resolved (e.g. its module has been modified or the script has
+        # been deleted, retaining the record for its jobs) cannot be run
+        if not script.is_executable or script.python_class is None:
+            raise ValidationError(_("This script is not currently executable."))
+
+        input_serializer = self.get_serializer(data=request.data)
 
 
         # Check that at least one RQ worker is running
         # Check that at least one RQ worker is running
         if not any_workers_for_queue('default'):
         if not any_workers_for_queue('default'):

+ 2 - 0
netbox/extras/events.py

@@ -253,6 +253,8 @@ def process_event_rules(event_rules, object_type, event):
                 'name': script.name,
                 'name': script.name,
                 'user': event['user'],
                 'user': event['user'],
                 'data': event_data,
                 'data': event_data,
+                'notifications': script.notifications_default,
+                'job_timeout': script.job_timeout,
             }
             }
             if 'snapshots' in event:
             if 'snapshots' in event:
                 params['snapshots'] = event['snapshots']
                 params['snapshots'] = event['snapshots']

+ 98 - 0
netbox/extras/filters.py

@@ -1,13 +1,111 @@
+from functools import cache
+
 import django_filters
 import django_filters
+from django.db.models import Q
 
 
 from .models import Tag
 from .models import Tag
 
 
 __all__ = (
 __all__ = (
+    'MissingKeyAwareFilterMixin',
     'TagFilter',
     'TagFilter',
     'TagIDFilter',
     'TagIDFilter',
+    'missing_key_aware_filter_factory',
 )
 )
 
 
 
 
+class MissingKeyAwareFilterMixin:
+    """
+    Treat a JSON key which is absent as equivalent to one holding a null value: an object storing
+    no value for a custom field must filter identically however that absence is represented.
+
+    Custom field data materializes a key only once a value is assigned to it (see
+    CustomField.populate_initial_data()), so an object predating a field carries no key for it at
+    all, whereas one whose value has been cleared holds a JSON null. Postgres treats the two
+    differently, in two places:
+
+    * Django compiles `exclude(custom_field_data__foo='x')` to a bare `NOT (data -> 'foo' = 'x')`.
+      A row which does not carry the key yields SQL NULL there, so the negation evaluates to NULL
+      and the row is discarded. A row holding a JSON null fares no better under any of the text
+      lookups (icontains, istartswith, etc.), which compare `data ->> 'foo'` and so are NULL for a
+      JSON null as well.
+    * The null sentinel (`?cf_foo=null`; see FILTERS_NULL_CHOICE_VALUE) asks for the objects holding
+      no value. MultipleChoiceFilter.filter() translates it to None and hands it to
+      get_filter_predicate(), which builds a lookup matching a JSON null only -- silently omitting
+      every object which predates the field.
+
+    Both directions are handled: the sentinel is mapped onto "holds no value" rather than onto a
+    predicate of its own, and a negation is built explicitly so that valueless rows are admitted.
+
+    Two constraints on where this may be mixed in, both satisfied by every filter class
+    CustomField.to_filter() can select:
+
+    * filter() is reimplemented rather than delegated to, so any custom filter() on the base class
+      is bypassed. Do not mix this into a class which overrides filter() (e.g.
+      MultiValueMACAddressFilter, MultiValueContentTypeFilter).
+      missing_key_aware_filter_factory() rejects such classes.
+    * `conjoined` is not honored: multiple values are always OR'ed. Passing it raises TypeError.
+    """
+    def __init__(self, *args, **kwargs):
+        if kwargs.get('conjoined'):
+            raise TypeError(
+                f"{type(self).__name__} does not support conjoined filtering: multiple values are "
+                f"always OR'ed."
+            )
+        super().__init__(*args, **kwargs)
+
+    def filter(self, qs, value):
+        if not value:
+            return super().filter(qs, value)
+
+        # `<key>__isnull` matches only a missing key and `<key>=None` only a JSON null, so together
+        # they select exactly the objects holding no value. Both are null-safe, which is what makes
+        # them usable inside the negation below.
+        unset = Q(**{f'{self.field_name}__isnull': True}) | Q(**{self.field_name: None})
+
+        values = set(value)
+        match_unset = self.null_value in values
+        values.discard(self.null_value)
+
+        q = Q()
+        for v in values:
+            q |= Q(**self.get_filter_predicate(v))
+        if match_unset:
+            q |= unset
+
+        if self.exclude:
+            # Negate explicitly rather than deferring to exclude(), whose bare NOT discards the
+            # rows carrying no key. Those rows are admitted, unless holding no value is itself one
+            # of the things being excluded.
+            q = ~q if match_unset else ~q | unset
+
+        qs = qs.filter(q)
+
+        return qs.distinct() if self.distinct else qs
+
+
+@cache
+def missing_key_aware_filter_factory(filter_class):
+    """
+    Return a subclass of the given filter class which treats an absent JSON key as equivalent to a
+    null one. Results are cached so that each filter class yields a single stable subclass.
+
+    The class must inherit MultipleChoiceFilter.filter() unmodified: the mixin reimplements it, so a
+    filter() of its own (and with it any custom predicate or short-circuit) would be silently
+    bypassed, yielding a wrong result set rather than an error.
+    """
+    if filter_class.filter is not django_filters.MultipleChoiceFilter.filter:
+        raise TypeError(
+            f"{filter_class.__name__} cannot be made missing-key aware: it defines its own "
+            f"filter(), which MissingKeyAwareFilterMixin would bypass."
+        )
+
+    return type(
+        f'MissingKeyAware{filter_class.__name__}',
+        (MissingKeyAwareFilterMixin, filter_class),
+        {}
+    )
+
+
 class TagFilter(django_filters.ModelMultipleChoiceFilter):
 class TagFilter(django_filters.ModelMultipleChoiceFilter):
     """
     """
     Match on one or more assigned tags. If multiple tags are specified (e.g. ?tag=foo&tag=bar), the queryset is filtered
     Match on one or more assigned tags. If multiple tags are specified (e.g. ?tag=foo&tag=bar), the queryset is filtered

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

@@ -102,7 +102,7 @@ class CustomFieldBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):
             name=_('Validation')
             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):
 class CustomFieldChoiceSetBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):
@@ -300,6 +300,11 @@ class EventRuleBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
         max_length=200,
         max_length=200,
         required=False
         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')
     nullable_fields = ('description', 'conditions')
 
 
@@ -367,7 +372,7 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm)
     fieldsets = (
     fieldsets = (
         FieldSet('weight', 'profile', 'is_active', 'description'),
         FieldSet('weight', 'profile', 'is_active', 'description'),
     )
     )
-    nullable_fields = ('profile', 'description')
+    nullable_fields = ('profile', 'description', 'owner')
 
 
 
 
 class ConfigTemplateBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):
 class ConfigTemplateBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):

+ 11 - 2
netbox/extras/graphql/mixins.py

@@ -4,7 +4,7 @@ import strawberry
 import strawberry_django
 import strawberry_django
 from strawberry.types import Info
 from strawberry.types import Info
 
 
-from extras.models import ImageAttachment, JournalEntry
+from extras.models import CustomField, ImageAttachment, JournalEntry
 from utilities.querysets import RestrictedPrefetch
 from utilities.querysets import RestrictedPrefetch
 
 
 __all__ = (
 __all__ = (
@@ -47,7 +47,16 @@ class CustomFieldsMixin:
 
 
     @strawberry_django.field(only=['custom_field_data'])
     @strawberry_django.field(only=['custom_field_data'])
     def custom_fields(self) -> strawberry.scalars.JSON:
     def custom_fields(self) -> strawberry.scalars.JSON:
-        return self.custom_field_data
+        # Emit a key for every custom field assigned to the model, as the REST API does, rather than
+        # returning the stored data verbatim. A key is materialized only once a value is assigned
+        # (see CustomField.populate_initial_data()), so an object which predates a field carries no
+        # key for it; without this, such a field would be absent from the response instead of null.
+        # CustomFieldManager.get_for_model() is served from the per-request cache, so this costs one
+        # query per model rather than one per object.
+        return {
+            cf.name: self.custom_field_data.get(cf.name)
+            for cf in CustomField.objects.get_for_model(self)
+        }
 
 
 
 
 @strawberry.type
 @strawberry.type

+ 4 - 3
netbox/extras/management/commands/runscript.py

@@ -81,11 +81,11 @@ class Command(BaseCommand):
                     logger.error(f'\t{field}: {error.get("message")}')
                     logger.error(f'\t{field}: {error.get("message")}')
             raise CommandError()
             raise CommandError()
 
 
-        # Remove exec-parameter fields from ScriptForm before passing data to the script.
-        # (Previously missed '_notifications', which leaked into the script's own data.)
+        # Remove exec-parameter fields from ScriptForm before passing data to the script
         cleaned_data = form.cleaned_data.copy()
         cleaned_data = form.cleaned_data.copy()
+        notifications = cleaned_data.pop('_notifications')
         for key in EXEC_PARAM_FIELDS:
         for key in EXEC_PARAM_FIELDS:
-            cleaned_data.pop(key, None)
+            cleaned_data.pop(key, None)    
 
 
         # Execute the script.
         # Execute the script.
         job = ScriptJob.enqueue(
         job = ScriptJob.enqueue(
@@ -93,6 +93,7 @@ class Command(BaseCommand):
             user=user,
             user=user,
             immediate=True,
             immediate=True,
             data=cleaned_data,
             data=cleaned_data,
+            notifications=notifications,
             request=NetBoxFakeRequest({
             request=NetBoxFakeRequest({
                 'META': {},
                 'META': {},
                 'COOKIES': {},
                 'COOKIES': {},

+ 40 - 12
netbox/extras/models/customfields.py

@@ -10,7 +10,6 @@ from django.conf import settings
 from django.core.validators import RegexValidator, ValidationError
 from django.core.validators import RegexValidator, ValidationError
 from django.db import models, transaction
 from django.db import models, transaction
 from django.db.models import F, Func, Value
 from django.db.models import F, Func, Value
-from django.db.models.expressions import RawSQL
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.html import escape
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
@@ -71,10 +70,12 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
         """
         """
         Return all CustomFields assigned to the given model.
         Return all CustomFields assigned to the given model.
         """
         """
-        # Check the request cache before hitting the database
+        # Check the request cache before hitting the database. Test the cached value against None
+        # rather than for truthiness: a model with no custom fields caches an empty QuerySet, which
+        # would otherwise be treated as a miss and re-queried on every call.
         cache = query_cache.get()
         cache = query_cache.get()
         if cache is not None:
         if cache is not None:
-            if custom_fields := cache['custom_fields'].get(model._meta.model):
+            if (custom_fields := cache['custom_fields'].get(model._meta.model)) is not None:
                 return custom_fields
                 return custom_fields
 
 
         content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
         content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
@@ -324,7 +325,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
         return None
         return None
 
 
     @staticmethod
     @staticmethod
-    def _update_object_data(model, **update_kwargs):
+    def _update_object_data(model, filters=None, **update_kwargs):
         """
         """
         Apply an UPDATE to the custom_field_data of every instance of the given model in batches,
         Apply an UPDATE to the custom_field_data of every instance of the given model in batches,
         bounding the number of rows touched by each statement. A single unbounded UPDATE across
         bounding the number of rows touched by each statement. A single unbounded UPDATE across
@@ -336,29 +337,41 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
         a renamed field landing on only some objects) should the loop be interrupted when not
         a renamed field landing on only some objects) should the loop be interrupted when not
         already running inside a request's transaction. Batching avoids the statement timeout
         already running inside a request's transaction. Batching avoids the statement timeout
         regardless, as that limit applies per statement rather than per transaction.
         regardless, as that limit applies per statement rather than per transaction.
+
+        :param filters: Optional dict of ORM filters restricting which rows are updated. Callers
+            which need only to touch rows already holding a given key should pass
+            `{'custom_field_data__has_key': ...}`; because keys are materialized only when a value
+            is actually set (see populate_initial_data()), this typically excludes the bulk of the
+            table.
         """
         """
+        filters = filters or {}
+        queryset = model.objects.filter(**filters)
         with transaction.atomic():
         with transaction.atomic():
             last_pk = 0
             last_pk = 0
             while True:
             while True:
                 pks = list(
                 pks = list(
-                    model.objects.filter(pk__gt=last_pk).order_by('pk')
+                    queryset.filter(pk__gt=last_pk).order_by('pk')
                     .values_list('pk', flat=True)[:CUSTOMFIELD_DATA_BATCH_SIZE]
                     .values_list('pk', flat=True)[:CUSTOMFIELD_DATA_BATCH_SIZE]
                 )
                 )
                 if not pks:
                 if not pks:
                     break
                     break
-                model.objects.filter(pk__in=pks).update(**update_kwargs)
+                queryset.filter(pk__in=pks).update(**update_kwargs)
                 last_pk = pks[-1]
                 last_pk = pks[-1]
 
 
     def populate_initial_data(self, content_types):
     def populate_initial_data(self, content_types):
         """
         """
         Populate initial custom field data upon either a) the creation of a new CustomField, or
         Populate initial custom field data upon either a) the creation of a new CustomField, or
         b) the assignment of an existing CustomField to new object types.
         b) the assignment of an existing CustomField to new object types.
+
+        Only a non-null default is written. A field with no default has no value to record, and an
+        absent key is equivalent to a null one everywhere the data is read (see CustomFieldsMixin),
+        so materializing a JSON null on every object would be a very expensive no-op: on a large
+        table it can outlast the request. Objects without the key simply report no value until one
+        is assigned.
         """
         """
         if self.default is None:
         if self.default is None:
-            # We have to convert None to a JSON null for jsonb_set()
-            value = RawSQL("'null'::jsonb", [])
-        else:
-            value = Value(self.default, models.JSONField())
+            return
+        value = Value(self.default, models.JSONField())
         for ct in content_types:
         for ct in content_types:
             if model := ct.model_class():
             if model := ct.model_class():
                 self._update_object_data(
                 self._update_object_data(
@@ -375,11 +388,16 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
         """
         """
         Delete custom field data which is no longer relevant (either because the CustomField is
         Delete custom field data which is no longer relevant (either because the CustomField is
         no longer assigned to a model, or because it has been deleted).
         no longer assigned to a model, or because it has been deleted).
+
+        Only objects which actually hold a value for the field are rewritten. Because keys are
+        materialized only when a value is set (see populate_initial_data()), this typically
+        excludes the bulk of the table.
         """
         """
         for ct in content_types:
         for ct in content_types:
             if model := ct.model_class():
             if model := ct.model_class():
                 self._update_object_data(
                 self._update_object_data(
                     model,
                     model,
+                    filters={'custom_field_data__has_key': self.name},
                     custom_field_data=F('custom_field_data') - self.name
                     custom_field_data=F('custom_field_data') - self.name
                 )
                 )
 
 
@@ -392,13 +410,15 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
             if model := ct.model_class():
             if model := ct.model_class():
                 self._update_object_data(
                 self._update_object_data(
                     model,
                     model,
+                    filters={'custom_field_data__has_key': old_name},
                     custom_field_data=Func(
                     custom_field_data=Func(
                         F('custom_field_data') - old_name,
                         F('custom_field_data') - old_name,
                         Value([new_name]),
                         Value([new_name]),
                         Func(
                         Func(
                             F('custom_field_data'),
                             F('custom_field_data'),
-                            function='jsonb_extract_path_text',
-                            template=f"to_jsonb(%(expressions)s -> '{old_name}')"
+                            Value(old_name),
+                            function='jsonb_extract_path',
+                            output_field=models.JSONField()
                         ),
                         ),
                         function='jsonb_set')
                         function='jsonb_set')
                 )
                 )
@@ -703,6 +723,9 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
 
 
         :param lookup_expr: Custom lookup expression (optional)
         :param lookup_expr: Custom lookup expression (optional)
         """
         """
+        # Imported locally as extras.filters imports extras.models
+        from extras.filters import missing_key_aware_filter_factory
+
         kwargs = {
         kwargs = {
             'field_name': f'custom_field_data__{self.name}'
             'field_name': f'custom_field_data__{self.name}'
         }
         }
@@ -768,6 +791,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
         else:
         else:
             return None
             return None
 
 
+        # A negated lookup must match objects which carry no key for this field at all; see
+        # MissingKeyAwareFilterMixin. BooleanFilter is never negated, so it is left alone.
+        if not issubclass(filter_class, django_filters.BooleanFilter):
+            filter_class = missing_key_aware_filter_factory(filter_class)
+
         filter_instance = filter_class(**kwargs)
         filter_instance = filter_class(**kwargs)
         filter_instance.custom_field = self
         filter_instance.custom_field = self
 
 

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

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

+ 26 - 2
netbox/extras/models/scripts.py

@@ -3,7 +3,8 @@ import logging
 from functools import cached_property
 from functools import cached_property
 
 
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
-from django.db import models
+from django.contrib.contenttypes.models import ContentType
+from django.db import models, router, transaction
 from django.db.models import Q
 from django.db.models import Q
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
@@ -11,7 +12,7 @@ from django.utils.translation import gettext_lazy as _
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
 from core.models import ManagedFile
 from core.models import ManagedFile
 from extras.utils import is_script
 from extras.utils import is_script
-from netbox.models.features import EventRulesMixin, JobsMixin
+from netbox.models.features import EventRulesMixin, JobsMixin, batch_delete_jobs
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 
 
 from .mixins import PythonModuleMixin
 from .mixins import PythonModuleMixin
@@ -119,6 +120,29 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
     def __str__(self):
     def __str__(self):
         return self.python_name
         return self.python_name
 
 
+    def delete(self, using=None, *args, **kwargs):
+        # Job is imported here rather than at module level to avoid a circular import
+        # (core.models.jobs -> core.signals -> extras.events -> extras.models -> this module).
+        from core.models import Job
+
+        # Deleting a ScriptModule cascades (via the Script.module FK) to its child Scripts, and
+        # Django's collector would materialize every one of those Scripts' Jobs to delete them.
+        # A module's scripts can accumulate thousands of jobs, exhausting memory. Batch-delete
+        # the child Scripts' jobs up front, in a single queryset (no per-script loop), before
+        # delegating to the cascade. The transaction rolls the job deletions back if the parent
+        # delete fails; note it does not cover ManagedFile.delete removing the file from disk,
+        # which happens before the DB delete and is not transactional. See #22812.
+        using = using or router.db_for_write(self.__class__, instance=self)
+        with transaction.atomic(using=using):
+            script_type = ContentType.objects.get_for_model(Script, for_concrete_model=False)
+            child_jobs = Job.objects.using(using).filter(
+                object_type=script_type,
+                object_id__in=self.scripts.values_list('pk', flat=True),
+            )
+            batch_delete_jobs(child_jobs)
+            return super().delete(using, *args, **kwargs)
+    delete.alters_data = True
+
     @property
     @property
     def ordered_scripts(self):
     def ordered_scripts(self):
         script_objects = {s.name: s for s in self.scripts.all()}
         script_objects = {s.name: s for s in self.scripts.all()}

+ 1 - 1
netbox/extras/scripts.py

@@ -331,7 +331,7 @@ class BaseScript:
         self._current_test = None  # Tracks the current test method being run (if any)
         self._current_test = None  # Tracks the current test method being run (if any)
 
 
         # Initiate the log
         # 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
         # Declare the placeholder for the current request
         self.request = None
         self.request = None

+ 24 - 12
netbox/extras/signals.py

@@ -20,20 +20,33 @@ from .utils import run_validators
 #
 #
 
 
 
 
-def handle_cf_added_obj_types(instance, action, pk_set, **kwargs):
+def handle_cf_object_types_changed(instance, action, pk_set, reverse, **kwargs):
     """
     """
-    Handle the population of default/null values when a CustomField is added to one or more ContentTypes.
+    Handle the stored data of a CustomField as it is assigned to or unassigned from object types.
+
+    Only the forward direction is handled: every action below operates on the CustomField, whereas
+    the reverse of this relation (ContentType.custom_fields) reports the ContentType as the sender's
+    instance. Nothing in NetBox assigns object types that way.
     """
     """
-    if action == 'post_add':
-        instance.populate_initial_data(ContentType.objects.filter(pk__in=pk_set))
+    if reverse or action not in ('pre_clear', 'post_add', 'post_remove'):
+        return
 
 
+    if action == 'pre_clear':
+        # clear() unassigns every object type at once. It must be handled before the fact: no
+        # pk_set is reported for a clear, so the assignments have to be read while they still
+        # exist. (Note that set() diffs via remove()/add() by default, so it does not land here.)
+        instance.remove_stale_data(instance.object_types.all())
+        return
 
 
-def handle_cf_removed_obj_types(instance, action, pk_set, **kwargs):
-    """
-    Handle the cleanup of old custom field data when a CustomField is removed from one or more ContentTypes.
-    """
-    if action == 'post_remove':
-        instance.remove_stale_data(ContentType.objects.filter(pk__in=pk_set))
+    object_types = ContentType.objects.filter(pk__in=pk_set)
+
+    if action == 'post_add':
+        # Populate the field's default value (if any) on all existing objects
+        instance.populate_initial_data(object_types)
+
+    else:
+        # Remove the field's stored data from objects to which it no longer applies
+        instance.remove_stale_data(object_types)
 
 
 
 
 def handle_cf_renamed(instance, created, **kwargs):
 def handle_cf_renamed(instance, created, **kwargs):
@@ -53,8 +66,7 @@ def handle_cf_deleted(instance, **kwargs):
 
 
 post_save.connect(handle_cf_renamed, sender=CustomField)
 post_save.connect(handle_cf_renamed, sender=CustomField)
 pre_delete.connect(handle_cf_deleted, sender=CustomField)
 pre_delete.connect(handle_cf_deleted, sender=CustomField)
-m2m_changed.connect(handle_cf_added_obj_types, sender=CustomField.object_types.through)
-m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.object_types.through)
+m2m_changed.connect(handle_cf_object_types_changed, sender=CustomField.object_types.through)
 
 
 
 
 #
 #

+ 7 - 7
netbox/extras/tests/query_counts.json

@@ -4,7 +4,7 @@
   "configcontext:api_list_objects": 22,
   "configcontext:api_list_objects": 22,
   "configcontext:list_objects_with_permission": 16,
   "configcontext:list_objects_with_permission": 16,
   "configcontextprofile:api_list_objects": 12,
   "configcontextprofile:api_list_objects": 12,
-  "configcontextprofile:list_objects_with_permission": 19,
+  "configcontextprofile:list_objects_with_permission": 16,
   "configtemplate:api_list_objects": 10,
   "configtemplate:api_list_objects": 10,
   "configtemplate:list_objects_with_permission": 17,
   "configtemplate:list_objects_with_permission": 17,
   "customfield:api_list_objects": 10,
   "customfield:api_list_objects": 10,
@@ -13,14 +13,14 @@
   "customfieldchoiceset:list_objects_with_permission": 16,
   "customfieldchoiceset:list_objects_with_permission": 16,
   "customlink:api_list_objects": 10,
   "customlink:api_list_objects": 10,
   "customlink:list_objects_with_permission": 18,
   "customlink:list_objects_with_permission": 18,
-  "eventrule:api_list_objects": 16,
-  "eventrule:list_objects_with_permission": 23,
+  "eventrule:api_list_objects": 15,
+  "eventrule:list_objects_with_permission": 20,
   "exporttemplate:api_list_objects": 10,
   "exporttemplate:api_list_objects": 10,
   "exporttemplate:list_objects_with_permission": 19,
   "exporttemplate:list_objects_with_permission": 19,
   "imageattachment:api_list_objects": 11,
   "imageattachment:api_list_objects": 11,
   "imageattachment:list_objects_with_permission": 21,
   "imageattachment:list_objects_with_permission": 21,
-  "journalentry:api_list_objects": 16,
-  "journalentry:list_objects_with_permission": 24,
+  "journalentry:api_list_objects": 15,
+  "journalentry:list_objects_with_permission": 21,
   "notification:api_list_objects": 12,
   "notification:api_list_objects": 12,
   "notificationgroup:api_list_objects": 11,
   "notificationgroup:api_list_objects": 11,
   "notificationgroup:list_objects_with_permission": 18,
   "notificationgroup:list_objects_with_permission": 18,
@@ -32,6 +32,6 @@
   "tag:api_list_objects": 10,
   "tag:api_list_objects": 10,
   "tag:list_objects_with_permission": 18,
   "tag:list_objects_with_permission": 18,
   "taggeditem:api_list_objects": 12,
   "taggeditem:api_list_objects": 12,
-  "webhook:api_list_objects": 13,
-  "webhook:list_objects_with_permission": 20
+  "webhook:api_list_objects": 12,
+  "webhook:list_objects_with_permission": 17
 }
 }

+ 258 - 2
netbox/extras/tests/test_api.py

@@ -8,13 +8,14 @@ from unittest.mock import MagicMock, patch
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.db import IntegrityError
 from django.db import IntegrityError
+from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.timezone import make_aware, now
 from django.utils.timezone import make_aware, now
 from rest_framework import status
 from rest_framework import status
 
 
 from core.choices import JobNotificationChoices, ManagedFileRootPathChoices
 from core.choices import JobNotificationChoices, ManagedFileRootPathChoices
 from core.events import *
 from core.events import *
-from core.models import DataFile, DataSource, ObjectType
+from core.models import DataFile, DataSource, Job, ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Rack, RackRole, Site
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Rack, RackRole, Site
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
@@ -23,7 +24,7 @@ from extras.scripts import Script as PythonClass
 from users.constants import TOKEN_PREFIX
 from users.constants import TOKEN_PREFIX
 from users.models import Group, ObjectPermission, Token, User
 from users.models import Group, ObjectPermission, Token, User
 from utilities.tables import get_table_for_model
 from utilities.tables import get_table_for_model
-from utilities.testing import APITestCase, APIViewTestCases
+from utilities.testing import APITestCase, APIViewTestCases, disable_warnings
 
 
 
 
 class AppTestCase(APITestCase):
 class AppTestCase(APITestCase):
@@ -296,6 +297,36 @@ class CustomFieldChoiceSetTestCase(APIViewTestCases.APIViewTestCase):
         response = self.client.post(self._get_list_url(), data, format='json', **self.header)
         response = self.client.post(self._get_list_url(), data, format='json', **self.header)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
 
 
+    def test_null_base_choices(self):
+        """
+        A null value for base_choices should be accepted, as returned by the API for a choice set which defines
+        only extra choices.
+        """
+        self.add_permissions('extras.add_customfieldchoiceset', 'extras.change_customfieldchoiceset')
+        data = {
+            'name': 'test',
+            'base_choices': None,
+            'extra_choices': [
+                ['choice1', 'Choice 1'],
+            ],
+        }
+
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertIsNone(response.data['base_choices'])
+        choice_set = CustomFieldChoiceSet.objects.get(pk=response.data['id'])
+        self.assertIsNone(choice_set.base_choices)
+
+        # A choice set with base choices assigned can be reverted to null
+        choice_set.base_choices = CustomFieldChoiceSetBaseChoices.IATA
+        choice_set.save()
+        response = self.client.patch(
+            self._get_detail_url(choice_set), {'base_choices': None}, format='json', **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        choice_set.refresh_from_db()
+        self.assertIsNone(choice_set.base_choices)
+
     def test_invalid_choice_color(self):
     def test_invalid_choice_color(self):
         self.add_permissions('extras.add_customfieldchoiceset')
         self.add_permissions('extras.add_customfieldchoiceset')
         data = {
         data = {
@@ -1373,6 +1404,34 @@ class ScriptTestCase(APITestCase):
         self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
         self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
         self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
         self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
 
 
+    def test_list_scripts(self):
+        """
+        The list route is served by BaseViewSet, which resolves the QuerySet's prefetches & annotations (and
+        any fields/omit request parameters) from the serializer.
+        """
+        url = reverse('extras-api:script-list')
+
+        response = self.client.get(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['count'], 1)
+        self.assertEqual(response.data['results'][0]['name'], self.TestScriptClass.Meta.name)
+
+        response = self.client.get(f'{url}?fields=id,name', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(sorted(response.data['results'][0]), ['id', 'name'])
+
+    def test_get_script_by_module_and_name(self):
+        """
+        A script may also be identified by its module & name, e.g. /api/extras/scripts/example.MyReport/.
+        """
+        script = Script.objects.first()
+        url = reverse('extras-api:script-detail', kwargs={'pk': f'script.{script.name}'})
+
+        response = self.client.get(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['id'], script.pk)
+
     def test_schedule_script_past_time_rejected(self):
     def test_schedule_script_past_time_rejected(self):
         """
         """
         Scheduling with past schedule_at should fail.
         Scheduling with past schedule_at should fail.
@@ -1443,6 +1502,203 @@ class ScriptTestCase(APITestCase):
             # Restore the original setting for other tests
             # Restore the original setting for other tests
             self.TestScriptClass.Meta.scheduling_enabled = original
             self.TestScriptClass.Meta.scheduling_enabled = original
 
 
+    def test_run_script_without_permission(self):
+        """
+        A user permitted to view a script but not to run it must not be able to enqueue it. (The script is
+        excluded from the restricted QuerySet, so the request yields a 404.)
+        """
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
+
+        # setUp() grants only extras.view_script
+        with disable_warnings('django.request'):
+            response = self.client.post(self.url, payload, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
+        self.assertFalse(Job.objects.exists())
+
+        # Granting the run permission permits the same request
+        self.add_permissions('extras.run_script')
+        response = self.client.post(self.url, payload, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertTrue(Job.objects.exists())
+
+    @override_settings(LOGIN_REQUIRED=False, EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_run_script_anonymous(self):
+        """
+        An unauthenticated user must be told that running a script is not permitted, rather than that the
+        script does not exist.
+        """
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
+
+        with disable_warnings('django.request'):
+            response = self.client.post(self.url, payload, format='json')
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+        self.assertFalse(Job.objects.exists())
+
+    def test_run_script_read_only_token(self):
+        """
+        Running a script is a write operation and must be rejected for a read-only token.
+        """
+        self.add_permissions('extras.run_script')
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
+
+        # A write-disabled token should be rejected
+        ro_token = Token.objects.create(version=2, user=self.user, write_enabled=False)
+        ro_header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{ro_token.key}.{ro_token.token}'}
+        response = self.client.post(self.url, payload, format='json', **ro_header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
+        # The default (write-enabled) token should succeed
+        response = self.client.post(self.url, payload, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+    def test_run_script_read_only_token_without_permission(self):
+        """
+        A read-only token is rejected before the script is resolved, so an insufficient token is reported as
+        such regardless of the user's permission to run the script.
+        """
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
+
+        # setUp() grants only extras.view_script
+        ro_token = Token.objects.create(version=2, user=self.user, write_enabled=False)
+        ro_header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{ro_token.key}.{ro_token.token}'}
+        response = self.client.post(self.url, payload, format='json', **ro_header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
+    def test_run_script_not_executable(self):
+        """
+        A script whose Python class cannot be resolved must be rejected, not raise an exception.
+        """
+        self.add_permissions('extras.run_script')
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
+
+        # Simulate a script whose class can no longer be found in its module
+        class_patch = patch.object(Script, 'python_class', None)
+        class_patch.start()
+        self.addCleanup(class_patch.stop)
+
+        response = self.client.post(self.url, payload, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertFalse(Job.objects.exists())
+
+    def test_run_script_by_module_and_name(self):
+        """
+        A script identified by its module & name (rather than by its PK) must also be runnable.
+        """
+        self.add_permissions('extras.run_script')
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
+        script = Script.objects.first()
+        url = reverse('extras-api:script-detail', kwargs={'pk': f'script.{script.name}'})
+
+        response = self.client.post(url, payload, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['id'], script.pk)
+        self.assertTrue(Job.objects.exists())
+
+    def test_run_script_format_suffix(self):
+        """
+        The format-suffix variants of the detail route (e.g. /1.json) must dispatch to run().
+        """
+        self.add_permissions('extras.run_script')
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
+        script = Script.objects.first()
+        lookups = (script.pk, f'script.{script.name}')
+
+        for lookup in lookups:
+            with self.subTest(lookup=lookup):
+                url = reverse('extras-api:script-detail', kwargs={'pk': lookup, 'format': 'json'})
+
+                response = self.client.post(url, payload, format='json', **self.header)
+
+                self.assertHttpStatus(response, status.HTTP_200_OK)
+                self.assertEqual(response.data['id'], script.pk)
+
+        self.assertEqual(Job.objects.count(), len(lookups))
+
+    def test_modify_script_methods_disabled(self):
+        """
+        Individual scripts are created, modified, and deleted through their module, so PUT/PATCH/DELETE on
+        the script endpoint are not supported (even for a user holding the corresponding permissions).
+        """
+        self.add_permissions('extras.change_script', 'extras.delete_script')
+        script = Script.objects.first()
+
+        for method in ('put', 'patch', 'delete'):
+            with self.subTest(method=method):
+                with disable_warnings('django.request'):
+                    response = getattr(self.client, method)(self.url, {}, format='json', **self.header)
+                self.assertHttpStatus(response, status.HTTP_405_METHOD_NOT_ALLOWED)
+
+        # The script must remain untouched
+        self.assertTrue(Script.objects.filter(pk=script.pk).exists())
+
+    def test_create_script_disabled(self):
+        """
+        Scripts cannot be created via the API: POST is mapped only on the detail route (to run a script),
+        and must be neither permitted nor advertised on the list route.
+        """
+        self.add_permissions('extras.add_script')
+        list_url = reverse('extras-api:script-list')
+
+        with disable_warnings('django.request'):
+            response = self.client.post(list_url, {}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_405_METHOD_NOT_ALLOWED)
+
+        # OPTIONS must not advertise a create action for the list route
+        response = self.client.options(list_url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertNotIn('POST', response.data.get('actions', {}))
+
+    def test_options_detail_route(self):
+        """
+        POST on the detail route runs a script, so its OPTIONS metadata must describe the run input
+        rather than the Script model's own fields.
+        """
+        self.add_permissions('extras.run_script')
+
+        response = self.client.options(self.url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        post_fields = response.data['actions']['POST']
+        self.assertIn('data', post_fields)
+        self.assertIn('commit', post_fields)
+        self.assertNotIn('module', post_fields)
+        self.assertNotIn('name', post_fields)
+
+    def test_options_detail_route_dynamic_fields(self):
+        """
+        The run input serializer does not support the fields/omit query parameters, but their presence must
+        not break the generation of OPTIONS metadata.
+        """
+        self.add_permissions('extras.run_script')
+
+        for query in ('fields=id', 'omit=id'):
+            with self.subTest(query=query):
+                response = self.client.options(f'{self.url}?{query}', **self.header)
+
+                self.assertHttpStatus(response, status.HTTP_200_OK)
+                self.assertIn('data', response.data['actions']['POST'])
+
+    def test_unsupported_method(self):
+        """
+        A request using an HTTP method which maps to no action must be rejected with a 405.
+        """
+        with disable_warnings('django.request'):
+            response = self.client.trace(self.url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_405_METHOD_NOT_ALLOWED)
+
+    def test_get_script_invalid_pk(self):
+        """
+        A PK which cannot be cast to an integer must yield a 404, not a server error. This covers numeric (but
+        non-decimal) characters, as well as a decimal value too long for Python to convert.
+        """
+        for pk in ('½', '1' * 5000):
+            with self.subTest(pk=pk[:10]):
+                url = reverse('extras-api:script-detail', kwargs={'pk': pk})
+
+                with disable_warnings('django.request'):
+                    response = self.client.get(url, **self.header)
+                self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
+
 
 
 class ScriptRunExecutionTestCase(APITestCase):
 class ScriptRunExecutionTestCase(APITestCase):
     """
     """

+ 52 - 0
netbox/extras/tests/test_api_routers.py

@@ -0,0 +1,52 @@
+from django.test import TestCase
+
+from extras.api.routers import ScriptRouter
+from extras.api.views import CustomFieldChoiceSetViewSet, ScriptViewSet, WebhookViewSet
+
+
+class ScriptRouterTestCase(TestCase):
+    """
+    Verify the routes generated by ScriptRouter.
+    """
+    @staticmethod
+    def get_actions(viewset):
+        """
+        Return a mapping of route name to the HTTP methods bound on it for the given ViewSet.
+        """
+        router = ScriptRouter()
+        router.register('dummy', viewset, basename='dummy')
+
+        return {
+            url.name: url.callback.actions
+            for url in router.urls if hasattr(url.callback, 'actions')
+        }
+
+    def test_script_routes(self):
+        actions = self.get_actions(ScriptViewSet)
+
+        # POST on the detail route runs the script; the list route accepts only GET
+        self.assertEqual(actions['dummy-detail'], {'get': 'retrieve', 'post': 'run'})
+        self.assertEqual(actions['dummy-list'], {'get': 'list'})
+
+    def test_script_viewset_subclass(self):
+        # A subclass of ScriptViewSet (e.g. as registered by a plugin) gets the same route mapping
+        class MyScriptViewSet(ScriptViewSet):
+            pass
+
+        actions = self.get_actions(MyScriptViewSet)
+
+        self.assertEqual(actions['dummy-detail'], {'get': 'retrieve', 'post': 'run'})
+        self.assertEqual(actions['dummy-list'], {'get': 'list'})
+
+    def test_other_viewsets_unaffected(self):
+        # Standard ViewSets keep the stock detail route mapping
+        self.assertNotIn('post', self.get_actions(WebhookViewSet)['dummy-detail'])
+
+        # Routes generated for @action methods are untouched
+        self.assertEqual(self.get_actions(CustomFieldChoiceSetViewSet)['dummy-choices'], {'get': 'choices'})
+
+    def test_route_templates_not_mutated(self):
+        router = ScriptRouter()
+        router.get_routes(ScriptViewSet)
+
+        self.assertNotIn('post', router.routes[2].mapping)

+ 517 - 41
netbox/extras/tests/test_customfields.py

@@ -1,10 +1,15 @@
 import datetime
 import datetime
 import json
 import json
+from collections import defaultdict
 from decimal import Decimal
 from decimal import Decimal
 from unittest.mock import patch
 from unittest.mock import patch
 
 
+import django_filters
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
+from django.db import connection
+from django.db.models import QuerySet
 from django.test import tag
 from django.test import tag
+from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
 from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 
 
@@ -12,10 +17,14 @@ from core.models import ObjectChange, ObjectType
 from dcim.filtersets import SiteFilterSet
 from dcim.filtersets import SiteFilterSet
 from dcim.forms import SiteImportForm
 from dcim.forms import SiteImportForm
 from dcim.models import Manufacturer, Rack, Site
 from dcim.models import Manufacturer, Rack, Site
+from dcim.tables import SiteTable
 from extras.choices import *
 from extras.choices import *
+from extras.filters import MissingKeyAwareFilterMixin, missing_key_aware_filter_factory
 from extras.models import CustomField, CustomFieldChoiceSet
 from extras.models import CustomField, CustomFieldChoiceSet
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
+from netbox.context import query_cache
+from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter
 from utilities.testing import APITestCase, TestCase
 from utilities.testing import APITestCase, TestCase
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 
 
@@ -47,7 +56,7 @@ class CustomFieldTestCase(TestCase):
     def test_text_field(self):
     def test_text_field(self):
         value = 'Foobar!'
         value = 'Foobar!'
 
 
-        # Create a custom field & check that initial value is null
+        # Create a custom field & check that no initial data is written
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='text_field',
             name='text_field',
             type=CustomFieldTypeChoices.TYPE_TEXT,
             type=CustomFieldTypeChoices.TYPE_TEXT,
@@ -55,7 +64,7 @@ class CustomFieldTestCase(TestCase):
         )
         )
         cf.object_types.set([self.object_type])
         cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
-        self.assertIsNone(instance.custom_field_data[cf.name])
+        self.assertNotIn(cf.name, instance.custom_field_data)
 
 
         # Assign a value and check that it is saved
         # Assign a value and check that it is saved
         instance.custom_field_data[cf.name] = value
         instance.custom_field_data[cf.name] = value
@@ -72,7 +81,7 @@ class CustomFieldTestCase(TestCase):
     def test_longtext_field(self):
     def test_longtext_field(self):
         value = 'A' * 256
         value = 'A' * 256
 
 
-        # Create a custom field & check that initial value is null
+        # Create a custom field & check that no initial data is written
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='longtext_field',
             name='longtext_field',
             type=CustomFieldTypeChoices.TYPE_LONGTEXT,
             type=CustomFieldTypeChoices.TYPE_LONGTEXT,
@@ -80,7 +89,7 @@ class CustomFieldTestCase(TestCase):
         )
         )
         cf.object_types.set([self.object_type])
         cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
-        self.assertIsNone(instance.custom_field_data[cf.name])
+        self.assertNotIn(cf.name, instance.custom_field_data)
 
 
         # Assign a value and check that it is saved
         # Assign a value and check that it is saved
         instance.custom_field_data[cf.name] = value
         instance.custom_field_data[cf.name] = value
@@ -96,7 +105,7 @@ class CustomFieldTestCase(TestCase):
 
 
     def test_integer_field(self):
     def test_integer_field(self):
 
 
-        # Create a custom field & check that initial value is null
+        # Create a custom field & check that no initial data is written
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='integer_field',
             name='integer_field',
             type=CustomFieldTypeChoices.TYPE_INTEGER,
             type=CustomFieldTypeChoices.TYPE_INTEGER,
@@ -104,7 +113,7 @@ class CustomFieldTestCase(TestCase):
         )
         )
         cf.object_types.set([self.object_type])
         cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
-        self.assertIsNone(instance.custom_field_data[cf.name])
+        self.assertNotIn(cf.name, instance.custom_field_data)
 
 
         for value in (123456, 0, -123456):
         for value in (123456, 0, -123456):
 
 
@@ -122,7 +131,7 @@ class CustomFieldTestCase(TestCase):
 
 
     def test_decimal_field(self):
     def test_decimal_field(self):
 
 
-        # Create a custom field & check that initial value is null
+        # Create a custom field & check that no initial data is written
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='decimal_field',
             name='decimal_field',
             type=CustomFieldTypeChoices.TYPE_DECIMAL,
             type=CustomFieldTypeChoices.TYPE_DECIMAL,
@@ -130,7 +139,7 @@ class CustomFieldTestCase(TestCase):
         )
         )
         cf.object_types.set([self.object_type])
         cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
-        self.assertIsNone(instance.custom_field_data[cf.name])
+        self.assertNotIn(cf.name, instance.custom_field_data)
 
 
         for value in (123456.54, 0, -123456.78):
         for value in (123456.54, 0, -123456.78):
 
 
@@ -148,7 +157,7 @@ class CustomFieldTestCase(TestCase):
 
 
     def test_boolean_field(self):
     def test_boolean_field(self):
 
 
-        # Create a custom field & check that initial value is null
+        # Create a custom field & check that no initial data is written
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='boolean_field',
             name='boolean_field',
             type=CustomFieldTypeChoices.TYPE_INTEGER,
             type=CustomFieldTypeChoices.TYPE_INTEGER,
@@ -156,7 +165,7 @@ class CustomFieldTestCase(TestCase):
         )
         )
         cf.object_types.set([self.object_type])
         cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
-        self.assertIsNone(instance.custom_field_data[cf.name])
+        self.assertNotIn(cf.name, instance.custom_field_data)
 
 
         for value in (True, False):
         for value in (True, False):
 
 
@@ -175,7 +184,7 @@ class CustomFieldTestCase(TestCase):
     def test_date_field(self):
     def test_date_field(self):
         value = datetime.date(2016, 6, 23)
         value = datetime.date(2016, 6, 23)
 
 
-        # Create a custom field & check that initial value is null
+        # Create a custom field & check that no initial data is written
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='date_field',
             name='date_field',
             type=CustomFieldTypeChoices.TYPE_DATE,
             type=CustomFieldTypeChoices.TYPE_DATE,
@@ -183,7 +192,7 @@ class CustomFieldTestCase(TestCase):
         )
         )
         cf.object_types.set([self.object_type])
         cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
-        self.assertIsNone(instance.custom_field_data[cf.name])
+        self.assertNotIn(cf.name, instance.custom_field_data)
 
 
         # Assign a value and check that it is saved
         # Assign a value and check that it is saved
         instance.custom_field_data[cf.name] = cf.serialize(value)
         instance.custom_field_data[cf.name] = cf.serialize(value)
@@ -200,7 +209,7 @@ class CustomFieldTestCase(TestCase):
     def test_datetime_field(self):
     def test_datetime_field(self):
         value = datetime.datetime(2016, 6, 23, 9, 45, 0)
         value = datetime.datetime(2016, 6, 23, 9, 45, 0)
 
 
-        # Create a custom field & check that initial value is null
+        # Create a custom field & check that no initial data is written
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='date_field',
             name='date_field',
             type=CustomFieldTypeChoices.TYPE_DATETIME,
             type=CustomFieldTypeChoices.TYPE_DATETIME,
@@ -208,7 +217,7 @@ class CustomFieldTestCase(TestCase):
         )
         )
         cf.object_types.set([self.object_type])
         cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
-        self.assertIsNone(instance.custom_field_data[cf.name])
+        self.assertNotIn(cf.name, instance.custom_field_data)
 
 
         # Assign a value and check that it is saved
         # Assign a value and check that it is saved
         instance.custom_field_data[cf.name] = cf.serialize(value)
         instance.custom_field_data[cf.name] = cf.serialize(value)
@@ -225,7 +234,7 @@ class CustomFieldTestCase(TestCase):
     def test_url_field(self):
     def test_url_field(self):
         value = 'http://example.com/'
         value = 'http://example.com/'
 
 
-        # Create a custom field & check that initial value is null
+        # Create a custom field & check that no initial data is written
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='url_field',
             name='url_field',
             type=CustomFieldTypeChoices.TYPE_URL,
             type=CustomFieldTypeChoices.TYPE_URL,
@@ -233,7 +242,7 @@ class CustomFieldTestCase(TestCase):
         )
         )
         cf.object_types.set([self.object_type])
         cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
-        self.assertIsNone(instance.custom_field_data[cf.name])
+        self.assertNotIn(cf.name, instance.custom_field_data)
 
 
         # Assign a value and check that it is saved
         # Assign a value and check that it is saved
         instance.custom_field_data[cf.name] = value
         instance.custom_field_data[cf.name] = value
@@ -250,7 +259,7 @@ class CustomFieldTestCase(TestCase):
     def test_json_field(self):
     def test_json_field(self):
         value = '{"foo": 1, "bar": 2}'
         value = '{"foo": 1, "bar": 2}'
 
 
-        # Create a custom field & check that initial value is null
+        # Create a custom field & check that no initial data is written
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='json_field',
             name='json_field',
             type=CustomFieldTypeChoices.TYPE_JSON,
             type=CustomFieldTypeChoices.TYPE_JSON,
@@ -258,7 +267,7 @@ class CustomFieldTestCase(TestCase):
         )
         )
         cf.object_types.set([self.object_type])
         cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
-        self.assertIsNone(instance.custom_field_data[cf.name])
+        self.assertNotIn(cf.name, instance.custom_field_data)
 
 
         # Assign a value and check that it is saved
         # Assign a value and check that it is saved
         instance.custom_field_data[cf.name] = value
         instance.custom_field_data[cf.name] = value
@@ -340,7 +349,7 @@ class CustomFieldTestCase(TestCase):
             extra_choices=CHOICES
             extra_choices=CHOICES
         )
         )
 
 
-        # Create a custom field & check that initial value is null
+        # Create a custom field & check that no initial data is written
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='select_field',
             name='select_field',
             type=CustomFieldTypeChoices.TYPE_SELECT,
             type=CustomFieldTypeChoices.TYPE_SELECT,
@@ -349,7 +358,7 @@ class CustomFieldTestCase(TestCase):
         )
         )
         cf.object_types.set([self.object_type])
         cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
-        self.assertIsNone(instance.custom_field_data[cf.name])
+        self.assertNotIn(cf.name, instance.custom_field_data)
 
 
         # Assign a value and check that it is saved
         # Assign a value and check that it is saved
         instance.custom_field_data[cf.name] = value
         instance.custom_field_data[cf.name] = value
@@ -377,7 +386,7 @@ class CustomFieldTestCase(TestCase):
             extra_choices=CHOICES
             extra_choices=CHOICES
         )
         )
 
 
-        # Create a custom field & check that initial value is null
+        # Create a custom field & check that no initial data is written
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='multiselect_field',
             name='multiselect_field',
             type=CustomFieldTypeChoices.TYPE_MULTISELECT,
             type=CustomFieldTypeChoices.TYPE_MULTISELECT,
@@ -386,7 +395,7 @@ class CustomFieldTestCase(TestCase):
         )
         )
         cf.object_types.set([self.object_type])
         cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
-        self.assertIsNone(instance.custom_field_data[cf.name])
+        self.assertNotIn(cf.name, instance.custom_field_data)
 
 
         # Assign a value and check that it is saved
         # Assign a value and check that it is saved
         instance.custom_field_data[cf.name] = value
         instance.custom_field_data[cf.name] = value
@@ -547,7 +556,7 @@ class CustomFieldTestCase(TestCase):
     def test_object_field(self):
     def test_object_field(self):
         value = VLAN.objects.create(name='VLAN 1', vid=1).pk
         value = VLAN.objects.create(name='VLAN 1', vid=1).pk
 
 
-        # Create a custom field & check that initial value is null
+        # Create a custom field & check that no initial data is written
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='object_field',
             name='object_field',
             type=CustomFieldTypeChoices.TYPE_OBJECT,
             type=CustomFieldTypeChoices.TYPE_OBJECT,
@@ -556,7 +565,7 @@ class CustomFieldTestCase(TestCase):
         )
         )
         cf.object_types.set([self.object_type])
         cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
-        self.assertIsNone(instance.custom_field_data[cf.name])
+        self.assertNotIn(cf.name, instance.custom_field_data)
 
 
         # Assign a value and check that it is saved
         # Assign a value and check that it is saved
         instance.custom_field_data[cf.name] = value
         instance.custom_field_data[cf.name] = value
@@ -579,7 +588,7 @@ class CustomFieldTestCase(TestCase):
         VLAN.objects.bulk_create(vlans)
         VLAN.objects.bulk_create(vlans)
         value = [vlan.pk for vlan in vlans]
         value = [vlan.pk for vlan in vlans]
 
 
-        # Create a custom field & check that initial value is null
+        # Create a custom field & check that no initial data is written
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='object_field',
             name='object_field',
             type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
             type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
@@ -588,7 +597,7 @@ class CustomFieldTestCase(TestCase):
         )
         )
         cf.object_types.set([self.object_type])
         cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
-        self.assertIsNone(instance.custom_field_data[cf.name])
+        self.assertNotIn(cf.name, instance.custom_field_data)
 
 
         # Assign a value and check that it is saved
         # Assign a value and check that it is saved
         instance.custom_field_data[cf.name] = value
         instance.custom_field_data[cf.name] = value
@@ -663,13 +672,302 @@ class CustomFieldTestCase(TestCase):
             0
             0
         )
         )
 
 
-        # Removal: the key is stripped from every existing object when the field is deleted
+        # Removal: deleting the field strips the key from every existing object
         cf.delete()
         cf.delete()
         self.assertEqual(
         self.assertEqual(
             Site.objects.filter(custom_field_data__has_key='renamed_field').count(),
             Site.objects.filter(custom_field_data__has_key='renamed_field').count(),
             0
             0
         )
         )
 
 
+    def test_provisioning_writes_nothing_without_a_default(self):
+        """
+        A field with no default has no value to record, so creating one must not touch any object.
+        """
+        cf = CustomField.objects.create(
+            name='unset_field',
+            type=CustomFieldTypeChoices.TYPE_TEXT
+        )
+
+        with CaptureQueriesContext(connection) as queries:
+            cf.object_types.set([self.object_type])
+
+        # No object data is written at all -- the cost of adding a field no longer scales with the
+        # number of objects it applies to
+        self.assertFalse([
+            q['sql'] for q in queries.captured_queries
+            if q['sql'].lstrip().upper().startswith('UPDATE "DCIM_SITE"'.upper())
+        ])
+
+        self.assertEqual(Site.objects.filter(custom_field_data__has_key='unset_field').count(), 0)
+        for site in Site.objects.all():
+            self.assertEqual(site.custom_field_data, {})
+            self.assertIsNone(site.cf['unset_field'])
+
+    def test_provisioning_applies_a_default_immediately(self):
+        """
+        A default value, by contrast, must be recorded on every existing object as soon as the
+        field is created -- it has to be filterable straight away, so it cannot be deferred.
+        """
+        cf = CustomField.objects.create(
+            name='defaulted_field',
+            type=CustomFieldTypeChoices.TYPE_TEXT,
+            default='bar'
+        )
+        cf.object_types.set([self.object_type])
+
+        self.assertEqual(
+            Site.objects.filter(custom_field_data__defaulted_field='bar').count(),
+            Site.objects.count()
+        )
+
+    def test_rename_touches_only_objects_holding_a_value(self):
+        """
+        Renaming rewrites the key only where a value is actually stored. This is what keeps a
+        rename cheap now that objects are no longer provisioned with a placeholder each.
+        """
+        cf = CustomField.objects.create(
+            name='sparse_field',
+            type=CustomFieldTypeChoices.TYPE_TEXT
+        )
+        cf.object_types.set([self.object_type])
+
+        site = Site.objects.first()
+        site.custom_field_data['sparse_field'] = 'value'
+        site.save()
+
+        cf.name = 'sparse_renamed'
+        cf.save()
+
+        self.assertEqual(
+            list(
+                Site.objects.filter(custom_field_data__has_key='sparse_renamed')
+                .values_list('pk', flat=True)
+            ),
+            [site.pk]
+        )
+        self.assertEqual(Site.objects.filter(custom_field_data__has_key='sparse_field').count(), 0)
+        site.refresh_from_db()
+        self.assertEqual(site.custom_field_data['sparse_renamed'], 'value')
+
+    def test_removal_from_object_type_purges_data(self):
+        """
+        Unassigning a field from an object type removes its data from those objects.
+        """
+        cf = CustomField.objects.create(
+            name='unassigned_field',
+            type=CustomFieldTypeChoices.TYPE_TEXT,
+            default='baz'
+        )
+        cf.object_types.set([self.object_type])
+        self.assertEqual(
+            Site.objects.filter(custom_field_data__has_key='unassigned_field').count(),
+            Site.objects.count()
+        )
+
+        cf.object_types.remove(self.object_type)
+
+        self.assertEqual(
+            Site.objects.filter(custom_field_data__has_key='unassigned_field').count(),
+            0
+        )
+
+    def test_clearing_object_types_purges_data(self):
+        """
+        clear() unassigns every object type at once and reports no pk_set, so it must be handled
+        before the fact. Its data is removed just as remove()'s is.
+        """
+        cf = CustomField.objects.create(
+            name='cleared_field',
+            type=CustomFieldTypeChoices.TYPE_TEXT,
+            default='baz'
+        )
+        cf.object_types.set([self.object_type])
+        self.assertEqual(
+            Site.objects.filter(custom_field_data__has_key='cleared_field').count(),
+            Site.objects.count()
+        )
+
+        cf.object_types.clear()
+
+        self.assertEqual(
+            Site.objects.filter(custom_field_data__has_key='cleared_field').count(),
+            0
+        )
+
+    def test_batch_update_excludes_rows_which_no_longer_match(self):
+        """
+        A caller's filters must constrain the UPDATE as well as the selection of each batch.
+        rename_object_data() builds a jsonb_set() expression which evaluates to NULL for a row not
+        holding the key being renamed, so a row which loses it between the two statements would
+        otherwise have its entire custom_field_data column nulled out.
+        """
+        cf = CustomField.objects.create(
+            name='drifting_field',
+            type=CustomFieldTypeChoices.TYPE_TEXT
+        )
+        cf.object_types.set([self.object_type])
+
+        sites = list(Site.objects.order_by('pk'))
+        holder, bystander = sites[0], sites[-1]
+        Site.objects.filter(pk=holder.pk).update(custom_field_data={'drifting_field': 'value'})
+        Site.objects.filter(pk=bystander.pk).update(custom_field_data={'other': 'untouched'})
+
+        # Simulate a concurrent write: the batch selection yields a pk which no longer satisfies
+        # the has_key filter by the time the UPDATE is issued.
+        select_pks = QuerySet.values_list
+        injected = []
+
+        def inject_stale_pk(self, *args, **kwargs):
+            result = select_pks(self, *args, **kwargs)
+            if self.model is Site and args == ('pk',) and kwargs.get('flat') and not injected:
+                injected.append(bystander.pk)
+                return [*result, bystander.pk]
+            return result
+
+        with patch.object(QuerySet, 'values_list', inject_stale_pk):
+            cf.name = 'drifted_field'
+            cf.save()
+
+        self.assertEqual(injected, [bystander.pk], "the stale pk was never injected")
+
+        # The renamed value landed, and the bystander was left entirely alone
+        holder.refresh_from_db()
+        self.assertEqual(holder.custom_field_data, {'drifted_field': 'value'})
+        bystander.refresh_from_db()
+        self.assertEqual(bystander.custom_field_data, {'other': 'untouched'})
+
+    @staticmethod
+    def order_sites_by(*aliases):
+        """
+        Order a SiteTable by the given column aliases and return the underlying QuerySet.
+        """
+        table = SiteTable(Site.objects.all())
+        table.order_by = aliases
+        return table.data.data
+
+    def test_table_ordering_groups_objects_with_no_value(self):
+        """
+        Objects holding no value sort together regardless of whether they store a JSON null or
+        carry no key at all, and numeric fields still sort numerically rather than lexically.
+        """
+        cf = CustomField.objects.create(
+            name='sort_field',
+            type=CustomFieldTypeChoices.TYPE_INTEGER
+        )
+        cf.object_types.set([self.object_type])
+
+        sites = list(Site.objects.order_by('name'))
+        # Site A holds a value, Site B an explicit null, Site C no key whatsoever
+        Site.objects.filter(pk=sites[0].pk).update(custom_field_data={'sort_field': 20})
+        Site.objects.filter(pk=sites[1].pk).update(custom_field_data={'sort_field': None})
+        Site.objects.filter(pk=sites[2].pk).update(custom_field_data={})
+        extra = Site.objects.create(
+            name='Site D', slug='site-d', custom_field_data={'sort_field': 100}
+        )
+
+        ordered = self.order_sites_by('cf_sort_field')
+        self.assertEqual(
+            [s.pk for s in ordered][:2],
+            [sites[0].pk, extra.pk],
+            "20 must sort before 100 (numerically, not lexically) ahead of the empty rows"
+        )
+        self.assertEqual(
+            {s.pk for s in ordered[2:]},
+            {sites[1].pk, sites[2].pk},
+            "the JSON-null and missing-key rows must group together at the end"
+        )
+
+        # Reversing the ordering carries the empty rows to the front, as it would SQL nulls
+        ordered = self.order_sites_by('-cf_sort_field')
+        self.assertEqual(
+            {s.pk for s in ordered[:2]},
+            {sites[1].pk, sites[2].pk},
+            "the JSON-null and missing-key rows must still group together"
+        )
+        self.assertEqual([s.pk for s in ordered[2:]], [extra.pk, sites[0].pk])
+
+    def test_table_ordering_breaks_ties_by_primary_key(self):
+        """
+        Rows tying on the sort value -- every object holding no value ties on both sort keys --
+        must still be totally ordered, or paginated results may skip or repeat rows between
+        page requests.
+        """
+        cf = CustomField.objects.create(
+            name='sort_field',
+            type=CustomFieldTypeChoices.TYPE_INTEGER
+        )
+        cf.object_types.set([self.object_type])
+
+        # None of these hold a value for the field, so all of them tie
+        Site.objects.bulk_create([
+            Site(name=f'Tied Site {i}', slug=f'tied-site-{i}') for i in range(1, 11)
+        ])
+
+        for alias in ('cf_sort_field', '-cf_sort_field'):
+            ordered = self.order_sites_by(alias)
+            self.assertEqual(
+                ordered.query.order_by[-1],
+                'pk',
+                "the primary key must be applied as the final sort key"
+            )
+
+            # Paging through the results must yield each object exactly once
+            expected = [site.pk for site in ordered]
+            paginated = []
+            for offset in range(0, len(expected), 4):
+                paginated.extend(site.pk for site in ordered[offset:offset + 4])
+            self.assertEqual(paginated, expected)
+
+    def test_table_ordering_composes_with_other_columns(self):
+        """
+        A custom field column must contribute its sort keys to a multi-column ordering rather than
+        replace it. (The sort parameter is read with getlist(), and a saved TableConfig records an
+        ordering of arbitrary length.)
+        """
+        cf = CustomField.objects.create(
+            name='sort_field',
+            type=CustomFieldTypeChoices.TYPE_INTEGER
+        )
+        cf.object_types.set([self.object_type])
+
+        sites = list(Site.objects.order_by('name'))
+        # Ordering by the custom field alone would reverse the first two sites
+        Site.objects.filter(pk=sites[0].pk).update(custom_field_data={'sort_field': 2})
+        Site.objects.filter(pk=sites[1].pk).update(custom_field_data={'sort_field': 1})
+        Site.objects.filter(pk=sites[2].pk).update(custom_field_data={'sort_field': 3})
+
+        ordered = self.order_sites_by('name', 'cf_sort_field')
+        self.assertEqual(
+            [site.pk for site in ordered],
+            [site.pk for site in sites],
+            "the preceding sort key must survive the addition of a custom field column"
+        )
+
+        # A column named after the custom field column must likewise still apply
+        Site.objects.update(custom_field_data={'sort_field': 1})
+        ordered = self.order_sites_by('cf_sort_field', '-name')
+        self.assertEqual(
+            [site.pk for site in ordered],
+            [site.pk for site in reversed(sites)],
+            "the trailing sort key must be applied before the primary key tie breaker"
+        )
+
+    def test_table_ordering_tolerates_a_repeated_sort_alias(self):
+        """
+        The sort parameter is read with getlist(), so the same custom field column can appear in
+        the ordering more than once, applying the same annotation to the queryset twice.
+        """
+        cf = CustomField.objects.create(
+            name='sort_field',
+            type=CustomFieldTypeChoices.TYPE_INTEGER
+        )
+        cf.object_types.set([self.object_type])
+
+        table = SiteTable(Site.objects.all())
+        table.order_by = ['cf_sort_field', '-cf_sort_field']
+
+        self.assertEqual(len(list(table.rows)), Site.objects.count())
+
     def test_default_value_validation(self):
     def test_default_value_validation(self):
         choiceset = CustomFieldChoiceSet.objects.create(
         choiceset = CustomFieldChoiceSet.objects.create(
             name="Test Choice Set",
             name="Test Choice Set",
@@ -826,6 +1124,22 @@ class CustomFieldManagerTestCase(TestCase):
         self.assertEqual(CustomField.objects.get_for_model(Site).count(), 1)
         self.assertEqual(CustomField.objects.get_for_model(Site).count(), 1)
         self.assertEqual(CustomField.objects.get_for_model(VirtualMachine).count(), 0)
         self.assertEqual(CustomField.objects.get_for_model(VirtualMachine).count(), 0)
 
 
+    def test_get_for_model_caches_models_with_no_custom_fields(self):
+        """
+        A model with no custom fields assigned must be served from the request cache like any other.
+        An empty QuerySet is falsy, so testing the cached value for truthiness would treat it as a
+        miss and re-query on every call.
+        """
+        token = query_cache.set(defaultdict(dict))
+        self.addCleanup(query_cache.reset, token)
+
+        # Site has one custom field assigned, VirtualMachine none
+        for model in (Site, VirtualMachine):
+            # Prime the cache, iterating so that the QuerySet's own result cache is populated too
+            list(CustomField.objects.get_for_model(model))
+            with self.assertNumQueries(0):
+                list(CustomField.objects.get_for_model(model))
+
 
 
 class CustomFieldAPITestCase(APITestCase):
 class CustomFieldAPITestCase(APITestCase):
 
 
@@ -1805,6 +2119,77 @@ class CustomFieldModelTestCase(TestCase):
         site.custom_field_data['baz'] = 'def'
         site.custom_field_data['baz'] = 'def'
         site.clean()
         site.clean()
 
 
+    def test_required_field_enforced_on_existing_objects(self):
+        """
+        Adding a required custom field invalidates the objects which already exist, whether they
+        carry no key for it -- the normal state now that empty values are not provisioned -- or an
+        explicit null. Both are rejected, as they were before: every object then held a materialized
+        null, which CustomField.validate() rejects for a required field.
+        """
+        site = Site.objects.create(name='Test Site', slug='test-site')
+
+        cf = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='req', required=True)
+        cf.save()
+        cf.object_types.set([ObjectType.objects.get_for_model(Site)])
+
+        # No value was provisioned onto the existing object
+        site.refresh_from_db()
+        self.assertNotIn('req', site.custom_field_data)
+        with self.assertRaises(ValidationError):
+            site.clean()
+
+        # An explicit null is rejected identically
+        site.custom_field_data['req'] = None
+        with self.assertRaises(ValidationError):
+            site.clean()
+
+        site.custom_field_data['req'] = 'value'
+        site.clean()
+
+
+class MissingKeyAwareFilterTestCase(TestCase):
+    """
+    MissingKeyAwareFilterMixin reimplements MultipleChoiceFilter.filter() for the negated case, so
+    it may only be mixed into a class which inherits that method unmodified and which does not
+    filter conjoined. Both constraints are enforced, as violating either would yield a wrong result
+    set rather than an error.
+    """
+    def test_factory_rejects_a_class_which_defines_filter(self):
+        # MultiValueMACAddressFilter overrides filter() to swallow ValidationError
+        with self.assertRaises(TypeError):
+            missing_key_aware_filter_factory(MultiValueMACAddressFilter)
+
+        # BooleanFilter does not inherit MultipleChoiceFilter.filter() at all
+        with self.assertRaises(TypeError):
+            missing_key_aware_filter_factory(django_filters.BooleanFilter)
+
+    def test_factory_accepts_a_class_which_inherits_filter(self):
+        filter_class = missing_key_aware_filter_factory(MultiValueCharFilter)
+
+        self.assertTrue(issubclass(filter_class, MissingKeyAwareFilterMixin))
+        self.assertTrue(issubclass(filter_class, MultiValueCharFilter))
+        # The factory is cached, so a class yields a single stable subclass
+        self.assertIs(filter_class, missing_key_aware_filter_factory(MultiValueCharFilter))
+
+    def test_conjoined_filtering_is_rejected(self):
+        filter_class = missing_key_aware_filter_factory(MultiValueCharFilter)
+
+        filter_class(field_name='custom_field_data__foo')
+        filter_class(field_name='custom_field_data__foo', conjoined=False)
+        with self.assertRaises(TypeError):
+            filter_class(field_name='custom_field_data__foo', conjoined=True)
+
+    def test_every_supported_custom_field_type_satisfies_the_constraints(self):
+        """
+        The filter classes CustomField.to_filter() selects must all remain admissible.
+        """
+        for cf_type in CustomFieldTypeChoices.values():
+            with self.subTest(cf_type):
+                cf = CustomField(name='test', type=cf_type)
+                # Raises TypeError if the selected filter class violates a constraint
+                cf.to_filter()
+                cf.to_filter(lookup_expr='empty')
+
 
 
 class CustomFieldModelFilterTestCase(TestCase):
 class CustomFieldModelFilterTestCase(TestCase):
     queryset = Site.objects.all()
     queryset = Site.objects.all()
@@ -1961,12 +2346,14 @@ class CustomFieldModelFilterTestCase(TestCase):
                 'cf11': manufacturers[2].pk,
                 'cf11': manufacturers[2].pk,
                 'cf12': [manufacturers[2].pk, manufacturers[3].pk],
                 'cf12': [manufacturers[2].pk, manufacturers[3].pk],
             }),
             }),
+            # Carries no custom field data at all. Negated lookups ("is not x") match it, as they
+            # do an object holding an explicit null; see MissingKeyAwareFilterMixin.
             Site(name='Site 4', slug='site-4'),
             Site(name='Site 4', slug='site-4'),
         ])
         ])
 
 
     def test_filter_integer(self):
     def test_filter_integer(self):
         self.assertEqual(self.filterset({'cf_cf1': [100, 200]}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf1': [100, 200]}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf1__n': [200]}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf1__n': [200]}, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset({'cf_cf1__gt': [200]}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf1__gt': [200]}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf1__gte': [200]}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf1__gte': [200]}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
@@ -1975,7 +2362,7 @@ class CustomFieldModelFilterTestCase(TestCase):
 
 
     def test_filter_decimal(self):
     def test_filter_decimal(self):
         self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf2__n': [200.2]}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf2__n': [200.2]}, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset({'cf_cf2__gt': [200.2]}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf2__gt': [200.2]}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1)
@@ -1988,15 +2375,15 @@ class CustomFieldModelFilterTestCase(TestCase):
 
 
     def test_filter_text_strict(self):
     def test_filter_text_strict(self):
         self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 1)
-        self.assertEqual(self.filterset({'cf_cf4__n': ['foo']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf4__n': ['foo']}, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset({'cf_cf4__ic': ['foo']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf4__ic': ['foo']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf4__nic': ['foo']}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf4__nic': ['foo']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf4__isw': ['foo']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf4__isw': ['foo']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf4__nisw': ['foo']}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf4__nisw': ['foo']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf4__iew': ['bar']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf4__iew': ['bar']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1)
-        self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset({'cf_cf4__empty': True}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf4__empty': True}, self.queryset).qs.count(), 1)
 
 
     def test_filter_text_loose(self):
     def test_filter_text_loose(self):
@@ -2004,7 +2391,7 @@ class CustomFieldModelFilterTestCase(TestCase):
 
 
     def test_filter_date(self):
     def test_filter_date(self):
         self.assertEqual(self.filterset({'cf_cf6': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf6': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf6__n': ['2016-06-27']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf6__n': ['2016-06-27']}, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset({'cf_cf6__gt': ['2016-06-27']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf6__gt': ['2016-06-27']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
@@ -2016,20 +2403,108 @@ class CustomFieldModelFilterTestCase(TestCase):
             self.filterset({'cf_cf7': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(),
             self.filterset({'cf_cf7': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(),
             2
             2
         )
         )
-        self.assertEqual(self.filterset({'cf_cf7__n': ['http://b.example.com']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf7__n': ['http://b.example.com']}, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset({'cf_cf7__ic': ['b']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf7__ic': ['b']}, self.queryset).qs.count(), 1)
-        self.assertEqual(self.filterset({'cf_cf7__nic': ['b']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf7__nic': ['b']}, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset({'cf_cf7__isw': ['http://']}, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset({'cf_cf7__isw': ['http://']}, self.queryset).qs.count(), 3)
-        self.assertEqual(self.filterset({'cf_cf7__nisw': ['http://']}, self.queryset).qs.count(), 0)
+        self.assertEqual(self.filterset({'cf_cf7__nisw': ['http://']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf7__iew': ['.com']}, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset({'cf_cf7__iew': ['.com']}, self.queryset).qs.count(), 3)
-        self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0)
+        self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
-        self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset({'cf_cf7__empty': True}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf7__empty': True}, self.queryset).qs.count(), 1)
 
 
     def test_filter_url_loose(self):
     def test_filter_url_loose(self):
         self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
 
 
+    def test_filter_negation_matches_unset_values(self):
+        """
+        A negated lookup must match an object which holds no value for the field, whether that is
+        recorded as an explicit null or by the absence of the key; see MissingKeyAwareFilterMixin.
+        """
+        no_key = Site.objects.get(slug='site-4')
+        explicit_null = Site.objects.create(name='Site 5', slug='site-5', custom_field_data={
+            'cf1': None,
+            'cf4': None,
+            'cf6': None,
+            'cf7': None,
+        })
+
+        for filter_name, value in (
+            ('cf_cf1__n', 100),
+            ('cf_cf4__n', 'foo'),
+            ('cf_cf4__nic', 'foo'),
+            ('cf_cf4__nisw', 'foo'),
+            ('cf_cf4__niew', 'bar'),
+            ('cf_cf4__nie', 'FOO'),
+            ('cf_cf6__n', '2016-06-26'),
+            ('cf_cf7__n', 'http://a.example.com'),
+            ('cf_cf7__nic', 'a'),
+            ('cf_cf7__nisw', 'http://'),
+            ('cf_cf7__niew', '.com'),
+        ):
+            with self.subTest(filter_name):
+                pks = set(
+                    self.filterset({filter_name: [value]}, self.queryset).qs.values_list('pk', flat=True)
+                )
+                self.assertIn(no_key.pk, pks, "an object carrying no key must match")
+                self.assertIn(explicit_null.pk, pks, "an object holding a null must match")
+
+    def test_filter_null_sentinel_matches_unset_values(self):
+        """
+        The null sentinel (FILTERS_NULL_CHOICE_VALUE) asks for the objects holding no value, which
+        must include those carrying no key as well as those holding an explicit null. Negating it
+        must therefore return exactly the objects which do hold a value -- and in particular must
+        not return the ones it is being asked to exclude.
+
+        Only string-backed field types are exercised: a numeric or date field rejects 'null' during
+        form validation ("Enter a whole number"), so the sentinel never reaches the filter at all.
+        That is a property of multivalue_field_factory() and is unaffected by this behavior.
+        """
+        no_key = Site.objects.get(slug='site-4')
+        explicit_null = Site.objects.create(name='Site 5', slug='site-5', custom_field_data={
+            'cf4': None,
+            'cf7': None,
+            'cf9': None,
+        })
+        has_value = set(
+            Site.objects.filter(slug__in=('site-1', 'site-2', 'site-3')).values_list('pk', flat=True)
+        )
+
+        for filter_name in ('cf_cf4', 'cf_cf7', 'cf_cf9'):
+            with self.subTest(filter_name):
+                pks = set(
+                    self.filterset({filter_name: ['null']}, self.queryset).qs.values_list('pk', flat=True)
+                )
+                self.assertEqual(pks, {no_key.pk, explicit_null.pk})
+
+                pks = set(
+                    self.filterset({f'{filter_name}__n': ['null']}, self.queryset)
+                    .qs.values_list('pk', flat=True)
+                )
+                self.assertEqual(pks, has_value)
+
+    def test_filter_null_sentinel_combined_with_a_value(self):
+        """
+        The sentinel may be passed alongside real values, in which case it widens the match rather
+        than replacing it. Under negation the valueless objects are then excluded, as they are among
+        the values being negated.
+        """
+        no_key = Site.objects.get(slug='site-4')
+        site_1 = Site.objects.get(slug='site-1')
+
+        pks = set(
+            self.filterset({'cf_cf4': ['foo', 'null']}, self.queryset).qs.values_list('pk', flat=True)
+        )
+        self.assertIn(site_1.pk, pks, "an object holding the value must match")
+        self.assertIn(no_key.pk, pks, "an object holding no value must match")
+
+        pks = set(
+            self.filterset({'cf_cf4__n': ['foo', 'null']}, self.queryset).qs.values_list('pk', flat=True)
+        )
+        self.assertNotIn(site_1.pk, pks, "an object holding the value must be excluded")
+        self.assertNotIn(no_key.pk, pks, "an object holding no value must be excluded")
+
     def test_filter_select(self):
     def test_filter_select(self):
         self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf9__empty': True}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf9__empty': True}, self.queryset).qs.count(), 1)
@@ -2037,7 +2512,8 @@ class CustomFieldModelFilterTestCase(TestCase):
     def test_filter_multiselect(self):
     def test_filter_multiselect(self):
         self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1)  # Contains a literal null
+        # Matches both the object holding a literal null and the one carrying no key, as `empty` does
+        self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf10__empty': True}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf10__empty': True}, self.queryset).qs.count(), 2)
 
 
     def test_filter_object(self):
     def test_filter_object(self):

+ 318 - 6
netbox/extras/tests/test_event_rules.py

@@ -8,25 +8,28 @@ from unittest.mock import Mock, patch
 import django_rq
 import django_rq
 from django.conf import settings
 from django.conf import settings
 from django.http import HttpResponse
 from django.http import HttpResponse
-from django.test import RequestFactory, TestCase, tag
+from django.test import RequestFactory, TestCase, override_settings, tag
 from django.urls import reverse
 from django.urls import reverse
 from PIL import Image
 from PIL import Image
 from requests import Session
 from requests import Session
 from rest_framework import status
 from rest_framework import status
 
 
-from core.choices import ManagedFileRootPathChoices
+from core.choices import JobNotificationChoices, ManagedFileRootPathChoices
 from core.events import *
 from core.events import *
 from core.models import Job, ObjectType
 from core.models import Job, ObjectType
-from dcim.choices import SiteStatusChoices
-from dcim.models import DeviceType, Interface, Manufacturer, Site
+from dcim.choices import DeviceStatusChoices, InterfaceTypeChoices, SiteStatusChoices
+from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
 from extras.choices import EventRuleActionChoices
 from extras.choices import EventRuleActionChoices
 from extras.events import enqueue_event, flush_events, serialize_for_event
 from extras.events import enqueue_event, flush_events, serialize_for_event
-from extras.models import EventRule, Script, ScriptModule, Tag, Webhook
+from extras.models import EventRule, Notification, Script, ScriptModule, Tag, Webhook
 from extras.scripts import Script as ScriptBase
 from extras.scripts import Script as ScriptBase
 from extras.signals import process_job_end_event_rules
 from extras.signals import process_job_end_event_rules
 from extras.webhooks import generate_signature, send_webhook
 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.context_managers import event_tracking
-from utilities.testing import APITestCase, create_test_device
+from users.models import ObjectPermission
+from utilities.testing import APITestCase, create_test_device, disable_warnings
 from utilities.testing.mixins import RQQueueTestMixin
 from utilities.testing.mixins import RQQueueTestMixin
 
 
 
 
@@ -167,6 +170,32 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
         self.assertEqual(job.kwargs['snapshots']['postchange']['name'], 'Site 1')
         self.assertEqual(job.kwargs['snapshots']['postchange']['name'], 'Site 1')
         self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Bar', 'Foo'])
         self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Bar', 'Foo'])
 
 
+    def test_single_create_rollback_discards_events(self):
+        """
+        Check that creating an object which is then rolled back by the object-level permission check
+        in perform_create() queues no background task.
+        """
+        # Permit the creation of active sites 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': SiteStatusChoices.STATUS_ACTIVE},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Site))
+
+        data = {'name': 'Site 1', 'slug': 'site-1', 'status': SiteStatusChoices.STATUS_PLANNED}
+        url = reverse('dcim-api:site-list')
+        with disable_warnings('django.request'):
+            response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+        self.assertEqual(Site.objects.count(), 0)
+
+        # No task may be queued for a creation that was rolled back
+        self.assertEqual(self.queue.count, 0)
+
     def test_bulk_create_process_eventrule(self):
     def test_bulk_create_process_eventrule(self):
         """
         """
         Check that bulk creating multiple objects with an applicable EventRule queues a background task for each
         Check that bulk creating multiple objects with an applicable EventRule queues a background task for each
@@ -218,6 +247,72 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
             self.assertEqual(job.kwargs['snapshots']['postchange']['name'], response.data[i]['name'])
             self.assertEqual(job.kwargs['snapshots']['postchange']['name'], response.data[i]['name'])
             self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Bar', 'Foo'])
             self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Bar', 'Foo'])
 
 
+    def test_bulk_create_rollback_discards_events(self):
+        """
+        Check that a sequential bulk create which is rolled back queues no background tasks for the
+        objects that were provisionally created before the failure.
+        """
+        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')
+        role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        # DeviceViewSet uses SequentialBulkCreatesMixin, so each valid object is provisionally
+        # created (and its event queued) before a later object fails validation.
+        event_rule = EventRule.objects.get(name='Event Rule 1')
+        event_rule.object_types.set([ObjectType.objects.get_for_model(Device)])
+
+        data = [
+            {
+                'name': 'Device 1',
+                'device_type': device_type.pk,
+                'role': role.pk,
+                'site': site.pk,
+                'status': DeviceStatusChoices.STATUS_ACTIVE,
+            },
+            {},  # Missing all required fields
+        ]
+        url = reverse('dcim-api:device-list')
+        self.add_permissions('dcim.add_device', 'dcim.view_site', 'dcim.view_devicetype', 'dcim.view_devicerole')
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(Device.objects.count(), 0)
+
+        # 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):
     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.
         Check that updating an object with an applicable EventRule queues a background task for the rule's action.
@@ -252,6 +347,37 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
         self.assertEqual(job.kwargs['snapshots']['postchange']['name'], 'Site X')
         self.assertEqual(job.kwargs['snapshots']['postchange']['name'], 'Site X')
         self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Baz'])
         self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Baz'])
 
 
+    def test_single_update_rollback_discards_events(self):
+        """
+        Check that updating an object which is then rolled back by the object-level permission check
+        in perform_update() queues no background task.
+        """
+        site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
+
+        # Permit the modification of active sites only. Setting the status to "planned" takes the
+        # object outside the permission's scope, so it is saved (queueing its event) and then
+        # rejected by _validate_objects(), rolling the transaction back.
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['change'],
+            constraints={'status': SiteStatusChoices.STATUS_ACTIVE},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Site))
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
+        with disable_warnings('django.request'):
+            response = self.client.patch(
+                url, {'status': SiteStatusChoices.STATUS_PLANNED}, format='json', **self.header
+            )
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+        site.refresh_from_db()
+        self.assertEqual(site.status, SiteStatusChoices.STATUS_ACTIVE)
+
+        # No task may be queued for an update that was rolled back
+        self.assertEqual(self.queue.count, 0)
+
     def test_bulk_update_process_eventrule(self):
     def test_bulk_update_process_eventrule(self):
         """
         """
         Check that bulk updating multiple objects with an applicable EventRule queues a background task for each
         Check that bulk updating multiple objects with an applicable EventRule queues a background task for each
@@ -309,6 +435,38 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
             self.assertEqual(job.kwargs['snapshots']['postchange']['name'], response.data[i]['name'])
             self.assertEqual(job.kwargs['snapshots']['postchange']['name'], response.data[i]['name'])
             self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Baz'])
             self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Baz'])
 
 
+    def test_bulk_update_rollback_discards_events(self):
+        """
+        Check that a bulk update which is rolled back because one object failed validation queues no
+        background tasks for the objects that were provisionally updated.
+        """
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
+        )
+        Site.objects.bulk_create(sites)
+
+        # The first two objects are valid and will be provisionally updated; the third fails
+        # validation, rolling the entire batch back.
+        data = [
+            {'id': sites[0].pk, 'name': 'Site X'},
+            {'id': sites[1].pk, 'name': 'Site Y'},
+            {'id': sites[2].pk, 'status': 'not-a-valid-status'},
+        ]
+        url = reverse('dcim-api:site-list')
+        self.add_permissions('dcim.change_site')
+        response = self.client.patch(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        # No object may have been modified
+        for site in sites:
+            site.refresh_from_db()
+        self.assertListEqual([site.name for site in sites], ['Site 1', 'Site 2', 'Site 3'])
+
+        # No task may be queued for an update that was rolled back
+        self.assertEqual(self.queue.count, 0)
+
     def test_single_delete_process_eventrule(self):
     def test_single_delete_process_eventrule(self):
         """
         """
         Check that deleting an object with an applicable EventRule queues a background task for the rule's action.
         Check that deleting an object with an applicable EventRule queues a background task for the rule's action.
@@ -333,6 +491,35 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
         self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1')
         self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1')
         self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
         self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
 
 
+    def test_single_delete_rollback_discards_events(self):
+        """
+        Check that deleting an object whose cascading deletion is aborted queues no background task
+        for the dependent objects that were already processed.
+        """
+        device = create_test_device('Device 1')
+        Interface.objects.create(
+            device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, description='Has one'
+        )
+        Interface.objects.create(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
+
+        event_rule = EventRule.objects.get(name='Event Rule 3')
+        event_rule.object_types.set([ObjectType.objects.get_for_model(Interface)])
+
+        url = reverse('dcim-api:device-detail', kwargs={'pk': device.pk})
+        self.add_permissions('dcim.delete_device')
+
+        # Deleting the Device cascades to both Interfaces. The first satisfies the protection rule
+        # and so is processed (queueing its event); the second does not, aborting the request.
+        protection_rules = {'dcim.interface': [{'description': {'required': True}}]}
+        with override_settings(PROTECTION_RULES=protection_rules):
+            response = self.client.delete(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertTrue(Device.objects.filter(pk=device.pk).exists())
+        self.assertEqual(Interface.objects.filter(device=device).count(), 2)
+
+        # No task may be queued for a deletion that was rolled back
+        self.assertEqual(self.queue.count, 0)
+
     def test_bulk_delete_process_eventrule(self):
     def test_bulk_delete_process_eventrule(self):
         """
         """
         Check that bulk deleting multiple objects with an applicable EventRule queues a background task for each
         Check that bulk deleting multiple objects with an applicable EventRule queues a background task for each
@@ -367,6 +554,58 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
             self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
             self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
             self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
             self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
 
 
+    def test_bulk_delete_rollback_discards_events(self):
+        """
+        Check that a bulk delete which is rolled back because one object is protected queues no
+        background tasks for the objects that were provisionally deleted.
+        """
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
+        )
+        Site.objects.bulk_create(sites)
+
+        # A Device references the third Site, whose deletion will therefore raise a ProtectedError
+        # and roll the entire batch back.
+        create_test_device('Device 1', site=sites[2])
+
+        data = [{'id': site.pk} for site in sites]
+        url = reverse('dcim-api:site-list')
+        self.add_permissions('dcim.delete_site')
+        response = self.client.delete(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
+        self.assertEqual(Site.objects.count(), 3)
+
+        # No task may be queued for a deletion that was rolled back
+        self.assertEqual(self.queue.count, 0)
+
+    def test_bulk_delete_abort_discards_events(self):
+        """
+        Check that a bulk delete aborted by an exception (rather than by a per-object error) also
+        queues no background tasks. A protection rule raises AbortRequest from a signal receiver,
+        which propagates out of the per-object loop.
+        """
+        sites = (
+            Site(name='Site 1', slug='site-1', description='Has a description'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
+
+        data = [{'id': site.pk} for site in sites]
+        url = reverse('dcim-api:site-list')
+        self.add_permissions('dcim.delete_site')
+
+        # Site 2 has no description, so its deletion is blocked once Site 1 has already been deleted
+        protection_rules = {'dcim.site': [{'description': {'required': True}}]}
+        with override_settings(PROTECTION_RULES=protection_rules):
+            response = self.client.delete(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(Site.objects.count(), 2)
+
+        # No task may be queued for a deletion that was rolled back
+        self.assertEqual(self.queue.count, 0)
+
     @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
     @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
     def test_send_webhook(self):
     def test_send_webhook(self):
         request_id = uuid.uuid4()
         request_id = uuid.uuid4()
@@ -756,6 +995,79 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
         self.assertEqual(script_job.status, "completed")
         self.assertEqual(script_job.status, "completed")
         self.assertEqual(script_job.data.get('output', ''), "finished successfully")
         self.assertEqual(script_job.data.get('output', ''), "finished successfully")
 
 
+    @tag('regression')  # Issue #22852
+    def test_eventrule_script_action_honors_script_defaults(self):
+        """A script run from an event rule uses the notification policy and job timeout from its Meta class."""
+        class DummyScript(ScriptBase):
+            class Meta:
+                name = 'Dummy Defaults Script'
+                notifications_default = JobNotificationChoices.NOTIFICATION_ON_FAILURE
+                job_timeout = 600
+
+            def run(self, data, commit=True):
+                return 'finished successfully'
+
+        dummy_script = DummyScript()
+
+        with patch.object(ScriptModule, 'sync_classes'):
+            module = ScriptModule.objects.create(
+                file_root=ManagedFileRootPathChoices.SCRIPTS,
+                file_path='dummy_defaults_script.py',
+            )
+        script = Script.objects.create(
+            module=module,
+            name=dummy_script.name,
+            is_executable=True,
+        )
+
+        event_rule = EventRule.objects.create(
+            name='Test Script Defaults Event Rule',
+            event_types=[OBJECT_CREATED],
+            action_type=EventRuleActionChoices.SCRIPT,
+            action_object_type=ObjectType.objects.get_for_model(Script),
+            action_object_id=script.pk,
+        )
+        event_rule.object_types.set([ObjectType.objects.get_for_model(DeviceType)])
+
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer')
+        self.add_permissions('dcim.add_devicetype')
+
+        with patch.object(Script, 'python_class') as mock:
+            mock.return_value = dummy_script
+            with self.captureOnCommitCallbacks(execute=True):
+                response = self.client.post(
+                    reverse('dcim-api:devicetype-list'),
+                    {
+                        'manufacturer': manufacturer.pk,
+                        'model': 'Test DeviceType',
+                        'slug': 'test-devicetype',
+                    },
+                    format='json',
+                    **self.header,
+                )
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
+            self.assertEqual(self.queue.count, 1)
+            self.assertEqual(self.queue.jobs[0].timeout, 600)
+            script_job = Job.objects.get(name=dummy_script.name)
+            self.assertEqual(script_job.notifications, JobNotificationChoices.NOTIFICATION_ON_FAILURE)
+
+            # silence rqworker (cleaner output) and trigger job execution
+            rq_logger = logging.getLogger('rq.worker')
+            self.addCleanup(rq_logger.setLevel, rq_logger.level)
+            rq_logger.setLevel(logging.ERROR)
+            self.run_rq_jobs('default')
+
+        script_job.refresh_from_db()
+        self.assertEqual(script_job.status, "completed")
+        self.assertFalse(
+            Notification.objects.filter(
+                user=self.user,
+                object_type=ObjectType.objects.get_for_model(Job),
+                object_id=script_job.pk,
+            ).exists()
+        )
+
     @tag('regression')
     @tag('regression')
     def test_eventrule_webhook_action_with_object_image_files(self):
     def test_eventrule_webhook_action_with_object_image_files(self):
         """
         """

+ 14 - 15
netbox/extras/tests/test_management_commands.py

@@ -7,11 +7,13 @@ from django.core.management import call_command
 from django.core.management.base import CommandError
 from django.core.management.base import CommandError
 from django.test import TestCase
 from django.test import TestCase
 
 
+from core.choices import JobNotificationChoices
 from dcim.choices import InterfaceTypeChoices
 from dcim.choices import InterfaceTypeChoices
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
 from extras.management.commands import renaturalize, webhook_receiver
 from extras.management.commands import renaturalize, webhook_receiver
 from extras.management.commands.webhook_receiver import WebhookHandler
 from extras.management.commands.webhook_receiver import WebhookHandler
 from extras.models import ImageAttachment
 from extras.models import ImageAttachment
+from extras.scripts import Script, StringVar
 from extras.tests.test_models import OverwriteStyleMemoryStorage, UnreadableSizeMemoryStorage
 from extras.tests.test_models import OverwriteStyleMemoryStorage, UnreadableSizeMemoryStorage
 from users.models import User
 from users.models import User
 from utilities.fields import NaturalOrderingField
 from utilities.fields import NaturalOrderingField
@@ -255,20 +257,14 @@ class RunScriptTestCase(TestCase):
         )
         )
 
 
     def test_enqueues_script_job(self):
     def test_enqueues_script_job(self):
-        class TestScript:
-            full_name = 'test.Script'
+        class TestScript(Script):
+            value = StringVar()
 
 
-            def as_form(self, data, files):
-                form = MagicMock()
-                form.is_valid.return_value = True
-                form.cleaned_data = {
-                    '_schedule_at': None,
-                    '_interval': None,
-                    '_commit': None,
-                    'name': data['name'],
-                }
-                form.errors.get_json_data.return_value = {}
-                return form
+            class Meta:
+                notifications_default = JobNotificationChoices.NOTIFICATION_ON_FAILURE
+
+            def run(self, data, commit):
+                return None
 
 
         script_obj = SimpleNamespace(python_class=TestScript)
         script_obj = SimpleNamespace(python_class=TestScript)
         job = SimpleNamespace(duration='0 seconds')
         job = SimpleNamespace(duration='0 seconds')
@@ -288,7 +284,7 @@ class RunScriptTestCase(TestCase):
                 'runscript',
                 'runscript',
                 'test.Script',
                 'test.Script',
                 user='admin',
                 user='admin',
-                data='{"name": "test"}',
+                data='{"value": "test"}',
                 stdout=StringIO(),
                 stdout=StringIO(),
             )
             )
 
 
@@ -298,8 +294,9 @@ class RunScriptTestCase(TestCase):
         self.assertEqual(kwargs['instance'], script_obj)
         self.assertEqual(kwargs['instance'], script_obj)
         self.assertEqual(kwargs['user'], self.user)
         self.assertEqual(kwargs['user'], self.user)
         self.assertTrue(kwargs['immediate'])
         self.assertTrue(kwargs['immediate'])
-        self.assertEqual(kwargs['data'], {'name': 'test'})
+        self.assertEqual(kwargs['data'], {'value': 'test'})
         self.assertFalse(kwargs['commit'])
         self.assertFalse(kwargs['commit'])
+        self.assertEqual(kwargs['notifications'], JobNotificationChoices.NOTIFICATION_ON_FAILURE)
 
 
     def test_invalid_script_data_raises_error_without_enqueueing_job(self):
     def test_invalid_script_data_raises_error_without_enqueueing_job(self):
         class TestScript:
         class TestScript:
@@ -351,6 +348,7 @@ class RunScriptTestCase(TestCase):
                     '_schedule_at': None,
                     '_schedule_at': None,
                     '_interval': None,
                     '_interval': None,
                     '_commit': None,
                     '_commit': None,
+                    '_notifications': JobNotificationChoices.NOTIFICATION_ALWAYS,
                 }
                 }
                 form.errors.get_json_data.return_value = {}
                 form.errors.get_json_data.return_value = {}
                 return form
                 return form
@@ -391,6 +389,7 @@ class RunScriptTestCase(TestCase):
                     '_schedule_at': None,
                     '_schedule_at': None,
                     '_interval': None,
                     '_interval': None,
                     '_commit': None,
                     '_commit': None,
+                    '_notifications': JobNotificationChoices.NOTIFICATION_ALWAYS,
                 }
                 }
                 form.errors.get_json_data.return_value = {}
                 form.errors.get_json_data.return_value = {}
                 return form
                 return form

+ 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
         # The real circuits app must be untouched and remain an importable package
         self.assertIs(sys.modules['circuits'], circuits)
         self.assertIs(sys.modules['circuits'], circuits)
         self.assertTrue(hasattr(circuits, '__path__'))
         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])

+ 306 - 0
netbox/extras/tests/test_scripts_deletion.py

@@ -0,0 +1,306 @@
+import uuid
+from unittest import mock
+
+from django.contrib.contenttypes.models import ContentType
+from django.db import router
+from django.db.models import QuerySet
+from django.test import TestCase, override_settings
+from django.urls import reverse
+
+from core.choices import JobStatusChoices, ManagedFileRootPathChoices
+from core.models import DataSource, Job
+from extras.models import Script, ScriptModule
+from extras.validators import CustomValidator
+from netbox.models.deletion import ConfirmCollector, CountOnly
+from utilities.exceptions import AbortRequest
+from utilities.testing import TestCase as ViewTestCase
+
+
+class ScriptDeletionTestCase(TestCase):
+    """
+    Regression tests for #22812: deleting a JobsMixin object (Script, ScriptModule, DataSource)
+    with many associated Jobs must not load every Job into memory at once.
+    """
+    @classmethod
+    def setUpTestData(cls):
+        cls.script_ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
+
+    def _create_module(self):
+        return ScriptModule.objects.create(
+            file_root=ManagedFileRootPathChoices.SCRIPTS,
+            file_path=f'test_{uuid.uuid4().hex[:8]}.py',
+        )
+
+    def _create_script(self, module=None):
+        module = module or self._create_module()
+        script = Script.objects.create(module=module, name=f'S{uuid.uuid4().hex[:8]}')
+        return module, script
+
+    def _add_jobs(self, obj, count, object_type=None):
+        object_type = object_type or ContentType.objects.get_for_model(type(obj), for_concrete_model=False)
+        Job.objects.bulk_create([
+            Job(
+                object_type=object_type,
+                object_id=obj.pk,
+                name='testjob',
+                status=JobStatusChoices.STATUS_COMPLETED,
+                job_id=uuid.uuid4(),
+                data={'output': 'x' * 50},
+            )
+            for _ in range(count)
+        ])
+
+    def test_delete_script_deletes_all_jobs(self):
+        _, script = self._create_script()
+        self._add_jobs(script, 2500)
+        self.assertEqual(script.jobs.count(), 2500)
+
+        script.delete()
+
+        self.assertFalse(Script.objects.filter(pk=script.pk).exists())
+        self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 0)
+
+    def test_delete_script_batches_jobs(self):
+        _, script = self._create_script()
+        self._add_jobs(script, 5)
+
+        job_delete_calls = []
+        original_delete = QuerySet.delete
+
+        def counting_delete(qs, *args, **kwargs):
+            if qs.model is Job:
+                job_delete_calls.append(len(qs))
+            return original_delete(qs, *args, **kwargs)
+
+        with mock.patch('netbox.models.features.JOB_DELETE_BATCH_SIZE', 2):
+            with mock.patch.object(QuerySet, 'delete', counting_delete):
+                script.delete()
+
+        # 5 jobs at a batch size of 2 => three batched deletes (2, 2, 1)
+        self.assertEqual(job_delete_calls, [2, 2, 1])
+        self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 0)
+
+    def test_delete_scriptmodule_cascades_to_scripts_and_jobs(self):
+        module, script = self._create_script()
+        self._add_jobs(script, 100)
+
+        module.delete()
+
+        self.assertFalse(ScriptModule.objects.filter(pk=module.pk).exists())
+        self.assertFalse(Script.objects.filter(pk=script.pk).exists())
+        self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 0)
+
+    def test_delete_scriptmodule_batches_child_script_jobs(self):
+        # The reporter's actual path: a script is only removable via the UI by deleting its
+        # ScriptModule. The module's delete must batch the child Script's jobs.
+        module, script = self._create_script()
+        self._add_jobs(script, 5)
+
+        job_delete_calls = []
+        original_delete = QuerySet.delete
+
+        def counting_delete(qs, *args, **kwargs):
+            if qs.model is Job:
+                job_delete_calls.append(len(qs))
+            return original_delete(qs, *args, **kwargs)
+
+        with mock.patch('netbox.models.features.JOB_DELETE_BATCH_SIZE', 2):
+            with mock.patch.object(QuerySet, 'delete', counting_delete):
+                module.delete()
+
+        # 5 child-script jobs at a batch size of 2 => three batched deletes (2, 2, 1). The module
+        # has no jobs of its own, so JobsMixin.delete adds no further Job deletes.
+        self.assertEqual(job_delete_calls, [2, 2, 1])
+        self.assertFalse(Script.objects.filter(pk=script.pk).exists())
+        self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 0)
+
+    def test_delete_datasource_deletes_jobs(self):
+        datasource = DataSource.objects.create(name='DS', type='local', source_url='/tmp/test')
+        self._add_jobs(datasource, 100)
+        ds_ct = ContentType.objects.get_for_model(DataSource, for_concrete_model=False)
+        self.assertEqual(Job.objects.filter(object_type=ds_ct, object_id=datasource.pk).count(), 100)
+
+        datasource.delete()
+
+        self.assertFalse(DataSource.objects.filter(pk=datasource.pk).exists())
+        self.assertEqual(Job.objects.filter(object_type=ds_ct, object_id=datasource.pk).count(), 0)
+
+    def test_soft_delete_preserves_jobs(self):
+        _, script = self._create_script()
+        self._add_jobs(script, 10)
+
+        script.delete(soft_delete=True)
+
+        script.refresh_from_db()
+        self.assertFalse(script.is_executable)
+        self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 10)
+
+    @override_settings(PROTECTION_RULES={'extras.script': [CustomValidator({'name': {'eq': ''}})]})
+    def test_delete_rolls_back_jobs_on_parent_failure(self):
+        # A protection rule that no real script can satisfy (name must be empty) makes the
+        # cascade's pre_delete handler raise AbortRequest *after* JobsMixin.delete has already
+        # batch-deleted the jobs. JobsMixin.delete wraps the batch loop and super().delete() in a
+        # transaction, so the job deletions must roll back, leaving no orphaned partial state.
+        # This exercises the real deletion-abort path rather than mocking Django internals.
+        _, script = self._create_script()
+        self._add_jobs(script, 10)
+
+        with self.assertRaises(AbortRequest):
+            script.delete()
+
+        self.assertTrue(Script.objects.filter(pk=script.pk).exists())
+        self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 10)
+
+    @override_settings(PROTECTION_RULES={'extras.script': [CustomValidator({'name': {'eq': ''}})]})
+    def test_delete_scriptmodule_rolls_back_child_jobs_on_failure(self):
+        # Same abort path via the module: the protection rule fires when the cascade pre_deletes
+        # the child Script, after ScriptModule.delete has already batch-deleted that script's jobs.
+        # The transaction must roll those job deletions back, leaving no orphaned partial state.
+        module, script = self._create_script()
+        self._add_jobs(script, 10)
+
+        with self.assertRaises(AbortRequest):
+            module.delete()
+
+        self.assertTrue(ScriptModule.objects.filter(pk=module.pk).exists())
+        self.assertTrue(Script.objects.filter(pk=script.pk).exists())
+        self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 10)
+
+
+class ConfirmCollectorTestCase(TestCase):
+    """
+    #22812: the delete-confirmation page must not materialize every dependent Job.
+    """
+    def _create_script_with_jobs(self, count):
+        module = ScriptModule.objects.create(
+            file_root=ManagedFileRootPathChoices.SCRIPTS,
+            file_path=f'test_{uuid.uuid4().hex[:8]}.py',
+        )
+        script = Script.objects.create(module=module, name=f'S{uuid.uuid4().hex[:8]}')
+        ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
+        Job.objects.bulk_create([
+            Job(object_type=ct, object_id=script.pk, name='j', status=JobStatusChoices.STATUS_COMPLETED,
+                job_id=uuid.uuid4(), data={'output': 'x' * 50})
+            for _ in range(count)
+        ])
+        return script
+
+    def test_confirm_collector_counts_jobs_without_instantiating(self):
+        script = self._create_script_with_jobs(500)
+
+        init_calls = []
+        original_init = Job.__init__
+
+        def counting_init(self, *args, **kwargs):
+            init_calls.append(1)
+            original_init(self, *args, **kwargs)
+
+        with mock.patch.object(Job, '__init__', counting_init):
+            collector = ConfirmCollector(using=router.db_for_write(Script))
+            collector.collect([script])
+
+        # No Job rows were instantiated; the relation was counted instead.
+        self.assertEqual(len(init_calls), 0)
+        self.assertNotIn(Job, collector.data)
+        self.assertEqual(collector.generic_relation_counts.get(Job), 500)
+        # The non-job cascade (the Script itself) is still collected.
+        self.assertIn(Script, collector.data)
+
+    def test_count_only_wrapper(self):
+        # CountOnly reports its count via len() but iterates empty, so it slots into the
+        # dependent-objects mapping as a non-expandable, non-materializing row.
+        wrapper = CountOnly(3000)
+        self.assertEqual(len(wrapper), 3000)
+        self.assertEqual(list(wrapper), [])
+        self.assertTrue(wrapper.count_only)
+
+    def test_confirm_collector_omits_jobs_when_none(self):
+        # A jobless object must not record a zero count, or the confirmation page would show a
+        # spurious "0 jobs" row (#22812 regression).
+        datasource = DataSource.objects.create(name='DS', type='local', source_url='/tmp/test')
+
+        collector = ConfirmCollector(using=router.db_for_write(DataSource))
+        collector.collect([datasource])
+
+        self.assertNotIn(Job, collector.generic_relation_counts)
+
+
+class ObjectDeleteViewCountsTestCase(ViewTestCase):
+    """
+    #22812: the delete-confirmation view must report a JobsMixin object's jobs as a count
+    (via CountOnly) without materializing them, and _get_dependent_objects must keep returning
+    a single dict.
+    """
+    def test_get_dependent_objects_returns_count_only_for_jobs(self):
+        from netbox.views.generic.object_views import ObjectDeleteView
+
+        module = ScriptModule.objects.create(
+            file_root=ManagedFileRootPathChoices.SCRIPTS,
+            file_path=f'test_{uuid.uuid4().hex[:8]}.py',
+        )
+        script = Script.objects.create(module=module, name=f'S{uuid.uuid4().hex[:8]}')
+        ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
+        Job.objects.bulk_create([
+            Job(
+                object_type=ct, object_id=script.pk, name='j',
+                status=JobStatusChoices.STATUS_COMPLETED, job_id=uuid.uuid4(),
+            )
+            for _ in range(50)
+        ])
+
+        view = ObjectDeleteView()
+        view.queryset = ScriptModule.objects.all()
+        dependent_objects = view._get_dependent_objects(module)
+
+        # Single dict returned (not a tuple); jobs represented as a CountOnly.
+        self.assertIsInstance(dependent_objects, dict)
+        self.assertIn(Job, dependent_objects)
+        self.assertIsInstance(dependent_objects[Job], CountOnly)
+        self.assertEqual(len(dependent_objects[Job]), 50)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_confirm_page_renders_job_count(self):
+        module = ScriptModule.objects.create(
+            file_root=ManagedFileRootPathChoices.SCRIPTS,
+            file_path=f'test_{uuid.uuid4().hex[:8]}.py',
+        )
+        script = Script.objects.create(module=module, name=f'S{uuid.uuid4().hex[:8]}')
+        ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
+        Job.objects.bulk_create([
+            Job(
+                object_type=ct, object_id=script.pk, name='j',
+                status=JobStatusChoices.STATUS_COMPLETED, job_id=uuid.uuid4(),
+            )
+            for _ in range(50)
+        ])
+
+        # ScriptModule is a proxy over core.ManagedFile, so the delete view requires the
+        # concrete model's permission (core.delete_managedfile), not extras.delete_scriptmodule.
+        self.add_permissions('core.delete_managedfile')
+        url = reverse('extras:scriptmodule_delete', kwargs={'pk': module.pk})
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        # Assert on the rendered context, not brittle HTML substrings: Job is present in
+        # dependent_objects as a CountOnly reporting the true count, so the confirmation page
+        # renders it as a summarized (non-expandable) row without materializing 50 Job rows.
+        dependent_objects = response.context['dependent_objects']
+        self.assertIn(Job, dependent_objects)
+        self.assertIsInstance(dependent_objects[Job], CountOnly)
+        self.assertEqual(len(dependent_objects[Job]), 50)
+        self.assertTrue(dependent_objects[Job].count_only)
+
+    def test_get_dependent_objects_omits_jobs_when_none(self):
+        from netbox.views.generic.object_views import ObjectDeleteView
+
+        # A module with no jobs must not produce a CountOnly(0) entry (#22812 regression).
+        module = ScriptModule.objects.create(
+            file_root=ManagedFileRootPathChoices.SCRIPTS,
+            file_path=f'test_{uuid.uuid4().hex[:8]}.py',
+        )
+
+        view = ObjectDeleteView()
+        view.queryset = ScriptModule.objects.all()
+        dependent_objects = view._get_dependent_objects(module)
+
+        self.assertNotIn(Job, dependent_objects)

+ 2 - 2
netbox/extras/tests/test_signals.py

@@ -81,8 +81,8 @@ class CustomFieldDeletedSignalTestCase(TestCase):
 
 
 class CustomFieldObjectTypeSignalTestCase(TestCase):
 class CustomFieldObjectTypeSignalTestCase(TestCase):
     """
     """
-    Verify extras.signals.handle_cf_added_obj_types and handle_cf_removed_obj_types
-    populate or strip default values when a CustomField's object_types m2m changes.
+    Verify extras.signals.handle_cf_object_types_changed populates or strips default values when a
+    CustomField's object_types m2m changes.
     """
     """
 
 
     def test_adding_object_type_populates_default_value(self):
     def test_adding_object_type_populates_default_value(self):

+ 37 - 0
netbox/extras/tests/test_templatetags.py

@@ -1,6 +1,7 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.contrib.auth.context_processors import PermWrapper
 from django.contrib.auth.context_processors import PermWrapper
 from django.test import RequestFactory, TestCase
 from django.test import RequestFactory, TestCase
+from django.utils.html import escape
 
 
 from core.models import ObjectType
 from core.models import ObjectType
 from dcim.models import Site
 from dcim.models import Site
@@ -84,6 +85,12 @@ class CustomLinkRenderErrorEscapingTest(TestCase):
     XSS_NAME = '<img src=x onerror=alert(1)>'
     XSS_NAME = '<img src=x onerror=alert(1)>'
     ESCAPED_NAME = '&lt;img src=x onerror=alert(1)&gt;'
     ESCAPED_NAME = '&lt;img src=x onerror=alert(1)&gt;'
 
 
+    # Subscripting a string with a nonexistent attribute yields an Undefined, and operating on it raises
+    # UndefinedError. These tests depend on Jinja2 quoting the subscript verbatim in that message (currently
+    # "'str object' has no attribute '<payload>'"); a change to Jinja2's message format would break them.
+    XSS_PAYLOAD = '" ></span><script>alert(1)</script>'
+    FAILING_TEMPLATE = f"{{{{ ''['{XSS_PAYLOAD}'] + 1 }}}}"
+
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         cls.site = Site.objects.create(name='Site 1', slug='site-1')
         cls.site = Site.objects.create(name='Site 1', slug='site-1')
@@ -134,3 +141,33 @@ class CustomLinkRenderErrorEscapingTest(TestCase):
         rendered = self.render(self.make_user_with_view_permission('user2'))
         rendered = self.render(self.make_user_with_view_permission('user2'))
         self.assertNotIn(self.XSS_NAME, rendered)
         self.assertNotIn(self.XSS_NAME, rendered)
         self.assertIn(self.ESCAPED_NAME, rendered)
         self.assertIn(self.ESCAPED_NAME, rendered)
+
+    def test_render_error_escapes_exception_message(self):
+        # The exception message reproduces the (attacker-controlled) template code, so it must be escaped
+        # in the error fallback as well (NB-3311).
+        custom_link = CustomLink.objects.create(
+            name='Custom Link 1',
+            enabled=True,
+            link_text=self.FAILING_TEMPLATE,
+            link_url='http://example.com/',
+        )
+        custom_link.object_types.set([ObjectType.objects.get_for_model(Site)])
+
+        rendered = self.render(self.make_user_with_view_permission('user3'))
+        self.assertNotIn(self.XSS_PAYLOAD, rendered)
+        self.assertIn(escape(self.XSS_PAYLOAD), rendered)
+
+    def test_render_error_escapes_grouped_exception_message(self):
+        # The grouped-link error fallback must likewise escape the exception message (NB-3311).
+        custom_link = CustomLink.objects.create(
+            name='Custom Link 1',
+            enabled=True,
+            group_name='Group 1',
+            link_text=self.FAILING_TEMPLATE,
+            link_url='http://example.com/',
+        )
+        custom_link.object_types.set([ObjectType.objects.get_for_model(Site)])
+
+        rendered = self.render(self.make_user_with_view_permission('user4'))
+        self.assertNotIn(self.XSS_PAYLOAD, rendered)
+        self.assertIn(escape(self.XSS_PAYLOAD), rendered)

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

@@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.contrib.messages import get_messages
 from django.contrib.messages import get_messages
 from django.test import tag
 from django.test import tag
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.html import escape
 
 
 from core.choices import JobStatusChoices, ManagedFileRootPathChoices
 from core.choices import JobStatusChoices, ManagedFileRootPathChoices
 from core.events import *
 from core.events import *
@@ -245,6 +246,30 @@ class CustomLinkRenderingTestCase(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotIn(f'FOO {site.name} BAR', str(response.content))
         self.assertNotIn(f'FOO {site.name} BAR', str(response.content))
 
 
+    def test_list_view_custom_link_column_escapes_render_error(self):
+        # Jinja2 includes the invalid key verbatim in UndefinedError; this test intentionally depends on that format.
+        payload = '" ></span><script>alert(1)</script>'
+        customlink = CustomLink(
+            name='Test',
+            link_text=f"{{{{ ''['{payload}'] + 1 }}}}",
+            link_url='http://example.com/',
+            new_window=False
+        )
+        customlink.save()
+        customlink.object_types.set([ObjectType.objects.get_for_model(Site)])
+
+        site = Site(name='Test Site', slug='test-site')
+        site.save()
+
+        response = self.client.get(f"{reverse('dcim:site_list')}?include_columns=cl_Test")
+        self.assertEqual(response.status_code, 200)
+        content = response.content.decode()
+
+        # The error element must be present, but the payload must appear only in escaped form
+        self.assertIn('<span class="text-danger" title="', content)
+        self.assertNotIn(payload, content)
+        self.assertIn(escape(payload), content)
+
 
 
 class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = SavedFilter
     model = SavedFilter
@@ -564,6 +589,24 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'as_attachment': True,
             '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):
 class ExportTemplateExportFlowTestCase(TestCase):
     """
     """
@@ -827,6 +870,31 @@ class ConfigContextProfileTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             f"{profiles[2].pk},New description",
             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
 # TODO: Change base class to PrimaryObjectViewTestCase
 # Blocked by absence of standard create/edit, bulk create views
 # Blocked by absence of standard create/edit, bulk create views
@@ -877,6 +945,24 @@ class ConfigContextTestCase(
             'description': 'New description',
             '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(
 class ConfigTemplateTestCase(
     ViewTestCases.GetObjectViewTestCase,
     ViewTestCases.GetObjectViewTestCase,
@@ -934,6 +1020,24 @@ class ConfigTemplateTestCase(
             'as_attachment': True,
             '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(
 class JournalEntryTestCase(
     # ViewTestCases.GetObjectViewTestCase,
     # 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.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
+from django.utils.decorators import method_decorator
 from django.utils.http import content_disposition_header
 from django.utils.http import content_disposition_header
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
+from django.views.decorators.cache import never_cache
 from django.views.generic import View
 from django.views.generic import View
 
 
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
@@ -309,6 +311,7 @@ class ExportTemplateListView(generic.ObjectListView):
 
 
 
 
 @register_model_view(ExportTemplate)
 @register_model_view(ExportTemplate)
+@method_decorator(never_cache, name='dispatch')
 class ExportTemplateView(generic.ObjectView):
 class ExportTemplateView(generic.ObjectView):
     queryset = ExportTemplate.objects.all()
     queryset = ExportTemplate.objects.all()
     template_name = 'generic/object.html'
     template_name = 'generic/object.html'
@@ -995,6 +998,7 @@ class ConfigContextProfileListView(generic.ObjectListView):
 
 
 
 
 @register_model_view(ConfigContextProfile)
 @register_model_view(ConfigContextProfile)
+@method_decorator(never_cache, name='dispatch')
 class ConfigContextProfileView(generic.ObjectView):
 class ConfigContextProfileView(generic.ObjectView):
     queryset = ConfigContextProfile.objects.all()
     queryset = ConfigContextProfile.objects.all()
     template_name = 'generic/object.html'
     template_name = 'generic/object.html'
@@ -1070,6 +1074,7 @@ class ConfigContextListView(generic.ObjectListView):
 
 
 
 
 @register_model_view(ConfigContext)
 @register_model_view(ConfigContext)
+@method_decorator(never_cache, name='dispatch')
 class ConfigContextView(generic.ObjectView):
 class ConfigContextView(generic.ObjectView):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
     template_name = 'generic/object.html'
     template_name = 'generic/object.html'
@@ -1156,6 +1161,7 @@ class ConfigContextBulkSyncDataView(generic.BulkSyncDataView):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
 
 
 
 
+@method_decorator(never_cache, name='dispatch')
 class ObjectConfigContextView(generic.ObjectView):
 class ObjectConfigContextView(generic.ObjectView):
     base_template = None
     base_template = None
     template_name = 'extras/object_configcontext.html'
     template_name = 'extras/object_configcontext.html'
@@ -1200,6 +1206,7 @@ class ConfigTemplateListView(generic.ObjectListView):
 
 
 
 
 @register_model_view(ConfigTemplate)
 @register_model_view(ConfigTemplate)
+@method_decorator(never_cache, name='dispatch')
 class ConfigTemplateView(generic.ObjectView):
 class ConfigTemplateView(generic.ObjectView):
     queryset = ConfigTemplate.objects.all()
     queryset = ConfigTemplate.objects.all()
     template_name = 'generic/object.html'
     template_name = 'generic/object.html'
@@ -1261,6 +1268,7 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
     queryset = ConfigTemplate.objects.all()
     queryset = ConfigTemplate.objects.all()
 
 
 
 
+@method_decorator(never_cache, name='dispatch')
 class ObjectRenderConfigView(generic.ObjectView):
 class ObjectRenderConfigView(generic.ObjectView):
     base_template = None
     base_template = None
     template_name = 'extras/object_render_config.html'
     template_name = 'extras/object_render_config.html'

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

@@ -19,7 +19,7 @@ from ipam import filtersets
 from ipam.models import *
 from ipam.models import *
 from ipam.utils import get_next_available_prefix
 from ipam.utils import get_next_available_prefix
 from netbox.api.viewsets import NetBoxModelViewSet
 from netbox.api.viewsets import NetBoxModelViewSet
-from netbox.api.viewsets.mixins import ObjectValidationMixin
+from netbox.api.viewsets.mixins import ObjectValidationMixin, discard_events_on_rollback
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.constants import ADVISORY_LOCK_KEYS
 from netbox.constants import ADVISORY_LOCK_KEYS
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
@@ -295,8 +295,9 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
                 return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
                 return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
 
             # Create the new IP address(es)
             # Create the new IP address(es)
+            using = router.db_for_write(self.queryset.model)
             try:
             try:
-                with transaction.atomic(using=router.db_for_write(self.queryset.model)):
+                with transaction.atomic(using=using), discard_events_on_rollback(self, using=using):
                     created = serializer.save()
                     created = serializer.save()
                     self._validate_objects(created)
                     self._validate_objects(created)
             except ObjectDoesNotExist:
             except ObjectDoesNotExist:

+ 9 - 9
netbox/ipam/filtersets.py

@@ -946,28 +946,28 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
 @register_filterset
 @register_filterset
 class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
 class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
     scope_type = MultiValueContentTypeFilter()
     scope_type = MultiValueContentTypeFilter()
-    region = django_filters.NumberFilter(
+    region = MultiValueNumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
-    site_group = django_filters.NumberFilter(
+    site_group = MultiValueNumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
-    site = django_filters.NumberFilter(
+    site = MultiValueNumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
-    location = django_filters.NumberFilter(
+    location = MultiValueNumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
-    rack_group = django_filters.NumberFilter(
+    rack_group = MultiValueNumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
-    rack = django_filters.NumberFilter(
+    rack = MultiValueNumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
-    cluster_group = django_filters.NumberFilter(
+    cluster_group = MultiValueNumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
-    cluster = django_filters.NumberFilter(
+    cluster = MultiValueNumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
     contains_vid = django_filters.NumberFilter(
     contains_vid = django_filters.NumberFilter(
@@ -992,7 +992,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
         model_name = name.replace('_', '')
         model_name = name.replace('_', '')
         return queryset.filter(
         return queryset.filter(
             scope_type=ContentType.objects.get(model=model_name),
             scope_type=ContentType.objects.get(model=model_name),
-            scope_id=value
+            scope_id__in=value
         )
         )
 
 
 
 

+ 1 - 1
netbox/ipam/forms/model_forms.py

@@ -910,7 +910,7 @@ class ServiceCreateForm(ServiceForm):
 
 
     class Meta(ServiceForm.Meta):
     class Meta(ServiceForm.Meta):
         fields = [
         fields = [
-            'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
+            'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'owner',
             'comments', 'tags', 'parent_object_type',
             'comments', 'tags', 'parent_object_type',
         ]
         ]
 
 

+ 66 - 6
netbox/ipam/graphql/types.py

@@ -5,10 +5,13 @@ import strawberry_django
 
 
 from circuits.graphql.types import ProviderType
 from circuits.graphql.types import ProviderType
 from dcim.graphql.types import SiteType
 from dcim.graphql.types import SiteType
+from dcim.models import Device, Interface, Location, Rack, RackGroup, Region, Site, SiteGroup
 from extras.graphql.mixins import ContactsMixin
 from extras.graphql.mixins import ContactsMixin
 from ipam import models
 from ipam import models
+from netbox.graphql.optimization import build_gfk_prefetch
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType
 from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType
+from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
 
 
 from .filters import *
 from .filters import *
 from .mixins import IPAddressesMixin
 from .mixins import IPAddressesMixin
@@ -18,6 +21,7 @@ if TYPE_CHECKING:
         DeviceType,
         DeviceType,
         InterfaceType,
         InterfaceType,
         LocationType,
         LocationType,
+        RackGroupType,
         RackType,
         RackType,
         RegionType,
         RegionType,
         SiteGroupType,
         SiteGroupType,
@@ -126,7 +130,16 @@ class FHRPGroupType(IPAddressesMixin, PrimaryObjectType):
 class FHRPGroupAssignmentType(BaseObjectType):
 class FHRPGroupAssignmentType(BaseObjectType):
     group: Annotated['FHRPGroupType', strawberry.lazy('ipam.graphql.types')]
     group: Annotated['FHRPGroupType', strawberry.lazy('ipam.graphql.types')]
 
 
-    @strawberry_django.field(prefetch_related='interface')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'interface',
+            [
+                Interface,
+                VMInterface,
+            ],
+        ),
+        only=['interface_type', 'interface_id'],
+    )
     def interface(self) -> Annotated[
     def interface(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.graphql.types')],
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.graphql.types')],
@@ -155,7 +168,17 @@ class IPAddressType(ContactsMixin, PrimaryObjectType):
     def family(self) -> IPAddressFamilyType:
     def family(self) -> IPAddressFamilyType:
         return IPAddressFamilyType(value=self.family, label=f'IPv{self.family}')
         return IPAddressFamilyType(value=self.family, label=f'IPv{self.family}')
 
 
-    @strawberry_django.field(prefetch_related='assigned_object')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'assigned_object',
+            [
+                models.FHRPGroup,
+                Interface,
+                VMInterface,
+            ],
+        ),
+        only=['assigned_object_type', 'assigned_object_id'],
+    )
     def assigned_object(self) -> Annotated[
     def assigned_object(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['FHRPGroupType', strawberry.lazy('ipam.graphql.types')]
         | Annotated['FHRPGroupType', strawberry.lazy('ipam.graphql.types')]
@@ -197,7 +220,18 @@ class PrefixType(ContactsMixin, PrimaryObjectType):
     def family(self) -> IPAddressFamilyType:
     def family(self) -> IPAddressFamilyType:
         return IPAddressFamilyType(value=self.family, label=f'IPv{self.family}')
         return IPAddressFamilyType(value=self.family, label=f'IPv{self.family}')
 
 
-    @strawberry_django.field(prefetch_related='scope')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'scope',
+            [
+                Region,
+                SiteGroup,
+                Site,
+                Location,
+            ],
+        ),
+        only=['scope_type', 'scope_id'],
+    )
     def scope(self) -> Annotated[
     def scope(self) -> Annotated[
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
@@ -259,7 +293,17 @@ class ServiceType(ContactsMixin, PrimaryObjectType):
     ports: list[int]
     ports: list[int]
     ipaddresses: list[Annotated['IPAddressType', strawberry.lazy('ipam.graphql.types')]]
     ipaddresses: list[Annotated['IPAddressType', strawberry.lazy('ipam.graphql.types')]]
 
 
-    @strawberry_django.field(prefetch_related='parent')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'parent',
+            [
+                Device,
+                VirtualMachine,
+                models.FHRPGroup,
+            ],
+        ),
+        only=['parent_object_type', 'parent_object_id'],
+    )
     def parent(self) -> Annotated[
     def parent(self) -> Annotated[
         Annotated['DeviceType', strawberry.lazy('dcim.graphql.types')]
         Annotated['DeviceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['VirtualMachineType', strawberry.lazy('virtualization.graphql.types')]
         | Annotated['VirtualMachineType', strawberry.lazy('virtualization.graphql.types')]
@@ -298,7 +342,7 @@ class VLANType(PrimaryObjectType):
     interfaces_as_tagged: list[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
     interfaces_as_tagged: list[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
     vminterfaces_as_tagged: list[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
     vminterfaces_as_tagged: list[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
 
 
-    @strawberry_django.field(prefetch_related='qinq_svlan')
+    @strawberry_django.field(prefetch_related='qinq_svlan', only=['qinq_svlan_id'])
     def qinq_svlan(self) -> Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None:
     def qinq_svlan(self) -> Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None:
         return self.qinq_svlan
         return self.qinq_svlan
 
 
@@ -316,11 +360,27 @@ class VLANGroupType(OrganizationalObjectType):
     total_vlan_ids: BigInt
     total_vlan_ids: BigInt
     tenant: Annotated['TenantType', strawberry.lazy('tenancy.graphql.types')] | None
     tenant: Annotated['TenantType', strawberry.lazy('tenancy.graphql.types')] | None
 
 
-    @strawberry_django.field(prefetch_related='scope')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'scope',
+            [
+                Cluster,
+                ClusterGroup,
+                Location,
+                Rack,
+                RackGroup,
+                Region,
+                Site,
+                SiteGroup,
+            ],
+        ),
+        only=['scope_type', 'scope_id'],
+    )
     def scope(self) -> Annotated[
     def scope(self) -> Annotated[
         Annotated['ClusterType', strawberry.lazy('virtualization.graphql.types')]
         Annotated['ClusterType', strawberry.lazy('virtualization.graphql.types')]
         | Annotated['ClusterGroupType', strawberry.lazy('virtualization.graphql.types')]
         | Annotated['ClusterGroupType', strawberry.lazy('virtualization.graphql.types')]
         | Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['RackGroupType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RackType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RackType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['SiteType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['SiteType', strawberry.lazy('dcim.graphql.types')]

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

@@ -103,8 +103,6 @@ class FHRPGroupAssignment(ChangeLoggedModel):
         )
         )
     )
     )
 
 
-    clone_fields = ('interface_type', 'interface_id')
-
     class Meta:
     class Meta:
         ordering = ('-priority', 'pk')
         ordering = ('-priority', 'pk')
         indexes = (
         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'
         lookup = 'net_contains_or_equals' if include_self else 'net_contains'
         return Prefix.objects.filter(**{
         return Prefix.objects.filter(**{
-            'vrf': self.vrf,
+            'vrf_id': self.vrf_id,
             f'prefix__{lookup}': self.prefix
             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'
         lookup = 'net_contained_or_equal' if include_self else 'net_contained'
         return Prefix.objects.filter(**{
         return Prefix.objects.filter(**{
-            'vrf': self.vrf,
+            'vrf_id': self.vrf_id,
             f'prefix__{lookup}': self.prefix
             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
 from .models import IPAddress, Prefix
 
 
 
 
-def update_parents_children(prefix):
+def update_parents_children(prefix, using=None):
     """
     """
     Update depth on prefix & containing prefixes
     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:
     for parent in parents:
         parent._children = parent.hierarchy_children
         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
     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:
     for child in children:
         child._depth = child.hierarchy_depth
         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)
 @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)
     # Prefix has changed (or new instance has been created)
     if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix:
     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 this is not a new prefix, clean up parent/children of previous prefix
         if not created:
         if not created:
             old_prefix = Prefix(vrf_id=instance._vrf_id, prefix=instance._prefix)
             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)
 @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)
 @receiver(pre_delete, sender=IPAddress)

+ 32 - 32
netbox/ipam/tests/query_counts.json

@@ -1,37 +1,37 @@
 {
 {
-  "aggregate:api_list_objects": 14,
-  "aggregate:list_objects_with_permission": 24,
-  "asn:api_list_objects": 18,
-  "asn:list_objects_with_permission": 31,
-  "asnrange:api_list_objects": 15,
-  "asnrange:list_objects_with_permission": 22,
-  "fhrpgroup:api_list_objects": 14,
-  "fhrpgroup:list_objects_with_permission": 21,
+  "aggregate:api_list_objects": 13,
+  "aggregate:list_objects_with_permission": 21,
+  "asn:api_list_objects": 17,
+  "asn:list_objects_with_permission": 28,
+  "asnrange:api_list_objects": 14,
+  "asnrange:list_objects_with_permission": 19,
+  "fhrpgroup:api_list_objects": 13,
+  "fhrpgroup:list_objects_with_permission": 18,
   "fhrpgroupassignment:api_list_objects": 18,
   "fhrpgroupassignment:api_list_objects": 18,
-  "ipaddress:api_list_objects": 14,
-  "ipaddress:list_objects_with_permission": 21,
-  "iprange:api_list_objects": 13,
-  "iprange:list_objects_with_permission": 20,
-  "prefix:api_list_objects": 13,
-  "prefix:list_objects_with_permission": 29,
-  "rir:api_list_objects": 13,
-  "rir:list_objects_with_permission": 20,
-  "role:api_list_objects": 13,
-  "role:list_objects_with_permission": 20,
-  "routetarget:api_list_objects": 13,
-  "routetarget:list_objects_with_permission": 21,
-  "service:api_list_objects": 16,
-  "service:list_objects_with_permission": 21,
-  "servicetemplate:api_list_objects": 13,
-  "servicetemplate:list_objects_with_permission": 20,
-  "vlan:api_list_objects": 15,
-  "vlan:list_objects_with_permission": 24,
-  "vlangroup:api_list_objects": 13,
-  "vlangroup:list_objects_with_permission": 25,
+  "ipaddress:api_list_objects": 13,
+  "ipaddress:list_objects_with_permission": 18,
+  "iprange:api_list_objects": 12,
+  "iprange:list_objects_with_permission": 17,
+  "prefix:api_list_objects": 12,
+  "prefix:list_objects_with_permission": 26,
+  "rir:api_list_objects": 12,
+  "rir:list_objects_with_permission": 17,
+  "role:api_list_objects": 12,
+  "role:list_objects_with_permission": 17,
+  "routetarget:api_list_objects": 12,
+  "routetarget:list_objects_with_permission": 18,
+  "service:api_list_objects": 15,
+  "service:list_objects_with_permission": 18,
+  "servicetemplate:api_list_objects": 12,
+  "servicetemplate:list_objects_with_permission": 17,
+  "vlan:api_list_objects": 14,
+  "vlan:list_objects_with_permission": 21,
+  "vlangroup:api_list_objects": 12,
+  "vlangroup:list_objects_with_permission": 22,
   "vlantranslationpolicy:api_list_objects": 12,
   "vlantranslationpolicy:api_list_objects": 12,
-  "vlantranslationpolicy:list_objects_with_permission": 20,
+  "vlantranslationpolicy:list_objects_with_permission": 17,
   "vlantranslationrule:api_list_objects": 12,
   "vlantranslationrule:api_list_objects": 12,
-  "vlantranslationrule:list_objects_with_permission": 21,
-  "vrf:api_list_objects": 15,
-  "vrf:list_objects_with_permission": 20
+  "vlantranslationrule:list_objects_with_permission": 18,
+  "vrf:api_list_objects": 20,
+  "vrf:list_objects_with_permission": 17
 }
 }

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

@@ -224,6 +224,15 @@ class VRFTestCase(APIViewTestCases.APIViewTestCase):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
+        tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
+
+        route_targets = (
+            RouteTarget(name='65000:1001', tenant=tenant),
+            RouteTarget(name='65000:1002', tenant=tenant),
+            RouteTarget(name='65000:1003', tenant=tenant),
+        )
+        RouteTarget.objects.bulk_create(route_targets)
+
         vrfs = (
         vrfs = (
             VRF(name='VRF 1', rd='65000:1'),
             VRF(name='VRF 1', rd='65000:1'),
             VRF(name='VRF 2', rd='65000:2'),
             VRF(name='VRF 2', rd='65000:2'),
@@ -231,6 +240,11 @@ class VRFTestCase(APIViewTestCases.APIViewTestCase):
         )
         )
         VRF.objects.bulk_create(vrfs)
         VRF.objects.bulk_create(vrfs)
 
 
+        # Assigned so the query count baseline covers the non-nested route target expansion.
+        for vrf in vrfs:
+            vrf.import_targets.set(route_targets)
+            vrf.export_targets.set(route_targets)
+
 
 
 class RouteTargetTestCase(APIViewTestCases.APIViewTestCase):
 class RouteTargetTestCase(APIViewTestCases.APIViewTestCase):
     model = RouteTarget
     model = RouteTarget

+ 92 - 8
netbox/ipam/tests/test_filtersets.py

@@ -5,7 +5,19 @@ from netaddr import IPNetwork
 
 
 from circuits.models import Provider
 from circuits.models import Provider
 from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices
 from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices
-from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup
+from dcim.models import (
+    Device,
+    DeviceRole,
+    DeviceType,
+    Interface,
+    Location,
+    Manufacturer,
+    Rack,
+    RackGroup,
+    Region,
+    Site,
+    SiteGroup,
+)
 from ipam.choices import *
 from ipam.choices import *
 from ipam.filtersets import *
 from ipam.filtersets import *
 from ipam.models import *
 from ipam.models import *
@@ -1779,32 +1791,104 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_region(self):
     def test_region(self):
-        params = {'region': Region.objects.first().pk}
+        regions = (
+            Region.objects.get(slug='region-1'),
+            Region.objects.create(name='Region 2', slug='region-2'),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=regions[1])
+
+        params = {'region': [regions[0].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'region': [regions[0].pk, regions[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_site_group(self):
     def test_site_group(self):
-        params = {'site_group': SiteGroup.objects.first().pk}
+        site_groups = (
+            SiteGroup.objects.get(slug='site-group-1'),
+            SiteGroup.objects.create(name='Site Group 2', slug='site-group-2'),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=site_groups[1])
+
+        params = {'site_group': [site_groups[0].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'site_group': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_site(self):
     def test_site(self):
-        params = {'site': Site.objects.first().pk}
+        sites = (
+            Site.objects.get(slug='site-1'),
+            Site.objects.create(name='Site 2', slug='site-2'),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=sites[1])
+
+        params = {'site': [sites[0].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'site': [sites[0].pk, sites[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_location(self):
     def test_location(self):
-        params = {'location': Location.objects.first().pk}
+        site = Site.objects.get(slug='site-1')
+        locations = (
+            Location.objects.get(slug='location-1'),
+            Location.objects.create(name='Location 2', slug='location-2', site=site),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=locations[1])
+
+        params = {'location': [locations[0].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'location': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_rack_group(self):
+        rack_groups = (
+            RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1'),
+            RackGroup.objects.create(name='Rack Group 2', slug='rack-group-2'),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=rack_groups[0])
+        VLANGroup.objects.create(name='VLAN Group 10', slug='vlan-group-10', scope=rack_groups[1])
+
+        params = {'rack_group': [rack_groups[0].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'rack_group': [rack_groups[0].pk, rack_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_rack(self):
     def test_rack(self):
-        params = {'rack': Rack.objects.first().pk}
+        site = Site.objects.get(slug='site-1')
+        racks = (
+            Rack.objects.get(name='Rack 1'),
+            Rack.objects.create(name='Rack 2', site=site),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=racks[1])
+
+        params = {'rack': [racks[0].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'rack': [racks[0].pk, racks[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_cluster_group(self):
     def test_cluster_group(self):
-        params = {'cluster_group': ClusterGroup.objects.first().pk}
+        cluster_groups = (
+            ClusterGroup.objects.get(slug='cluster-group-1'),
+            ClusterGroup.objects.create(name='Cluster Group 2', slug='cluster-group-2'),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=cluster_groups[1])
+
+        params = {'cluster_group': [cluster_groups[0].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'cluster_group': [cluster_groups[0].pk, cluster_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_cluster(self):
     def test_cluster(self):
-        params = {'cluster': Cluster.objects.first().pk}
+        cluster_type = ClusterType.objects.get(slug='cluster-type-1')
+        clusters = (
+            Cluster.objects.get(name='Cluster 1'),
+            Cluster.objects.create(name='Cluster 2', type=cluster_type),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=clusters[1])
+
+        params = {'cluster': [clusters[0].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'cluster': [clusters[0].pk, clusters[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_tenant(self):
     def test_tenant(self):
         tenants = Tenant.objects.all()[:2]
         tenants = Tenant.objects.all()[:2]

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

@@ -1,13 +1,15 @@
 import uuid
 import uuid
 
 
 from django.contrib.contenttypes.models import ContentType
 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.choices import ObjectChangeActionChoices
 from core.models import ObjectChange
 from core.models import ObjectChange
-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 netbox.context_managers import event_tracking
 from users.models import User
 from users.models import User
+from utilities.testing import PinnedConnectionRouter
 from utilities.testing.utils import create_test_device, create_test_virtualmachine
 from utilities.testing.utils import create_test_device, create_test_virtualmachine
 
 
 
 
@@ -229,3 +231,56 @@ class ClearOOBIPSignalTestCase(TestCase):
                 action=ObjectChangeActionChoices.ACTION_UPDATE,
                 action=ObjectChangeActionChoices.ACTION_UPDATE,
             ).exists()
             ).exists()
         )
         )
+
+
+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)

+ 4 - 1
netbox/ipam/tests/test_views.py

@@ -20,7 +20,7 @@ from ipam.utils import AvailableIPSpace
 from ipam.views import AggregatePrefixesView, PrefixPrefixesView
 from ipam.views import AggregatePrefixesView, PrefixPrefixesView
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from users.models import Group, ObjectPermission
+from users.models import Group, ObjectPermission, Owner
 from utilities.testing import ViewTestCases, create_tags
 from utilities.testing import ViewTestCases, create_tags
 
 
 
 
@@ -2561,6 +2561,8 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         IPAddress.objects.bulk_create(ip_addresses)
         IPAddress.objects.bulk_create(ip_addresses)
 
 
+        owner = Owner.objects.create(name='Owner 1')
+
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
@@ -2571,6 +2573,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'ports': '104,105',
             'ports': '104,105',
             'ipaddresses': [],
             'ipaddresses': [],
             'description': 'A new service',
             'description': 'A new service',
+            'owner': owner.pk,
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
 
 

+ 14 - 3
netbox/netbox/api/viewsets/__init__.py

@@ -7,6 +7,7 @@ from django.db.models import ProtectedError, RestrictedError
 from django_pglocks import advisory_lock
 from django_pglocks import advisory_lock
 from rest_framework import mixins as drf_mixins
 from rest_framework import mixins as drf_mixins
 from rest_framework import status
 from rest_framework import status
+from rest_framework.exceptions import MethodNotAllowed
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet
 from rest_framework.viewsets import GenericViewSet
 
 
@@ -87,6 +88,13 @@ class BaseViewSet(GenericViewSet):
     def initial(self, request, *args, **kwargs):
     def initial(self, request, *args, **kwargs):
         super().initial(request, *args, **kwargs)
         super().initial(request, *args, **kwargs)
 
 
+        # Reject any method for which no action has been declared, rather than proceeding against an
+        # unrestricted QuerySet. (A method mapped to None, e.g. OPTIONS, is permitted: it needs no
+        # restriction.) This is the same 405 DRF would return when resolving the handler for an unmapped
+        # method, but it also covers a handler bound to such a method (e.g. @action(methods=['trace'])).
+        if request.method not in HTTP_ACTIONS:
+            raise MethodNotAllowed(request.method)
+
         # Restrict the view's QuerySet to allow only the permitted objects
         # Restrict the view's QuerySet to allow only the permitted objects
         if request.user.is_authenticated:
         if request.user.is_authenticated:
             if action := HTTP_ACTIONS[request.method]:
             if action := HTTP_ACTIONS[request.method]:
@@ -252,8 +260,9 @@ class NetBoxModelViewSet(
         logger.info(f"Creating new {model._meta.verbose_name}")
         logger.info(f"Creating new {model._meta.verbose_name}")
 
 
         # Enforce object-level permissions on save()
         # Enforce object-level permissions on save()
+        using = router.db_for_write(model)
         try:
         try:
-            with transaction.atomic(using=router.db_for_write(model)):
+            with transaction.atomic(using=using), mixins.discard_events_on_rollback(self, using=using):
                 instance = serializer.save()
                 instance = serializer.save()
                 self._validate_objects(instance)
                 self._validate_objects(instance)
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:
@@ -291,8 +300,9 @@ class NetBoxModelViewSet(
         logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
         logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
 
 
         # Enforce object-level permissions on save()
         # Enforce object-level permissions on save()
+        using = router.db_for_write(model)
         try:
         try:
-            with transaction.atomic(using=router.db_for_write(model)):
+            with transaction.atomic(using=using), mixins.discard_events_on_rollback(self, using=using):
                 # Re-check the If-Match ETag under a row-level lock to close the TOCTOU window
                 # Re-check the If-Match ETag under a row-level lock to close the TOCTOU window
                 # between the initial check in update() and the actual write.
                 # between the initial check in update() and the actual write.
                 if self._get_if_match(self.request):
                 if self._get_if_match(self.request):
@@ -325,8 +335,9 @@ class NetBoxModelViewSet(
         logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
         logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
         logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
         logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
 
 
+        using = router.db_for_write(model)
         try:
         try:
-            with transaction.atomic(using=router.db_for_write(model)):
+            with transaction.atomic(using=using), mixins.discard_events_on_rollback(self, using=using):
                 # Re-check the If-Match ETag under a row-level lock to close the TOCTOU window
                 # Re-check the If-Match ETag under a row-level lock to close the TOCTOU window
                 # between the initial check in destroy() and the actual delete.
                 # between the initial check in destroy() and the actual delete.
                 if self._get_if_match(self.request):
                 if self._get_if_match(self.request):

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

@@ -1,3 +1,5 @@
+from contextlib import contextmanager
+
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import router, transaction
 from django.db import router, transaction
 from django.http import Http404
 from django.http import Http404
@@ -5,6 +7,7 @@ from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.response import Response
 
 
 from core.models import ObjectType
 from core.models import ObjectType
+from core.signals import clear_events
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
 from netbox.api.serializers import BulkOperationSerializer
 from netbox.api.serializers import BulkOperationSerializer
 from netbox.api.serializers.bulk import get_bulk_update_serializer_class
 from netbox.api.serializers.bulk import get_bulk_update_serializer_class
@@ -16,9 +19,48 @@ __all__ = (
     'ExportTemplatesMixin',
     'ExportTemplatesMixin',
     'ObjectValidationMixin',
     'ObjectValidationMixin',
     'SequentialBulkCreatesMixin',
     'SequentialBulkCreatesMixin',
+    'discard_events_on_rollback',
 )
 )
 
 
 
 
+@contextmanager
+def discard_events_on_rollback(sender, using=None):
+    """
+    Discard any queued events if the transaction wrapping this block is rolled back.
+
+    The change logging signal receivers queue events eagerly, as the payload for a deleted object
+    must be captured while that object and its related rows are still reachable. The queue is not
+    flushed to the events pipeline until after the response has been rendered, however, so events
+    queued for writes which were subsequently rolled back would otherwise still be dispatched,
+    firing webhooks and event rules for changes that were never committed.
+
+    Bulk operations need this because they provisionally write every valid object in a batch and
+    then roll the entire batch back if any one object failed. Single-object writes need it because
+    a write can be undone after it has been saved (for instance by the object-level permission
+    check in perform_create()/perform_update(), or by a signal receiver raising AbortRequest). The
+    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.
+
+    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
+    except Exception:
+        # An exception escaping the block (e.g. AbortRequest raised by a signal receiver) rolls
+        # the transaction back just as an explicit set_rollback() does.
+        clear_events.send(sender=sender)
+        raise
+    if transaction.get_connection(using).needs_rollback:
+        clear_events.send(sender=sender)
+
+
 class CustomFieldsMixin:
 class CustomFieldsMixin:
     """
     """
     For models which support custom fields, populate the `custom_fields` context.
     For models which support custom fields, populate the `custom_fields` context.
@@ -61,7 +103,8 @@ class SequentialBulkCreatesMixin:
     appropriately.
     appropriately.
     """
     """
     def create(self, request, *args, **kwargs):
     def create(self, request, *args, **kwargs):
-        with transaction.atomic(using=router.db_for_write(self.queryset.model)):
+        using = router.db_for_write(self.queryset.model)
+        with transaction.atomic(using=using), discard_events_on_rollback(self, using=using):
             if not isinstance(request.data, list):
             if not isinstance(request.data, list):
                 # Creating a single object
                 # Creating a single object
                 return super().create(request, *args, **kwargs)
                 return super().create(request, *args, **kwargs)
@@ -122,7 +165,8 @@ class BulkUpdateModelMixin:
 
 
     def perform_bulk_update(self, objects, update_data, partial):
     def perform_bulk_update(self, objects, update_data, partial):
         updated_pks = []
         updated_pks = []
-        with transaction.atomic(using=router.db_for_write(self.queryset.model)):
+        using = router.db_for_write(self.queryset.model)
+        with transaction.atomic(using=using), discard_events_on_rollback(self, using=using):
             for obj in objects:
             for obj in objects:
                 data = update_data.get(obj.id)
                 data = update_data.get(obj.id)
                 if hasattr(obj, 'snapshot'):
                 if hasattr(obj, 'snapshot'):
@@ -185,7 +229,8 @@ class BulkDestroyModelMixin:
 
 
     def perform_bulk_destroy(self, objects, changelog_messages=None):
     def perform_bulk_destroy(self, objects, changelog_messages=None):
         changelog_messages = changelog_messages or {}
         changelog_messages = changelog_messages or {}
-        with transaction.atomic(using=router.db_for_write(self.queryset.model)):
+        using = router.db_for_write(self.queryset.model)
+        with transaction.atomic(using=using), discard_events_on_rollback(self, using=using):
             for obj in objects:
             for obj in objects:
                 if hasattr(obj, 'snapshot'):
                 if hasattr(obj, 'snapshot'):
                     obj.snapshot()
                     obj.snapshot()

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

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

+ 7 - 0
netbox/netbox/constants.py

@@ -74,3 +74,10 @@ CENSOR_TOKEN_CHANGED = '***CHANGED***'
 
 
 # Placeholder text for empty tables
 # Placeholder text for empty tables
 EMPTY_TABLE_TEXT = 'No results found'
 EMPTY_TABLE_TEXT = 'No results found'
+
+# Batch size for deleting a JobsMixin object's associated jobs during cascade deletion. Job
+# cannot be fast-deleted (a global pre_delete receiver forces per-instance signals), so deleting
+# in chunks bounds the work per delete cycle rather than building one huge collection and running
+# one long DELETE. 1000 matches EXPORT_CHUNK_SIZE and, in benchmarking a 200k-job deletion, was
+# the fastest of 100/1000/5000 while keeping peak memory flat. See #22812.
+JOB_DELETE_BATCH_SIZE = 1000

+ 14 - 11
netbox/netbox/context_managers.py

@@ -15,17 +15,20 @@ def event_tracking(request):
 
 
     :param request: WSGIRequest object with a unique `id` set
     :param request: WSGIRequest object with a unique `id` set
     """
     """
-    current_request.set(request)
-    events_queue.set({})
-    query_cache.set(defaultdict(dict))
+    request_token = current_request.set(request)
+    queue_token = events_queue.set({})
+    cache_token = query_cache.set(defaultdict(dict))
 
 
-    yield
+    try:
+        yield
 
 
-    # Flush queued webhooks to RQ
-    if events := list(events_queue.get().values()):
-        flush_events(events)
+        # Flush queued webhooks to RQ. This is done only if the wrapped block completed successfully; events
+        # queued by a failed request or job must not be dispatched.
+        if events := list(events_queue.get().values()):
+            flush_events(events)
 
 
-    # Clear context vars
-    current_request.set(None)
-    events_queue.set({})
-    query_cache.set(None)
+    finally:
+        # Restore the previous context vars, whether or not the wrapped block raised an exception
+        current_request.reset(request_token)
+        events_queue.reset(queue_token)
+        query_cache.reset(cache_token)

+ 6 - 3
netbox/netbox/denormalized.py

@@ -28,7 +28,7 @@ def register(model, field_name, mappings):
 
 
 
 
 @receiver(post_save)
 @receiver(post_save)
-def update_denormalized_fields(sender, instance, created, raw, **kwargs):
+def update_denormalized_fields(sender, instance, created, raw, using=None, **kwargs):
     """
     """
     Check if the sender has denormalized fields registered, and update them as necessary.
     Check if the sender has denormalized fields registered, and update them as necessary.
     """
     """
@@ -52,6 +52,9 @@ def update_denormalized_fields(sender, instance, created, raw, **kwargs):
         }
         }
 
 
         # TODO: Improve efficiency here by placing conditions on the query?
         # TODO: Improve efficiency here by placing conditions on the query?
-        # Update all the denormalized fields with the triggering object's new values
-        count = model.objects.filter(**filter_params).update(**update_params)
+        # Update all the denormalized fields with the triggering object's new values. The
+        # update is pinned to the connection the instance was saved on: letting a database
+        # router select one could write these values to a different database than the one
+        # holding the change which triggered them.
+        count = model.objects.using(using).filter(**filter_params).update(**update_params)
         logger.debug(f'Updated {count} rows')
         logger.debug(f'Updated {count} rows')

+ 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)
         return customfield.to_form_field(set_initial=False, enforce_required=False)
 
 
     def _extend_nullable_fields(self):
     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 = [
         nullable_custom_fields = [
             name for name, customfield in self.custom_fields.items()
             name for name, customfield in self.custom_fields.items()
             if (not customfield.required and customfield.ui_editable == CustomFieldUIEditableChoices.YES)
             if (not customfield.required and customfield.ui_editable == CustomFieldUIEditableChoices.YES)

+ 26 - 0
netbox/netbox/forms/bulk_import.py

@@ -1,4 +1,5 @@
 from django import forms
 from django import forms
+from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
 from django.db import models
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
@@ -70,6 +71,31 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
 
 
         return cleaned
         return cleaned
 
 
+    def _update_errors(self, errors):
+        """Convert errors for fields absent from the form to prefixed non-field errors."""
+        if hasattr(errors, 'error_dict'):
+            remapped = []
+            passthrough = {}
+            for field, error_list in errors.error_dict.items():
+                if field == NON_FIELD_ERRORS or field in self.fields:
+                    passthrough[field] = error_list
+                else:
+                    for error in error_list:
+                        message = next(iter(error))
+                        if error.params:
+                            message = message.replace('%', '%%')
+                        remapped.append(ValidationError(
+                            '{field}: {message}'.format(field=field, message=message),
+                            code=error.code,
+                            params=error.params,
+                        ))
+            if passthrough:
+                super()._update_errors(ValidationError(passthrough))
+            for error in remapped:
+                self.add_error(None, error)
+        else:
+            super()._update_errors(errors)
+
 
 
 class OwnerCSVMixin(forms.Form):
 class OwnerCSVMixin(forms.Form):
     owner = CSVModelChoiceField(
     owner = CSVModelChoiceField(

+ 43 - 0
netbox/netbox/graphql/optimization.py

@@ -0,0 +1,43 @@
+from collections.abc import Callable, Sequence
+
+from django.contrib.contenttypes.prefetch import GenericPrefetch
+from django.db.models import Model, QuerySet
+from strawberry.types import Info
+from strawberry_django.optimizer import optimize
+from strawberry_django.optimizer import optimizer as optimizer_ctx
+
+__all__ = (
+    'build_gfk_prefetch',
+    'optimize_prefetch_queryset',
+)
+
+
+def optimize_prefetch_queryset(queryset: QuerySet, info: Info) -> QuerySet:
+    """
+    Apply strawberry-django's query optimizer to a queryset used inside a GenericForeignKey prefetch.
+    """
+    if ext := optimizer_ctx.get():
+        return ext.optimize(queryset, info)
+
+    return optimize(queryset, info)
+
+
+def build_gfk_prefetch(
+    lookup: str,
+    models: Sequence[type[Model]],
+) -> Callable[[Info], GenericPrefetch]:
+    """
+    Return a selection-aware GenericPrefetch for a GenericForeignKey field.
+
+    Each model gets its own queryset, optimized according to the client's GraphQL selection set.
+    """
+
+    def prefetch(info: Info) -> GenericPrefetch:
+        querysets = [
+            optimize_prefetch_queryset(model.objects.all(), info)
+            for model in models
+        ]
+
+        return GenericPrefetch(lookup, querysets)
+
+    return prefetch

+ 66 - 1
netbox/netbox/graphql/pagination.py

@@ -1,12 +1,15 @@
 import strawberry
 import strawberry
+from django.db import DEFAULT_DB_ALIAS
+from django.db.models.functions import DenseRank
 from strawberry.types.unset import UNSET
 from strawberry.types.unset import UNSET
-from strawberry_django.pagination import _QS, apply
+from strawberry_django.pagination import _QS, _PaginationWindow, _resolve_limit, apply
 
 
 from netbox.config import get_config
 from netbox.config import get_config
 
 
 __all__ = (
 __all__ = (
     'OffsetPaginationInfo',
     'OffsetPaginationInfo',
     'OffsetPaginationInput',
     'OffsetPaginationInput',
+    'apply_distinct_window_pagination',
     'apply_pagination',
     'apply_pagination',
 )
 )
 
 
@@ -26,6 +29,58 @@ class OffsetPaginationInput(OffsetPaginationInfo):
     pass
     pass
 
 
 
 
+def apply_distinct_window_pagination(
+    queryset: _QS,
+    *,
+    related_field_id: str,
+    offset: int = 0,
+    limit: int | None = UNSET,
+) -> _QS:
+    """
+    Replacement for strawberry-django's `apply_window_pagination()` for a queryset which has `DISTINCT`
+    enabled, as is the case when a list field is filtered across a to-many relation with `DISTINCT: true`.
+
+    SQL evaluates window functions before `DISTINCT`, so the `ROW_NUMBER()` annotation which
+    strawberry-django uses to paginate a prefetched relation assigns a unique value to each of the
+    duplicate rows produced by the join, and `DISTINCT` can never collapse them. `DENSE_RANK()` instead
+    assigns the same rank to every row which compares equal under the window ordering, leaving the
+    duplicate rows identical so that `DISTINCT` deduplicates them as intended. And because the rank is
+    incremented only once per distinct row, the rows are numbered as if the duplicates were never there,
+    keeping the pagination limit meaningful.
+    """
+    limit = _resolve_limit(limit)
+
+    order_by = [
+        expr
+        for expr, _ in queryset.query.get_compiler(
+            using=queryset._db or DEFAULT_DB_ALIAS
+        ).get_order_by()
+    ]
+    # Order by the primary key as well, to ensure that two rows representing *different* objects can
+    # never be assigned the same rank (and hence be counted only once against the limit).
+    order_by.append('pk')
+
+    # Note that we omit the `_strawberry_total_count` annotation which strawberry-django adds, as it
+    # cannot be made accurate here: window functions are evaluated before `DISTINCT`, so it would count
+    # the duplicate rows. strawberry-django's `get_total_count()` already disregards the annotation for
+    # a queryset with `DISTINCT` enabled and falls back to `count()`, so computing it would be wasted
+    # work: an extra window aggregate over every joined row.
+    queryset = queryset.annotate(
+        _strawberry_row_number=_PaginationWindow(
+            DenseRank(),
+            partition_by=related_field_id,
+            order_by=order_by,
+        ),
+    )
+
+    if offset:
+        queryset = queryset.filter(_strawberry_row_number__gt=offset)
+    if limit is not None and limit >= 0:
+        queryset = queryset.filter(_strawberry_row_number__lte=offset + limit)
+
+    return queryset
+
+
 def apply_pagination(
 def apply_pagination(
     self,
     self,
     queryset: _QS,
     queryset: _QS,
@@ -71,4 +126,14 @@ def apply_pagination(
         elif pagination.limit <= 0:
         elif pagination.limit <= 0:
             pagination.limit = max_page_size
             pagination.limit = max_page_size
 
 
+    # A prefetched relation is paginated with a window function, which is incompatible with the
+    # `DISTINCT` applied by the filter layer. Fall back to our own implementation in that case.
+    if pagination is not None and related_field_id is not None and queryset.query.distinct:
+        return apply_distinct_window_pagination(
+            queryset,
+            related_field_id=related_field_id,
+            offset=pagination.offset,
+            limit=pagination.limit,
+        )
+
     return apply(pagination, queryset, related_field_id=related_field_id)
     return apply(pagination, queryset, related_field_id=related_field_id)

+ 66 - 0
netbox/netbox/models/deletion.py

@@ -8,6 +8,72 @@ from django.utils.translation import gettext as _
 logger = logging.getLogger("netbox.models.deletion")
 logger = logging.getLogger("netbox.models.deletion")
 
 
 
 
+class CountOnly:
+    """
+    A stand-in for a list of dependent instances that reports a count without holding any
+    instances. Used on the delete-confirmation page for high-cardinality relations (e.g. a
+    JobsMixin object's jobs) which we deliberately do not materialize (see #22812). It is a
+    lenient, empty iterable: `len()` returns the true row count, but iterating yields nothing,
+    so it slots into the same `{model: <iterable>}` mapping as real instance lists and renders
+    as a non-expandable row.
+    """
+    # Template flag: distinguishes a count-only entry (no instances to list) from a real list,
+    # so the confirmation page can render it without an expand/collapse affordance.
+    count_only = True
+
+    def __init__(self, count):
+        self.count = count
+
+    def __len__(self):
+        return self.count
+
+    def __iter__(self):
+        return iter(())
+
+
+class ConfirmCollector(Collector):
+    """
+    A display-only Collector used to enumerate the objects that would be deleted along with a
+    given object, for rendering the delete confirmation page. It behaves like Django's stock
+    Collector (preserving the full FK cascade graph and its ProtectedError/RestrictedError
+    behavior) except that it does not descend into the `jobs` GenericRelation. A JobsMixin
+    object can accumulate thousands of Jobs, each carrying large data/log_entries payloads;
+    materializing them all just to render a confirmation page can exhaust memory (see #22812).
+    Instead, the related Jobs are counted and recorded in `generic_relation_counts`.
+
+    This is intentionally specific to Job, the only high-cardinality GenericRelation in the
+    data model; it is not a general count-out over every GenericRelation. If another relation
+    ever needs the same treatment, extend the check in collect() (and the matching write-path
+    batching in JobsMixin/ScriptModule.delete) rather than assuming this already handles it.
+    """
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.generic_relation_counts = {}
+
+    def collect(self, objs, source=None, *args, **kwargs):
+        """
+        Override collect() to count the `jobs` GenericRelation rather than descend into it.
+
+        Django's Collector offers no per-relation skip hook, so we intercept the one call it
+        makes when cascading into a GenericRelation: collect(sub_objs, source=model, ...), where
+        `sub_objs` is a queryset of the related model. When that model is Job, we count the rows
+        instead of collecting (and thus instantiating) them, and forward every other call to the
+        stock implementation untouched. A directly-deleted Job (top-level call, source=None)
+        still collects normally.
+        """
+        from core.models import Job
+
+        if source is not None and getattr(objs, 'model', None) is Job:
+            # Django calls this branch for the jobs relation even when there are none; only record
+            # a count when there are actually jobs, so jobless objects don't get a spurious
+            # "0 jobs" row on the delete-confirmation page.
+            count = objs.count()
+            if count:
+                self.generic_relation_counts[Job] = self.generic_relation_counts.get(Job, 0) + count
+            return None
+        return super().collect(objs, source=source, *args, **kwargs)
+
+
 class CustomCollector(Collector):
 class CustomCollector(Collector):
     """
     """
     Override Django's stock Collector to handle GenericRelations and ensure proper ordering of cascading deletions.
     Override Django's stock Collector to handle GenericRelations and ensure proper ordering of cascading deletions.

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

@@ -5,7 +5,7 @@ from functools import cached_property
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
-from django.db import models
+from django.db import models, router, transaction
 from django.db.models import Q
 from django.db.models import Q
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
@@ -17,7 +17,7 @@ from extras.constants import CUSTOMFIELD_EMPTY_VALUES
 from extras.managers import NetBoxTaggableManager, NetBoxTaggableManagerField
 from extras.managers import NetBoxTaggableManager, NetBoxTaggableManagerField
 from extras.utils import is_taggable
 from extras.utils import is_taggable
 from netbox.config import get_config
 from netbox.config import get_config
-from netbox.constants import CORE_APPS
+from netbox.constants import CORE_APPS, JOB_DELETE_BATCH_SIZE
 from netbox.models.deletion import DeleteMixin
 from netbox.models.deletion import DeleteMixin
 from netbox.plugins import PluginConfig
 from netbox.plugins import PluginConfig
 from netbox.registry import registry
 from netbox.registry import registry
@@ -43,6 +43,7 @@ __all__ = (
     'NotificationsMixin',
     'NotificationsMixin',
     'SyncedDataMixin',
     'SyncedDataMixin',
     'TagsMixin',
     'TagsMixin',
+    'batch_delete_jobs',
     'get_model_features',
     'get_model_features',
     'has_feature',
     'has_feature',
     'model_is_public',
     'model_is_public',
@@ -449,9 +450,39 @@ class NotificationsMixin(models.Model):
         abstract = True
         abstract = True
 
 
 
 
+def batch_delete_jobs(job_queryset):
+    """
+    Delete the Jobs in `job_queryset` in JOB_DELETE_BATCH_SIZE chunks. Job cannot be fast-deleted
+    (a global pre_delete receiver forces per-instance signals), so a single delete would build one
+    huge collection of Job instances and run one very long DELETE; batching bounds the per-cycle
+    work. Callers are responsible for wrapping this in a transaction. As with the prior cascade
+    behavior, this bulk delete does not invoke Job.delete() and therefore does not cancel the
+    backing RQ job. See #22812.
+    """
+    from core.models import Job
+
+    # Route writes to the same database the queryset reads from. In JobsMixin.delete the queryset
+    # is bound to the instance's DB while Job.objects would otherwise use the router default; if
+    # those diverge the deleted rows never leave the read side and the loop below never terminates.
+    jobs = Job.objects.using(job_queryset.db)
+
+    job_pks = job_queryset.order_by('pk').values_list('pk', flat=True)
+    # Re-slice the queryset each iteration: it re-queries after each batch delete, so the
+    # remaining set shrinks and the loop terminates (do not hoist this into a cursor).
+    while pks := list(job_pks[:JOB_DELETE_BATCH_SIZE]):
+        # only('pk'): the batch still can't fast-delete, so each Job in the batch is instantiated;
+        # loading just the PK avoids pulling the large data/log_entries payloads into memory.
+        jobs.filter(pk__in=pks).only('pk').delete()
+
+
 class JobsMixin(models.Model):
 class JobsMixin(models.Model):
     """
     """
     Enables support for job results.
     Enables support for job results.
+
+    Note: for the job-batching in delete() to run, JobsMixin must precede DeleteMixin in a
+    model's MRO. DeleteMixin.delete() drives its own collector and does not call super(), so a
+    model declared as e.g. `class Foo(NetBoxModel, JobsMixin)` would reach DeleteMixin first and
+    bypass the batching. Core models that combine both (e.g. DataSource) list JobsMixin first.
     """
     """
     jobs = GenericRelation(
     jobs = GenericRelation(
         to='core.Job',
         to='core.Job',
@@ -463,6 +494,16 @@ class JobsMixin(models.Model):
     class Meta:
     class Meta:
         abstract = True
         abstract = True
 
 
+    def delete(self, using=None, *args, **kwargs):
+        # Delete associated jobs in batches so the cascade never has to load thousands of Job
+        # rows into memory at once. Wrapped in a transaction so that a failure in the parent
+        # delete rolls the job deletions back as well. See #22812.
+        using = using or router.db_for_write(self.__class__, instance=self)
+        with transaction.atomic(using=using):
+            batch_delete_jobs(self.jobs.using(using))
+            return super().delete(using, *args, **kwargs)
+    delete.alters_data = True
+
     def get_latest_jobs(self):
     def get_latest_jobs(self):
         """
         """
         Return a list of the most recent jobs for this instance.
         Return a list of the most recent jobs for this instance.

+ 1 - 4
netbox/netbox/settings.py

@@ -413,6 +413,7 @@ CACHES = {
         'LOCATION': CACHING_REDIS_URL,
         'LOCATION': CACHING_REDIS_URL,
         'OPTIONS': {
         'OPTIONS': {
             'CLIENT_CLASS': 'django_redis.client.DefaultClient',
             'CLIENT_CLASS': 'django_redis.client.DefaultClient',
+            'USERNAME': CACHING_REDIS_USERNAME,
             'PASSWORD': CACHING_REDIS_PASSWORD,
             'PASSWORD': CACHING_REDIS_PASSWORD,
         }
         }
     }
     }
@@ -634,10 +635,6 @@ SERIALIZATION_MODULES = {
 
 
 DEBUG_TOOLBAR_CONFIG = {
 DEBUG_TOOLBAR_CONFIG = {
     'SHOW_TOOLBAR_CALLBACK': 'utilities.debug.show_toolbar',
     'SHOW_TOOLBAR_CALLBACK': 'utilities.debug.show_toolbar',
-    # The GraphiQL integration provided by strawberry-django locates the toolbar via
-    # document.getElementById('djDebug'), which fails when the toolbar is rendered inside a
-    # shadow root (the default as of django-debug-toolbar v7.0).
-    'USE_SHADOW_DOM': False,
 }
 }
 
 
 
 

+ 38 - 21
netbox/netbox/settings_utils.py

@@ -4,6 +4,7 @@ import importlib
 import importlib.util
 import importlib.util
 import os
 import os
 import sys
 import sys
+import threading
 import warnings
 import warnings
 from typing import NamedTuple
 from typing import NamedTuple
 
 
@@ -91,32 +92,48 @@ def _import_module(name):
         raise
         raise
 
 
 
 
+# Serializes cache checks, module execution, and the temporary sys.path change.
+# Reentrant because configuration code may load another path-based module.
+_import_lock = threading.RLock()
+
+
 def _import_from_path(module_name, path):
 def _import_from_path(module_name, path):
     """Load a configuration module from an explicit file path.
     """Load a configuration module from an explicit file path.
 
 
-    The module is registered in sys.modules (and removed again if execution fails), and the
-    file's directory is placed on sys.path for the duration of execution so the module can
-    import siblings, matching normal import semantics closely enough for configuration files.
+    The module is registered in sys.modules while it executes, and the file's directory is
+    placed on sys.path for that duration so the module can import siblings, matching normal
+    import semantics closely enough for configuration files. A module already loaded under the
+    same name from the same path is reused, while the same name from a different path replaces
+    it. A failed load leaves the previous entry in place. Loading is serialized so that a
+    concurrent caller cannot observe a module mid-execution.
     """
     """
     path = os.path.abspath(path)
     path = os.path.abspath(path)
-    module_dir = os.path.dirname(path)
-    spec = importlib.util.spec_from_file_location(module_name, path)
-    if spec is None or spec.loader is None:
-        raise ImproperlyConfigured(f"Unable to load configuration file {path}")
-    module = importlib.util.module_from_spec(spec)
-    sys.modules[module_name] = module
-    sys.path.insert(0, module_dir)
-    try:
-        spec.loader.exec_module(module)
-    except Exception:
-        if sys.modules.get(module_name) is module:
-            del sys.modules[module_name]
-        raise
-    finally:
-        # Remove only the entry this helper inserted at index 0.
-        if sys.path and sys.path[0] == module_dir:
-            sys.path.pop(0)
-    return module
+    with _import_lock:
+        existing = sys.modules.get(module_name)
+        existing_path = getattr(existing, '__file__', None)
+        if existing_path and os.path.abspath(existing_path) == path:
+            return existing
+        module_dir = os.path.dirname(path)
+        spec = importlib.util.spec_from_file_location(module_name, path)
+        if spec is None or spec.loader is None:
+            raise ImproperlyConfigured(f"Unable to load configuration file {path}")
+        module = importlib.util.module_from_spec(spec)
+        sys.modules[module_name] = module
+        sys.path.insert(0, module_dir)
+        try:
+            spec.loader.exec_module(module)
+        except Exception:
+            if sys.modules.get(module_name) is module:
+                if existing is None:
+                    del sys.modules[module_name]
+                else:
+                    sys.modules[module_name] = existing
+            raise
+        finally:
+            # Remove only the entry this helper inserted at index 0.
+            if sys.path and sys.path[0] == module_dir:
+                sys.path.pop(0)
+        return module
 
 
 
 
 def get_configuration_dir(module):
 def get_configuration_dir(module):

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

@@ -6,7 +6,7 @@ import django_tables2 as tables
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth.context_processors import auth
 from django.contrib.auth.context_processors import auth
 from django.contrib.auth.models import AnonymousUser
 from django.contrib.auth.models import AnonymousUser
-from django.db.models import DateField, DateTimeField
+from django.db.models import DateField, DateTimeField, Q
 from django.template import Context, Template
 from django.template import Context, Template
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.dateparse import parse_date
 from django.utils.dateparse import parse_date
@@ -522,9 +522,46 @@ class CustomFieldColumn(tables.Column):
             CustomFieldTypeChoices.TYPE_MULTIOBJECT
             CustomFieldTypeChoices.TYPE_MULTIOBJECT
         ):
         ):
             kwargs['orderable'] = False
             kwargs['orderable'] = False
+        else:
+            kwargs.setdefault('order_by', (
+                self.unset_alias,
+                f'custom_field_data__{customfield.name}',
+            ))
 
 
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
+    @property
+    def unset_alias(self):
+        """
+        Return the name of the annotation which groups together the objects holding no value for
+        this field (see get_ordering_annotation()).
+
+        The annotation is named for the custom field so that ordering by two custom field columns
+        cannot produce a duplicate alias. Field names are validated to contain only alphanumerics
+        and underscores, so the alias is always a legal identifier.
+        """
+        return f'_cf_{self.customfield.name}_unset'
+
+    def get_ordering_annotation(self):
+        """
+        Return the annotation by which objects holding no value for this field are sorted together,
+        as the leading sort key for the column. (BaseTable applies it to the queryset when ordering
+        by this column.)
+
+        An object can lack a value either by storing a JSON null or by carrying no key for the
+        field at all -- the latter being the normal state for objects which predate it, as data is
+        no longer provisioned onto existing objects (see CustomField.populate_initial_data()).
+        Postgres sorts those two apart: a JSON null is the lowest jsonb value, whereas a missing
+        key yields SQL NULL and sorts last, so the "empty" rows would otherwise land at both ends
+        of the same column. This key (the `empty` lookup covers both states) groups them at one
+        end, matching how SQL NULLs are ordered for an ordinary column: last when ascending, first
+        when descending. The column's second sort key then orders by the raw value, so that numeric
+        and date fields still sort by type rather than lexically.
+        """
+        return {
+            self.unset_alias: Q(**{f'custom_field_data__{self.customfield.name}__empty': True})
+        }
+
     @staticmethod
     @staticmethod
     def _linkify_item(item):
     def _linkify_item(item):
         if hasattr(item, 'get_absolute_url'):
         if hasattr(item, 'get_absolute_url'):
@@ -623,7 +660,9 @@ class CustomLinkColumn(tables.Column):
                 return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
                 return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
         except Exception as e:
         except Exception as e:
             error_text = _('Error')
             error_text = _('Error')
-            return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> {error_text}</span>')
+            return format_html(
+                '<span class="text-danger" title="{}"><i class="mdi mdi-alert"></i> {}</span>', e, error_text
+            )
         return ''
         return ''
 
 
     def value(self, record, table, **kwargs):
     def value(self, record, table, **kwargs):

+ 63 - 0
netbox/netbox/tables/tables.py

@@ -12,6 +12,7 @@ from django.urls.exceptions import NoReverseMatch
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from django_tables2.data import TableQuerysetData
 from django_tables2.data import TableQuerysetData
+from django_tables2.utils import OrderBy
 
 
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
@@ -158,6 +159,68 @@ class BaseTable(tables.Table):
                 prefetch_fields.append('__'.join(prefetch_path))
                 prefetch_fields.append('__'.join(prefetch_path))
         self.data.data = self.data.data.prefetch_related(*prefetch_fields)
         self.data.data = self.data.data.prefetch_related(*prefetch_fields)
 
 
+    def _get_custom_field_ordering_columns(self, order_by):
+        """
+        Return the custom field columns among those named by the given ordering.
+
+        Args:
+            order_by: An iterable (or comma-separated string) of order by aliases.
+        """
+        order_by = order_by.split(',') if isinstance(order_by, str) else order_by or ()
+        ordering_columns = []
+        for alias in order_by:
+            name = OrderBy(alias).bare
+            # Ignore any aliases which django-tables2 will itself discard
+            if name not in self.columns or not self.columns[name].orderable:
+                continue
+            if isinstance(column := self.columns[name].column, columns.CustomFieldColumn):
+                ordering_columns.append(column)
+        return ordering_columns
+
+    def _apply_ordering_annotations(self, ordering_columns):
+        """
+        Dynamically annotate the table's QuerySet with the expressions needed to sort by the given
+        custom field columns. These are applied only for the columns actually being ordered by, to
+        avoid burdening every query with expressions it has no use for.
+        """
+        annotations = {}
+        for column in ordering_columns:
+            annotations.update(column.get_ordering_annotation())
+
+        # Skip any annotations already applied, as when the ordering is set more than once
+        if annotations := {
+            name: expr for name, expr in annotations.items()
+            if name not in self.data.data.query.annotations
+        }:
+            self.data.data = self.data.data.annotate(**annotations)
+
+    def _apply_ordering_tie_breaker(self):
+        """
+        Append the primary key to the table's ordering as a final sort key, so that the ordering is
+        total. Rows tying on every preceding key -- and every object holding no value for a custom
+        field ties on both of that column's keys -- are otherwise free to come back in a different
+        order for each query, which would cause paginated results to skip or repeat rows from one
+        page to the next.
+        """
+        ordering = self.data.data.query.order_by
+        if ordering and not any(OrderBy(o).bare in ('pk', 'id') for o in ordering):
+            self.data.data = self.data.data.order_by(*ordering, 'pk')
+
+    @tables.Table.order_by.setter
+    def order_by(self, value):
+        """
+        Extend the ordering of the table's data with the support needed by custom field columns.
+        """
+        if not isinstance(self.data, TableQuerysetData):
+            tables.Table.order_by.fset(self, value)
+            return
+
+        if ordering_columns := self._get_custom_field_ordering_columns(value):
+            self._apply_ordering_annotations(ordering_columns)
+        tables.Table.order_by.fset(self, value)
+        if ordering_columns:
+            self._apply_ordering_tie_breaker()
+
     def configure(self, request):
     def configure(self, request):
         """
         """
         Configure the table for a specific request context. This performs pagination and records
         Configure the table for a specific request context. This performs pagination and records

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

@@ -1,4 +1,5 @@
 from django.apps import apps
 from django.apps import apps
+from django.core.exceptions import FieldDoesNotExist
 from django.test import TestCase
 from django.test import TestCase
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
 
 
@@ -134,6 +135,16 @@ class FormClassesTestCase(TestCase):
             return NetBoxModelFilterSetForm
             return NetBoxModelFilterSetForm
         return None
         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):
     def test_model_form_base_classes(self):
         """
         """
         Check that each model form inherits from the appropriate base class.
         Check that each model form inherits from the appropriate base class.
@@ -152,6 +163,68 @@ class FormClassesTestCase(TestCase):
                 form_class = self.get_form_for_model(model, prefix='BulkEdit')
                 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}")
                 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):
     def test_import_form_base_classes(self):
         """
         """
         Check that each bulk import form inherits from the appropriate base class.
         Check that each bulk import form inherits from the appropriate base class.

+ 87 - 0
netbox/netbox/tests/test_context_managers.py

@@ -0,0 +1,87 @@
+import uuid
+from unittest.mock import patch
+
+from django.test import RequestFactory, SimpleTestCase
+
+from netbox.context import current_request, events_queue, query_cache
+from netbox.context_managers import event_tracking
+
+
+def _build_request():
+    request = RequestFactory().get('/')
+    request.id = uuid.uuid4()
+    request.user = None
+    return request
+
+
+class EventTrackingTestCase(SimpleTestCase):
+    """
+    Verify that event_tracking() populates and restores its context variables.
+    """
+    def assertContextVarsRestored(self):
+        self.assertIsNone(current_request.get())
+        self.assertEqual(events_queue.get(), {})
+        self.assertIsNone(query_cache.get())
+
+    def test_context_vars_set_within_block(self):
+        request = _build_request()
+
+        with event_tracking(request):
+            self.assertIs(current_request.get(), request)
+            self.assertEqual(events_queue.get(), {})
+            self.assertIsNotNone(query_cache.get())
+
+        self.assertContextVarsRestored()
+
+    def test_context_vars_restored_after_exception(self):
+        request = _build_request()
+
+        with self.assertRaises(RuntimeError):
+            with event_tracking(request):
+                raise RuntimeError('simulated view/script failure')
+
+        self.assertContextVarsRestored()
+
+    def test_events_flushed_on_success(self):
+        request = _build_request()
+
+        with patch('netbox.context_managers.flush_events') as flush_events:
+            with event_tracking(request):
+                events_queue.get()['foo'] = 'bar'
+
+        flush_events.assert_called_once_with(['bar'])
+
+    def test_events_not_flushed_after_exception(self):
+        request = _build_request()
+
+        with patch('netbox.context_managers.flush_events') as flush_events:
+            with self.assertRaises(RuntimeError):
+                with event_tracking(request):
+                    events_queue.get()['foo'] = 'bar'
+                    raise RuntimeError('simulated view/script failure')
+
+        flush_events.assert_not_called()
+
+    def test_nested_context_restores_outer_values(self):
+        outer_request = _build_request()
+        inner_request = _build_request()
+
+        with patch('netbox.context_managers.flush_events'):
+            with event_tracking(outer_request):
+                outer_cache = query_cache.get()
+                outer_queue = events_queue.get()
+                outer_queue['outer'] = 'event'
+
+                with event_tracking(inner_request):
+                    self.assertIs(current_request.get(), inner_request)
+                    self.assertIsNot(events_queue.get(), outer_queue)
+                    self.assertEqual(events_queue.get(), {})
+
+                # The outer request's context must be restored intact, including any events it had
+                # already queued
+                self.assertIs(current_request.get(), outer_request)
+                self.assertIs(query_cache.get(), outer_cache)
+                self.assertIs(events_queue.get(), outer_queue)
+                self.assertEqual(events_queue.get(), {'outer': 'event'})
+
+        self.assertContextVarsRestored()

+ 103 - 6
netbox/netbox/tests/test_forms.py

@@ -1,3 +1,8 @@
+from unittest.mock import patch
+
+from django.core.exceptions import NON_FIELD_ERRORS
+from django.core.exceptions import ValidationError as DjangoValidationError
+from django.core.validators import MaxLengthValidator
 from django.test import TestCase
 from django.test import TestCase
 
 
 from dcim.choices import InterfaceTypeChoices
 from dcim.choices import InterfaceTypeChoices
@@ -5,12 +10,8 @@ from dcim.forms import InterfaceImportForm
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
 
 
 
 
-class NetBoxModelImportFormCleanTestCase(TestCase):
-    """
-    Test the clean() method of NetBoxModelImportForm to ensure it properly converts
-    empty strings to None for nullable fields during CSV import.
-    Uses InterfaceImportForm as the concrete implementation to test.
-    """
+class NetBoxModelImportFormTestCase(TestCase):
+    """Test NetBoxModelImportForm."""
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -301,3 +302,99 @@ class NetBoxModelImportFormCleanTestCase(TestCase):
         )
         )
         self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
         self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
         self.assertIsNone(form.cleaned_data['wwn'])
         self.assertIsNone(form.cleaned_data['wwn'])
+
+    def test_missing_field_validation_error_becomes_non_field_error(self):
+        """Convert validation errors for absent fields to non-field errors."""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Test Interface',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+            }
+        )
+        with patch.object(
+            form.instance,
+            'full_clean',
+            side_effect=DjangoValidationError({'absent_field': ['Field error.']}),
+        ):
+            result = form.is_valid()
+
+        self.assertFalse(result)
+        self.assertIn('absent_field: Field error.', form.non_field_errors())
+
+    def test_non_field_error_not_overwritten_by_remapped_missing_field_error(self):
+        """Preserve remapped and existing non-field errors."""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Test Interface Mixed',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        # absent_field appears first to expose the former overwrite bug
+        form._update_errors(DjangoValidationError({
+            'absent_field': ['Field error.'],
+            NON_FIELD_ERRORS: ['A general error.'],
+        }))
+        non_field_errors = form.non_field_errors()
+        self.assertIn('A general error.', non_field_errors)
+        self.assertIn('absent_field: Field error.', non_field_errors)
+
+    def test_remapped_error_preserves_code_and_params(self):
+        """Preserve the original ValidationError code and params when remapping."""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Test Interface Params',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        form._update_errors(DjangoValidationError({
+            'absent_field': [
+                DjangoValidationError(
+                    '%(value)s is not a valid value.',
+                    code='invalid_value',
+                    params={'value': '100%'},
+                ),
+            ],
+        }))
+        non_field_errors = form.non_field_errors()
+        self.assertIn(
+            'absent_field: 100% is not a valid value.',
+            non_field_errors,
+        )
+        error_data = form.errors[NON_FIELD_ERRORS].as_data()
+        matching = [error for error in error_data if error.code == 'invalid_value']
+        self.assertEqual(len(matching), 1)
+        self.assertEqual(matching[0].params, {'value': '100%'})
+
+    def test_remapped_error_preserves_pluralized_message(self):
+        """Preserve pluralization order when remapping an ngettext_lazy message."""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Test Interface Plural',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        form._update_errors(DjangoValidationError({
+            'absent_field': [
+                DjangoValidationError(
+                    MaxLengthValidator.message,
+                    code=MaxLengthValidator.code,
+                    params={'limit_value': 20, 'show_value': 25},
+                ),
+            ],
+        }))
+        non_field_errors = form.non_field_errors()
+        self.assertIn(
+            'absent_field: Ensure this value has at most 20 characters (it has 25).',
+            non_field_errors,
+        )
+        error_data = form.errors[NON_FIELD_ERRORS].as_data()
+        matching = [error for error in error_data if error.code == MaxLengthValidator.code]
+        self.assertEqual(len(matching), 1)
+        self.assertEqual(matching[0].params, {'limit_value': 20, 'show_value': 25})

+ 333 - 0
netbox/netbox/tests/test_graphql.py

@@ -18,6 +18,7 @@ from dcim.models import (
     Device,
     Device,
     DeviceRole,
     DeviceRole,
     DeviceType,
     DeviceType,
+    Interface,
     Location,
     Location,
     Manufacturer,
     Manufacturer,
     Rack,
     Rack,
@@ -28,6 +29,7 @@ from dcim.models import (
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
 from extras.models import CustomField, TableConfig, Tag
 from extras.models import CustomField, TableConfig, Tag
 from ipam.models import RIR, Aggregate, IPAddress, Prefix
 from ipam.models import RIR, Aggregate, IPAddress, Prefix
+from netbox.graphql.pagination import apply_distinct_window_pagination
 from netbox.graphql.scalars import BigInt, BigIntScalar
 from netbox.graphql.scalars import BigInt, BigIntScalar
 from netbox.graphql.schema import Query, get_schema_extensions, schema
 from netbox.graphql.schema import Query, get_schema_extensions, schema
 from users.models import Token, User
 from users.models import Token, User
@@ -35,6 +37,12 @@ from utilities.tables import get_table_for_model
 from utilities.testing import APITestCase, APIViewTestCases, TestCase, disable_warnings
 from utilities.testing import APITestCase, APIViewTestCases, TestCase, disable_warnings
 
 
 
 
+def count_primary_table_queries(queries, table):
+    """Count queries that read from `table` as the primary relation (not only as a join)."""
+    pattern = re.compile(rf'FROM "{re.escape(table)}"')
+    return sum(1 for query_record in queries if pattern.search(query_record['sql']))
+
+
 class GraphQLTestCase(TestCase):
 class GraphQLTestCase(TestCase):
 
 
     def _schema_extension_instances(self):
     def _schema_extension_instances(self):
@@ -412,6 +420,31 @@ class GraphQLAPITestCase(APITestCase):
         self.assertNotIn('errors', data)
         self.assertNotIn('errors', data)
         self.assertEqual(int(data['data']['table_config']['object_type']['id']), site_ct.pk)
         self.assertEqual(int(data['data']['table_config']['object_type']['id']), site_ct.pk)
 
 
+    def test_graphql_custom_fields_include_unset_fields(self):
+        """
+        CustomFieldsMixin.custom_fields must emit a key for every custom field assigned to the model,
+        as the REST API does, rather than returning the stored data verbatim. A key is materialized
+        only once a value is assigned, so an object predating a field carries none; without this such
+        a field would be absent from the response instead of null. Stale data for a field which no
+        longer applies is likewise omitted.
+        """
+        self.add_permissions('dcim.view_site')
+        url = reverse('graphql')
+
+        cf = CustomField.objects.create(name='cf1', type=CustomFieldTypeChoices.TYPE_TEXT)
+        cf.object_types.set([ObjectType.objects.get_for_model(Site)])
+
+        site = Site.objects.get(slug='site-1')
+        self.assertNotIn('cf1', site.custom_field_data)
+        Site.objects.filter(pk=site.pk).update(custom_field_data={'stale': 'value'})
+
+        query = '{ site(id: ' + str(site.pk) + ') { custom_fields } }'
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(data['data']['site']['custom_fields'], {'cf1': None})
+
     @override_settings(LOGIN_REQUIRED=True)
     @override_settings(LOGIN_REQUIRED=True)
     def test_graphql_device_list_tags_are_prefetched(self):
     def test_graphql_device_list_tags_are_prefetched(self):
         """
         """
@@ -469,6 +502,149 @@ class GraphQLAPITestCase(APITestCase):
             msg=f'Expected batched tag prefetch, got {tag_queries} tag queries for 10 devices',
             msg=f'Expected batched tag prefetch, got {tag_queries} tag queries for 10 devices',
         )
         )
 
 
+    def test_graphql_ip_address_list_assigned_object(self):
+        """
+        Requesting assigned_object should batch prefetch related objects.
+        """
+        self.add_permissions('ipam.view_ipaddress', 'dcim.view_interface', 'dcim.view_device')
+
+        site = Site.objects.first()
+        manufacturer = Manufacturer.objects.create(name='Assigned Object Manufacturer', slug='assigned-object-mfg')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer,
+            model='Assigned Object Model',
+            slug='assigned-object-model',
+        )
+        device_role = DeviceRole.objects.create(name='Assigned Object Role', slug='assigned-object-role')
+        device = Device.objects.create(
+            name='Assigned Object Device',
+            site=site,
+            device_type=device_type,
+            role=device_role,
+        )
+        interface = Interface.objects.create(name='eth0', device=device, type='1000baset')
+        ip_addresses = IPAddress.objects.bulk_create([
+            IPAddress(address=f'192.0.2.{index}/24', assigned_object=interface)
+            for index in range(1, 6)
+        ])
+        ip_ids = json.dumps([str(ip.pk) for ip in ip_addresses])
+
+        query = f"""
+        {{
+            ip_address_list(filters: {{id: {{in_list: {ip_ids}}}}}) {{
+                address
+                assigned_object {{
+                    ... on InterfaceType {{
+                        name
+                        device {{
+                            name
+                        }}
+                    }}
+                }}
+            }}
+        }}
+        """
+        url = reverse('graphql')
+
+        with CaptureQueriesContext(connection) as context:
+            response = self.client.post(url, data={'query': query}, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['ip_address_list']), len(ip_addresses))
+
+        device_queries = count_primary_table_queries(context.captured_queries, 'dcim_device')
+        self.assertLessEqual(
+            device_queries,
+            2,
+            msg=f'Expected batched assigned_object prefetch, got {device_queries} device queries for 5 IP addresses',
+        )
+
+    def test_graphql_ip_address_list_assigned_object_nested_site(self):
+        """
+        Nested assigned_object selections should be optimized on the GFK prefetch queryset.
+        """
+        self.add_permissions(
+            'ipam.view_ipaddress',
+            'dcim.view_interface',
+            'dcim.view_device',
+            'dcim.view_site',
+        )
+
+        site = Site.objects.first()
+        manufacturer = Manufacturer.objects.create(
+            name='Nested Site Manufacturer',
+            slug='nested-site-mfg',
+        )
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer,
+            model='Nested Site Model',
+            slug='nested-site-model',
+        )
+        device_role = DeviceRole.objects.create(name='Nested Site Role', slug='nested-site-role')
+        interfaces = []
+        for index in range(5):
+            device = Device.objects.create(
+                name=f'Nested Site Device {index}',
+                site=site,
+                device_type=device_type,
+                role=device_role,
+            )
+            interfaces.append(Interface.objects.create(
+                name=f'eth{index}',
+                device=device,
+                type='1000baset',
+            ))
+        ip_addresses = IPAddress.objects.bulk_create([
+            IPAddress(address=f'192.0.2.{index}/24', assigned_object=interfaces[index - 1])
+            for index in range(1, 6)
+        ])
+        ip_ids = json.dumps([str(ip.pk) for ip in ip_addresses])
+
+        query = f"""
+        {{
+            ip_address_list(filters: {{id: {{in_list: {ip_ids}}}}}) {{
+                address
+                assigned_object {{
+                    ... on InterfaceType {{
+                        name
+                        device {{
+                            name
+                            site {{
+                                name
+                            }}
+                        }}
+                    }}
+                }}
+            }}
+        }}
+        """
+        url = reverse('graphql')
+
+        with CaptureQueriesContext(connection) as context:
+            response = self.client.post(url, data={'query': query}, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['ip_address_list']), len(ip_addresses))
+        for ip_data in data['data']['ip_address_list']:
+            self.assertEqual(ip_data['assigned_object']['device']['site']['name'], site.name)
+
+        device_queries = count_primary_table_queries(context.captured_queries, 'dcim_device')
+        site_queries = count_primary_table_queries(context.captured_queries, 'dcim_site')
+        self.assertLessEqual(
+            device_queries,
+            2,
+            msg=f'Expected batched device prefetch, got {device_queries} device queries for 5 IP addresses',
+        )
+        self.assertLessEqual(
+            site_queries,
+            2,
+            msg=f'Expected optimized site join, got {site_queries} site queries for 5 IP addresses',
+        )
+
     def test_offset_pagination(self):
     def test_offset_pagination(self):
         self.add_permissions('dcim.view_site')
         self.add_permissions('dcim.view_site')
         url = reverse('graphql')
         url = reverse('graphql')
@@ -703,6 +879,163 @@ class GraphQLAPITestCase(APITestCase):
         self.assertNotIn('errors', data)
         self.assertNotIn('errors', data)
         self.assertEqual(len(data['data']['site_list'][0]['devices']), 3)
         self.assertEqual(len(data['data']['site_list'][0]['devices']), 3)
 
 
+    def test_distinct_nested_list(self):
+        """
+        The `DISTINCT` filter should deduplicate a nested list field which is filtered across a to-many
+        relation, just as it does for the equivalent top-level list field.
+        """
+        self.add_permissions('dcim.view_device', 'dcim.view_site')
+        url = reverse('graphql')
+
+        site = Site.objects.get(slug='site-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')
+        role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        devices = Device.objects.bulk_create([
+            Device(name=f'Device {i}', site=site, device_type=device_type, role=role)
+            for i in range(1, 3)
+        ])
+        Interface.objects.bulk_create([
+            Interface(device=device, name=f'eth{i}', type='1000base-t')
+            for device in devices
+            for i in range(3)
+        ])
+
+        # Each device should be returned exactly once, despite having three matching interfaces
+        query = """
+        {
+            site_list(filters: {slug: {exact: "site-1"}}) {
+                name
+                devices(filters: {DISTINCT: true, interfaces: {name: {starts_with: "eth"}}}) {
+                    name
+                }
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(
+            [device['name'] for device in data['data']['site_list'][0]['devices']],
+            ['Device 1', 'Device 2']
+        )
+
+        # The equivalent top-level query should return the same devices
+        query = """
+        {
+            device_list(filters: {DISTINCT: true, interfaces: {name: {starts_with: "eth"}}}) {
+                name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(
+            [device['name'] for device in data['data']['device_list']],
+            ['Device 1', 'Device 2']
+        )
+
+    @override_settings(MAX_PAGE_SIZE=2)
+    def test_distinct_nested_list_max_page_size(self):
+        """
+        MAX_PAGE_SIZE should still be enforced on a deduplicated nested list field, and should be applied
+        to the number of distinct objects returned (not to the number of joined rows).
+        """
+        self.add_permissions('dcim.view_device', 'dcim.view_site')
+        url = reverse('graphql')
+
+        site = Site.objects.get(slug='site-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')
+        role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        devices = Device.objects.bulk_create([
+            Device(name=f'Device {i}', site=site, device_type=device_type, role=role)
+            for i in range(1, 5)
+        ])
+        Interface.objects.bulk_create([
+            Interface(device=device, name=f'eth{i}', type='1000base-t')
+            for device in devices
+            for i in range(3)
+        ])
+
+        query = """
+        {
+            site_list(filters: {slug: {exact: "site-1"}}) {
+                name
+                devices(filters: {DISTINCT: true, interfaces: {name: {starts_with: "eth"}}}) {
+                    name
+                }
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(
+            [device['name'] for device in data['data']['site_list'][0]['devices']],
+            ['Device 1', 'Device 2']
+        )
+
+        # An explicit offset should likewise be applied to the distinct objects
+        query = """
+        {
+            site_list(filters: {slug: {exact: "site-1"}}) {
+                name
+                devices(
+                    pagination: {offset: 1, limit: 2},
+                    filters: {DISTINCT: true, interfaces: {name: {starts_with: "eth"}}}
+                ) {
+                    name
+                }
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(
+            [device['name'] for device in data['data']['site_list'][0]['devices']],
+            ['Device 2', 'Device 3']
+        )
+
+    def test_distinct_window_pagination_tied_ordering(self):
+        """
+        Two rows which represent *different* objects must never be assigned the same rank, even when they
+        compare equal under the queryset's ordering. `DENSE_RANK()` ties such rows by definition, so the
+        primary key is appended to the window ordering to separate them; without it every device below
+        would be assigned rank 1 and the limit of two would return all four of them.
+        """
+        site = Site.objects.get(slug='site-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')
+        role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        devices = Device.objects.bulk_create([
+            Device(name=f'Device {i}', site=site, device_type=device_type, role=role)
+            for i in range(1, 5)
+        ])
+        Interface.objects.bulk_create([
+            Interface(device=device, name=f'eth{i}', type='1000base-t')
+            for device in devices
+            for i in range(3)
+        ])
+
+        # Order by a column whose value is identical for every device, so that the ordering alone cannot
+        # distinguish them. Each device additionally matches three interfaces, so the join emits three
+        # duplicate rows per device which DISTINCT must still collapse.
+        queryset = Device.objects.filter(
+            site=site, interfaces__name__startswith='eth'
+        ).order_by('status').distinct()
+        queryset = apply_distinct_window_pagination(queryset, related_field_id='site_id', limit=2)
+
+        results = list(queryset)
+        self.assertEqual(sorted(device.name for device in results), ['Device 1', 'Device 2'])
+        self.assertEqual(sorted(device._strawberry_row_number for device in results), [1, 2])
+
     def test_pagination_conflict(self):
     def test_pagination_conflict(self):
         url = reverse('graphql')
         url = reverse('graphql')
         query = """
         query = """

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

@@ -1,5 +1,6 @@
 from unittest import skipIf
 from unittest import skipIf
 
 
+from django.apps import apps
 from django.conf import settings
 from django.conf import settings
 from django.test import TestCase
 from django.test import TestCase
 from taggit.models import Tag
 from taggit.models import Tag
@@ -8,7 +9,8 @@ from core.models import AutoSyncRecord, DataSource
 from dcim.models import Site
 from dcim.models import Site
 from extras.models import CustomLink
 from extras.models import CustomLink
 from ipam.models import Prefix
 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):
 class ModelFeaturesTestCase(TestCase):
@@ -62,6 +64,23 @@ class ModelFeaturesTestCase(TestCase):
         self.assertIn('cloning', features)
         self.assertIn('cloning', features)
         self.assertNotIn('bookmarks', 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_injects_gfk_attribute(self):
     def test_cloningmixin_injects_gfk_attribute(self):
         """
         """
         Tests the cloning mixin with GFK attribute injection in the `clone` method.
         Tests the cloning mixin with GFK attribute injection in the `clone` method.

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff