Jelajahi Sumber

Merge main into feature

Jeremy Stretch 1 bulan lalu
induk
melakukan
e50683fee9
100 mengubah file dengan 4807 tambahan dan 406 penghapusan
  1. 328 0
      .github/workflows/release.yml
  2. 5 0
      .gitignore
  3. 12 1
      README.md
  4. 2 0
      SECURITY.md
  5. 133 0
      THREAT_MODEL.md
  6. 2 0
      contrib/generated_schema.json
  7. 3 0
      contrib/netbox.env
  8. 228 179
      contrib/openapi.json
  9. 3 0
      docs/configuration/required-parameters.md
  10. 3 0
      docs/configuration/system.md
  11. 12 0
      docs/customization/custom-scripts.md
  12. 126 0
      docs/development/building-the-package.md
  13. 38 8
      docs/development/release-checklist.md
  14. 6 0
      docs/installation/2-redis.md
  15. 20 0
      docs/installation/5-http-server.md
  16. 5 0
      docs/integrations/rest-api.md
  17. 35 1
      docs/release-notes/version-4.6.md
  18. 1 0
      mkdocs.yml
  19. 1 1
      netbox/circuits/migrations/0058_denormalization_triggers.py
  20. 7 0
      netbox/core/apps.py
  21. 107 0
      netbox/core/management/commands/upgrade.py
  22. 2 2
      netbox/core/tests/test_changelog.py
  23. 76 1
      netbox/core/tests/test_management_commands.py
  24. 20 16
      netbox/dcim/api/serializers_/base.py
  25. 2 0
      netbox/dcim/choices.py
  26. 17 38
      netbox/dcim/forms/mixins.py
  27. 16 0
      netbox/dcim/migrations/0238_alter_cable__abs_length.py
  28. 31 0
      netbox/dcim/migrations/0239_add_portmapping_objectchange.py
  29. 1 1
      netbox/dcim/migrations/0240_ltree_paths.py
  30. 1 1
      netbox/dcim/migrations/0241_denormalization_triggers.py
  31. 1 1
      netbox/dcim/migrations/0242_device__config_context_data.py
  32. 1 1
      netbox/dcim/migrations/0243_consolidate_unique_constraints.py
  33. 1 1
      netbox/dcim/migrations/0244_add_devicetype_end_of_life.py
  34. 1 1
      netbox/dcim/migrations/0245_modulebaytype.py
  35. 5 0
      netbox/dcim/models/base.py
  36. 46 11
      netbox/dcim/models/cables.py
  37. 6 1
      netbox/dcim/models/device_component_templates.py
  38. 6 1
      netbox/dcim/models/device_components.py
  39. 5 2
      netbox/dcim/models/mixins.py
  40. 24 0
      netbox/dcim/models/sites.py
  41. 3 0
      netbox/dcim/tests/query_counts.json
  42. 359 2
      netbox/dcim/tests/test_cablepaths2.py
  43. 92 0
      netbox/dcim/tests/test_models.py
  44. 361 0
      netbox/dcim/tests/test_port_mappings.py
  45. 120 0
      netbox/dcim/tests/test_views.py
  46. 56 4
      netbox/dcim/utils.py
  47. 10 0
      netbox/extras/api/mixins.py
  48. 9 0
      netbox/extras/api/serializers_/journaling.py
  49. 45 6
      netbox/extras/api/serializers_/scripts.py
  50. 26 6
      netbox/extras/api/views.py
  51. 5 0
      netbox/extras/constants.py
  52. 31 3
      netbox/extras/dashboard/widgets.py
  53. 6 0
      netbox/extras/forms/bulk_import.py
  54. 3 1
      netbox/extras/graphql/mixins.py
  55. 14 2
      netbox/extras/graphql/types.py
  56. 30 1
      netbox/extras/models/configs.py
  57. 8 4
      netbox/extras/models/mixins.py
  58. 5 0
      netbox/extras/models/models.py
  59. 18 0
      netbox/extras/querysets.py
  60. 8 2
      netbox/extras/scripts.py
  61. 4 4
      netbox/extras/templatetags/custom_links.py
  62. 2 1
      netbox/extras/templatetags/dashboard.py
  63. 1 1
      netbox/extras/tests/query_counts.json
  64. 516 5
      netbox/extras/tests/test_api.py
  65. 1 1
      netbox/extras/tests/test_customfields.py
  66. 110 2
      netbox/extras/tests/test_dashboard.py
  67. 4 4
      netbox/extras/tests/test_event_rules.py
  68. 86 3
      netbox/extras/tests/test_models.py
  69. 51 0
      netbox/extras/tests/test_scripts.py
  70. 1 1
      netbox/extras/tests/test_tags.py
  71. 61 0
      netbox/extras/tests/test_templatetags.py
  72. 1 9
      netbox/extras/utils.py
  73. 2 3
      netbox/generate_secret_key.py
  74. 1 1
      netbox/ipam/choices.py
  75. 1 1
      netbox/ipam/filtersets.py
  76. 27 0
      netbox/ipam/migrations/0093_alter_prefix__region_alter_prefix__site_group.py
  77. 2 2
      netbox/ipam/migrations/0094_denormalization_triggers.py
  78. 12 0
      netbox/ipam/querysets.py
  79. 20 0
      netbox/ipam/tests/test_filtersets.py
  80. 75 1
      netbox/ipam/tests/test_models.py
  81. 8 2
      netbox/ipam/views.py
  82. 8 0
      netbox/netbox/__main__.py
  83. 13 1
      netbox/netbox/api/serializers/base.py
  84. 8 2
      netbox/netbox/api/serializers/generic.py
  85. 3 1
      netbox/netbox/api/serializers/nested.py
  86. 23 21
      netbox/netbox/authentication/__init__.py
  87. 98 0
      netbox/netbox/cli.py
  88. 3 3
      netbox/netbox/configuration_example.py
  89. 10 1
      netbox/netbox/filtersets.py
  90. 88 0
      netbox/netbox/scaffold.py
  91. 33 20
      netbox/netbox/settings.py
  92. 204 0
      netbox/netbox/settings_utils.py
  93. 25 1
      netbox/netbox/tests/test_api.py
  94. 92 2
      netbox/netbox/tests/test_authentication.py
  95. 131 0
      netbox/netbox/tests/test_cli.py
  96. 59 0
      netbox/netbox/tests/test_graphql.py
  97. 150 0
      netbox/netbox/tests/test_scaffold.py
  98. 281 0
      netbox/netbox/tests/test_settings_utils.py
  99. 35 11
      netbox/netbox/views/generic/bulk_views.py
  100. 9 3
      netbox/netbox/views/generic/object_views.py

+ 328 - 0
.github/workflows/release.yml

@@ -0,0 +1,328 @@
+name: Build and publish Python package
+
+# Least-privilege default for every job; the publish job grants itself id-token below.
+permissions:
+  contents: read
+
+on:
+  pull_request:
+    paths:
+      - '.github/workflows/release.yml'
+      - 'pyproject.toml'
+      - 'README.md'
+      - 'LICENSE.txt'
+      - 'base_requirements.txt'
+      - 'requirements.txt'
+      - 'upgrade.sh'
+      - 'contrib/**'
+      - 'docs/**'
+      - 'mkdocs.yml'
+      - 'netbox/**'
+      - 'scripts/packaging/**'
+      - 'scripts/verify_*.py'
+      - 'scripts/smoketest_configuration.py'
+  push:
+    tags:
+      - 'v*'
+  workflow_dispatch:
+
+jobs:
+  build:
+    name: Build package artifacts
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+          cache: pip
+
+      - name: Install build tooling
+        run: python -m pip install --upgrade build twine
+
+      - name: Install documentation toolchain
+        run: python -m pip install -r requirements.txt
+
+      - name: Render the documentation
+        # -c = clean cache, -s = strict (abort on warnings); verify_wheel_contents.py
+        # additionally guards against a partial render reaching the wheel.
+        run: zensical build -c -s
+
+      - name: Build sdist and wheel
+        run: python -m build
+
+      - name: Check package metadata
+        run: twine check dist/*
+
+      - name: Upload package artifacts
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+          if-no-files-found: error
+
+  verify-dependencies:
+    name: Verify dependency pins are in sync
+    runs-on: ubuntu-latest
+    needs: build
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+          cache: pip
+
+      - name: Install packaging
+        run: python -m pip install packaging
+
+      - name: Verify requirements.txt is consistent with base_requirements.txt
+        run: python scripts/verify_dependencies.py
+
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+
+      - name: Verify wheel Requires-Dist matches requirements.txt
+        run: python scripts/verify_wheel_metadata.py dist/*.whl
+
+      - name: Verify wheel excludes live configuration files
+        run: python scripts/verify_wheel_contents.py dist/*.whl
+
+  verify-sdist:
+    name: Verify the sdist builds a wheel
+    runs-on: ubuntu-latest
+    needs: build
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+          cache: pip
+
+      - name: Install tooling
+        run: python -m pip install --upgrade pip packaging
+
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+
+      - name: Verify the sdist contents
+        run: |
+          python scripts/verify_sdist_contents.py dist/*.tar.gz
+
+      - name: Build a wheel from the sdist
+        run: |
+          python -m pip wheel --no-deps dist/*.tar.gz -w sdist-wheel/
+
+      - name: Verify the sdist-built wheel
+        run: |
+          python scripts/verify_wheel_metadata.py sdist-wheel/*.whl
+          python scripts/verify_wheel_contents.py sdist-wheel/*.whl
+
+  cli-smoke-test:
+    name: Smoke test wheel CLI (no dependencies)
+    runs-on: ubuntu-latest
+    needs: build
+    # The pre-configuration CLI paths are stdlib-only, so a --no-deps install suffices.
+    # Unlike smoke-test, this job also runs on pull requests.
+
+    steps:
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+
+      - name: Install wheel without dependencies
+        run: |
+          python -m venv "$RUNNER_TEMP/netbox-cli-venv"
+          "$RUNNER_TEMP/netbox-cli-venv/bin/python" -m pip install --no-deps dist/*.whl
+
+      - name: Exercise the pre-configuration CLI
+        run: |
+          "$RUNNER_TEMP/netbox-cli-venv/bin/netbox" --version
+          "$RUNNER_TEMP/netbox-cli-venv/bin/netbox" version
+          "$RUNNER_TEMP/netbox-cli-venv/bin/python" -m netbox --version
+          "$RUNNER_TEMP/netbox-cli-venv/bin/netbox" secret-key | grep -Eq '^.{50}$' || { echo "secret-key not 50 chars"; exit 1; }
+
+      - name: Smoke-test netbox setup from the wheel
+        run: |
+          "$RUNNER_TEMP/netbox-cli-venv/bin/netbox" setup --target "$RUNNER_TEMP/nbroot"
+          for f in "$RUNNER_TEMP/nbroot/conf/__init__.py" "$RUNNER_TEMP/nbroot/conf/configuration.py" "$RUNNER_TEMP/nbroot/local_requirements.txt"; do
+            test -f "$f" || { echo "missing $f"; exit 1; }
+          done
+          for f in apache.conf gunicorn.py netbox-rq.service netbox.env netbox.service nginx.conf uwsgi.ini; do
+            test -s "$RUNNER_TEMP/nbroot/contrib/$f" || { echo "missing or empty contrib/$f"; exit 1; }
+          done
+
+  smoke-test:
+    name: Smoke test wheel install
+    runs-on: ubuntu-latest
+    needs: build
+    # The wheel install + database migration is expensive; only run it for tag
+    # pushes and manual dispatch, not on every packaging-related pull request.
+    # cli-smoke-test provides lightweight, dependency-free CLI coverage on every PR instead.
+    if: github.event_name != 'pull_request'
+
+    services:
+      postgres:
+        image: postgres:17
+        env:
+          POSTGRES_DB: netbox
+          POSTGRES_USER: netbox
+          POSTGRES_PASSWORD: netbox
+        ports:
+          - 5432:5432
+        options: >-
+          --health-cmd "pg_isready -U netbox -d netbox"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+      redis:
+        image: redis:7
+        ports:
+          - 6379:6379
+        options: >-
+          --health-cmd "redis-cli ping"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+
+    env:
+      NETBOX_CONFIGURATION: smoketest_configuration
+      POSTGRES_DB: netbox
+      POSTGRES_USER: netbox
+      POSTGRES_PASSWORD: netbox
+      POSTGRES_HOST: 127.0.0.1
+      POSTGRES_PORT: 5432
+      REDIS_HOST: 127.0.0.1
+      REDIS_PORT: 6379
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+          cache: pip
+
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+
+      - name: Install system build dependencies for psycopg
+        run: sudo apt-get update && sudo apt-get install -y libpq-dev
+
+      - name: Install wheel into a clean virtual environment
+        run: |
+          python -m venv "$RUNNER_TEMP/netbox-wheel-venv"
+          "$RUNNER_TEMP/netbox-wheel-venv/bin/python" -m pip install --upgrade pip
+          "$RUNNER_TEMP/netbox-wheel-venv/bin/python" -m pip install dist/*.whl
+
+      - name: Run NetBox smoke checks
+        env:
+          # STATIC_ROOT is not a configuration parameter; NETBOX_ROOT places it under the scratch base.
+          NETBOX_ROOT: ${{ runner.temp }}/netbox-smoketest
+          NETBOX_SMOKETEST_BASE: ${{ runner.temp }}/netbox-smoketest
+          PYTHONPATH: ${{ github.workspace }}/scripts
+        run: |
+          "$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" check
+          "$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" upgrade --no-input
+          test -f "$NETBOX_SMOKETEST_BASE/static/docs/index.html" || { echo "bundled documentation was not collected to STATIC_ROOT"; exit 1; }
+          test -f "$NETBOX_SMOKETEST_BASE/static/docs/models/dcim/device/index.html" || { echo "model documentation page was not collected"; exit 1; }
+
+      - name: Smoke-test netbox setup from the wheel
+        run: |
+          "$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" setup --target "$RUNNER_TEMP/nbroot"
+          diff -q "$RUNNER_TEMP/nbroot/conf/configuration.py" netbox/netbox/configuration_example.py
+          for f in apache.conf gunicorn.py netbox-rq.service netbox.env netbox.service nginx.conf uwsgi.ini; do
+            diff -q "$RUNNER_TEMP/nbroot/contrib/$f" "contrib/$f"
+          done
+
+  publish-testpypi:
+    name: Publish package to Test PyPI
+    runs-on: ubuntu-latest
+    needs: [smoke-test, cli-smoke-test, verify-dependencies, verify-sdist]
+    # Publishing always requires a v* tag ref: a tag push publishes to Test PyPI
+    # automatically, and a manual dispatch does the same when the chosen ref is a v* tag.
+    # Branch dispatches still run the build, verify, and smoke-test jobs (a useful dry run)
+    # but the publish job is skipped. Production PyPI publishing is intentionally absent
+    # during the v4.6.x preview; it arrives with the v4.7.0 feature branch.
+    # startsWith() only routes to this job (workflow `if:` expressions cannot regex-match);
+    # the exact tag format (v<release.yaml version>) is enforced below by the "Enforce
+    # release tag format" step and scripts/verify_release_tag.py before any upload.
+    if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
+    environment:
+      name: testpypi
+      url: https://test.pypi.org/p/netbox
+    permissions:
+      contents: read
+      id-token: write
+
+    steps:
+      - name: Enforce release tag format
+        env:
+          TAG: ${{ github.ref_name }}
+        run: |
+          [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || {
+            echo "Ref '$TAG' is not a release tag of the form vX.Y.Z[-designation]"
+            exit 1
+          }
+
+      - name: Check out repository
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+      - name: Install tooling
+        run: python -m pip install --upgrade pip packaging
+
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+
+      - name: Verify the git tag matches the built version
+        run: python scripts/verify_release_tag.py "${{ github.ref_name }}" dist/*.whl
+
+      - name: Publish package distributions to Test PyPI
+        uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
+        with:
+          repository-url: https://test.pypi.org/legacy/

+ 5 - 0
.gitignore

@@ -64,3 +64,8 @@ yarn-error.log*
 .idea/
 .idea/
 .vscode/
 .vscode/
 .python-version
 .python-version
+
+# Python package build artifacts
+/dist/
+/build/
+*.egg-info/

+ 12 - 1
README.md

@@ -5,7 +5,7 @@
   <a href="https://github.com/netbox-community/netbox/blob/main/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
   <a href="https://github.com/netbox-community/netbox/blob/main/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
   <a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
   <a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
   <a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
   <a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
-  <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-16-blue" alt="Languages supported" /></a>
+  <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-17-blue" alt="Languages supported" /></a>
   <a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
   <a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
   <p>
   <p>
     <strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
     <strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
@@ -20,6 +20,7 @@ NetBox exists to empower network engineers. Since its release in 2016, it has be
   <a href="#netboxs-role">NetBox's Role</a> |
   <a href="#netboxs-role">NetBox's Role</a> |
   <a href="#why-netbox">Why NetBox?</a> |
   <a href="#why-netbox">Why NetBox?</a> |
   <a href="#getting-started">Getting Started</a> |
   <a href="#getting-started">Getting Started</a> |
+  <a href="#plugins">Plugins</a> |
   <a href="#get-involved">Get Involved</a> |
   <a href="#get-involved">Get Involved</a> |
   <a href="#screenshots">Screenshots</a>
   <a href="#screenshots">Screenshots</a>
 </p>
 </p>
@@ -85,6 +86,16 @@ NetBox automatically logs the creation, modification, and deletion of all manage
 * The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction.
 * The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction.
 * Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox!
 * Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox!
 
 
+## Plugins
+
+NetBox's functionality can be extended through plugins, which add new models, views, and integrations on top of the core application. A few of the most popular plugins include:
+
+* [NetBox Branching](https://github.com/netboxlabs/netbox-branching) — Work with isolated, mergeable branches of your NetBox data
+* [NetBox Custom Objects](https://github.com/netboxlabs/netbox-custom-objects) — Define entirely new object types directly in the UI
+* [NetBox DNS](https://github.com/sys4/netbox-plugin-dns) — Manage DNS zones and records as an authoritative source of truth
+* [NetBox BGP](https://github.com/netbox-community/netbox-bgp) — Document and manage BGP sessions and routing policies
+* [Browse all plugins](https://netboxlabs.com/plugins/) — Discover the full catalog of available plugins
+
 ## Get Involved
 ## Get Involved
 
 
 * Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter!
 * Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter!

+ 2 - 0
SECURITY.md

@@ -22,6 +22,8 @@ If you would like to consider upgrading to NetBox Cloud or Enterprise, please co
 
 
 ## Reporting a Suspected Vulnerability
 ## Reporting a Suspected Vulnerability
 
 
+Before reporting, please review our [Threat Model](THREAT_MODEL.md) to confirm that the behavior you've observed is an in-scope vulnerability and not an intended, privileged operation.
+
 If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so by emailing `security@netboxlabs.com`. Please ensure that your report meets all the following conditions:
 If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so by emailing `security@netboxlabs.com`. Please ensure that your report meets all the following conditions:
 
 
 * Affects the most recent stable release of NetBox, or a current beta release
 * Affects the most recent stable release of NetBox, or a current beta release

+ 133 - 0
THREAT_MODEL.md

@@ -0,0 +1,133 @@
+# NetBox Threat Model
+
+## Purpose & Scope
+
+This document describes the security threat model for **NetBox Community Edition**, installed and operated according to the [official documentation](https://netboxlabs.com/docs/netbox/). Its purpose is to state explicitly who and what NetBox trusts, what a supported deployment looks like, and — most importantly — which classes of behavior are intended, privileged operations rather than security vulnerabilities.
+
+NetBox is a feature-rich application that deliberately grants powerful capabilities (code execution, template rendering, outbound requests) to privileged users in order to support advanced network automation workflows. Many security reports we receive describe these intended capabilities as if they were defects. This document exists so that prospective reporters — and the maintainers who triage their reports — can quickly distinguish a genuine vulnerability from an authorized, privileged operation working as designed.
+
+This document **complements** our [Security Policy](SECURITY.md); it does not replace it. The policy governs *how* to report a suspected vulnerability and the conditions a report must meet. This document governs *what* constitutes a vulnerability in the first place.
+
+This model anchors to [OWASP's threat modeling guidance](https://owasp.org/www-community/Threat_Modeling) and uses a lightweight [STRIDE](https://en.wikipedia.org/wiki/STRIDE_%28security%29) breakdown (see below).
+
+## Supported Deployment Model
+
+NetBox's threat model assumes a deployment consistent with the recommendations in our [Security Policy](SECURITY.md) and [installation documentation](https://netboxlabs.com/docs/netbox/installation/):
+
+* **Not exposed to the public Internet.** NetBox is intended to run on an internal or otherwise access-controlled network, behind a reverse proxy (e.g. nginx). It is not designed or hardened to serve as an anonymous, public-facing web application.
+* **Administered by trusted operators.** The individuals who deploy, configure, and administer NetBox — including holders of the `is_superuser` flag and anyone with shell, filesystem, or database access to the host — are assumed to be trusted system administrators.
+* **The database is reachable only by the application.** PostgreSQL and Redis are assumed to be accessible only to the NetBox application itself, not to arbitrary clients.
+* **An authenticated user base.** NetBox is intended for use only by authenticated users. [`LOGIN_REQUIRED`](https://netboxlabs.com/docs/netbox/configuration/security/#login_required) defaults to `True`, and support for unauthenticated access is being removed entirely in NetBox v5.0.
+* **The reverse proxy owns the network edge.** TLS termination, HTTP request rate limiting, and authoritative determination of the client IP address are the responsibility of the deployment's reverse proxy and surrounding infrastructure — not the application. (See [`HTTP_CLIENT_IP_HEADERS`](https://netboxlabs.com/docs/netbox/configuration/system/#http_client_ip_headers); the headers NetBox trusts for client IP are only as trustworthy as the proxy that sets them.)
+
+Reports that assume a deployment outside this model — for example, "an anonymous Internet user can reach the login page" or "an administrator can modify the database" — describe the intended operating environment, not a vulnerability.
+
+## Trusted vs. Untrusted Actors
+
+The central question when evaluating any NetBox security report is: **does the attack require a privilege that NetBox already designates as trusted?**
+
+| Actor | Trust | Notes |
+| --- | --- | --- |
+| The NetBox server / process | **Trusted** | Executes application code; holds secrets. |
+| PostgreSQL database, Redis | **Trusted** | Assumed reachable only by the application. |
+| Infrastructure operators | **Trusted** | Shell/filesystem/DB access implies total control by design. |
+| Superusers (`is_superuser`) | **Trusted** | An active superuser bypasses all object-level permission checks. This is intentional. |
+| Users permitted to author code-bearing objects | **Trusted** | Holders of permissions to create/modify custom scripts, export templates, config templates, custom links, or webhooks (see below). |
+| Authenticated users **without** those permissions | **Untrusted** | Subject to full object-based permission enforcement. |
+| Unauthenticated / network-adjacent parties | **Untrusted** | Outside the supported deployment model entirely. |
+
+The governing principle:
+
+> **Granting a user permission to author a custom script, export or config template, custom link, or webhook is equivalent to granting that user a degree of code execution — by design.** Abuse of such a feature by a user who holds the corresponding permission is not a vulnerability. The mitigation is administrative: grant these permissions only to trusted users, as instructed by the documentation for each feature.
+
+## Privileged-by-Design Features
+
+The following features deliberately allow trusted users to supply code or logic that NetBox executes or renders. Each is gated by a specific permission and carries an explicit warning in its documentation. Using these features as designed — even in ways that read like "code execution" or "data access" to an outside observer — is **not** a vulnerability.
+
+### Custom Scripts
+
+Custom scripts are Python modules with **unrestricted access to the NetBox ORM, database, and Python runtime**. They are gated by the `extras.run_script` permission (and authored by users who can add/modify script modules). The documentation states plainly that they are *"inherently unsafe and should be installed and run only from trusted sources"*.
+
+### Export Templates, Config Templates, Custom Links & Webhooks (Jinja)
+
+These features render **user-authored [Jinja templates](https://jinja.palletsprojects.com/en/stable/)** with live application objects in scope. Templates are evaluated in a Jinja [`SandboxedEnvironment`](https://jinja.palletsprojects.com/en/stable/sandbox/) (`netbox/utilities/Jinja.py`), which restricts access to unsafe attributes and operations.
+
+It is important to be precise about where the boundary lies:
+
+* The sandbox **is** a boundary NetBox maintains. A genuine, reproducible *escape* from the sandbox — code or attribute access the sandbox is supposed to block — **is** a vulnerability we take seriously (see "In-Scope Vulnerabilities").
+* Authoring these objects is nonetheless a **privileged action**. A template author legitimately has broad read access to NetBox objects and can produce arbitrary output within the sandbox's bounds. That a template can read data the author is otherwise permitted to see, or generate HTML/configuration, is intended behavior — not an injection vulnerability.
+
+Each feature's documentation states that the relevant permission should be granted only to trusted users:
+
+* [Export templates](https://netboxlabs.com/docs/netbox/customization/export-templates/)
+* [Custom links](https://netboxlabs.com/docs/netbox/customization/custom-links/)
+* [Webhooks](https://netboxlabs.com/docs/netbox/integrations/webhooks/)
+* [Configuration rendering](https://netboxlabs.com/docs/netbox/features/configuration-rendering)
+
+### Webhooks & Event Rules (Outbound Requests)
+
+Webhooks issue **outbound HTTP requests to operator-defined URLs**, with the URL, headers, and body all rendered from user-authored Jinja. A trusted webhook author can therefore direct requests to arbitrary endpoints. This server-side request capability is the entire purpose of the feature; it is available only to users permitted to create webhooks, and is not a server-side request forgery (SSRF) vulnerability when exercised by such a user.
+
+### Config Contexts & Custom Fields
+
+Config contexts store arbitrary JSON applied to devices and virtual machines; custom fields add operator-defined attributes (with optional regex/JSON-schema validation). Neither executes code directly. Config context data may, however, be consumed by config templates during rendering, so it inherits the same "template author is trusted" posture described above.
+
+### Object-Based Permissions
+
+NetBox enforces a robust [object-based permission system](https://netboxlabs.com/docs/netbox/features/authentication-permissions/) layered on top of Django's model permissions. Permissions combine object types, users/groups, actions, and optional JSON **constraints** (including the special `$user` token). A failure of this system to enforce a permission or constraint that it advertises **is** a vulnerability (see below).
+
+## In-Scope Vulnerabilities
+
+We take the following seriously. The common thread is a breach of a boundary NetBox *claims* to enforce, or harm to a user who never consented to the risk.
+
+* **Authorization bypass** — reading or acting on objects a user has no permission to access.
+* **Privilege escalation** — bypassing a permission or constraint to gain access beyond what was granted.
+* **Injection that crosses a data boundary** — e.g. filter/ORM operator injection in the REST or GraphQL API exposing data a user shouldn't reach.
+* **Cross-site scripting (XSS) against a non-consenting victim** — stored or DOM-based XSS that executes in another user's session.
+* **Jinja sandbox escapes** — a reproducible escape from the template sandbox's intended restrictions.
+* **Authentication bypass** and **unauthenticated remote code execution or data access**.
+* **Dependency vulnerabilities with a realistic exploit path** through NetBox (not merely a flagged version).
+
+## Out-of-Scope / Non-Issues
+
+The following are **not** treated as NetBox vulnerabilities. Most describe a privileged feature used by a user the documentation already designates as trusted, or a concern that belongs to the deployment/platform layer.
+
+| Scenario | Status | Reason                                                                                                                                                                                                          |
+| --- | --- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| A permitted user runs code via a custom script, Jinja template, custom link, or webhook | Not a vulnerability | These features exist to execute user-authored logic; the required permission is trusted, operator-tier by design.                                                                                               |
+| A superuser modifies the database, reads secrets, or creates another superuser | Not a vulnerability | Superusers and infrastructure operators are trusted by design.                                                                                                                                                  |
+| A template author reads NetBox data they are otherwise permitted to view | Not a vulnerability | Template rendering with objects in scope is the purpose of the feature; the sandbox, not permission scoping, is the boundary.                                                                                   |
+| Missing login/request rate limiting | Out of scope | A deployment-layer concern, handled by the reverse proxy rather than the application.                                                                                                                           |
+| Client IP spoofing via `X-Forwarded-For` and similar headers | Out of scope | NetBox trusts the headers the reverse proxy sets; trustworthy client IP is a proxy responsibility ([`HTTP_CLIENT_IP_HEADERS`](https://netboxlabs.com/docs/netbox/configuration/system/#http_client_ip_headers)). |
+| Self-XSS (a user injecting script into their own session) | Not a vulnerability | The user is attacking only themselves; no privilege boundary is crossed.                                                                                                                                        |
+| CSRF on the login form | Not a vulnerability | Login CSRF is not a meaningful attack in NetBox's deployment model.                                                                                                                                             |
+| Automated-scanner reports that a file *may* be vulnerable | Rejected | Per our [Security Policy](SECURITY.md), we do not accept reports from automated tooling that merely suggest potential vulnerability without a confirmed reproducible exploit.                                   |
+
+## Lightweight STRIDE View
+
+| Category | NetBox posture |
+| --- | --- |
+| **S**poofing | Authentication via local accounts, LDAP, or SSO (python-social-auth); API tokens. Authoritative client-IP determination is delegated to the reverse proxy. |
+| **T**ampering | All writes are gated by object-based permissions with optional constraints, validated within atomic transactions. Code-bearing objects are writable only by trusted users. |
+| **R**epudiation | Changes are recorded via the changelog and journaling; event rules can emit notifications. |
+| **I**nformation disclosure | Object-based view permissions filter every queryset. Cross-boundary disclosure (e.g. API/GraphQL filter injection) is in scope; data legitimately visible to a template author is not. |
+| **D**enial of service | Request rate limiting and resource controls are a deployment/reverse-proxy responsibility, not the application's. |
+| **E**levation of privilege | The superuser flag is all-or-nothing and trusted. Any *unintended* escalation across the permission system (constraint bypass, action bypass) is in scope. |
+
+## Triage & Severity
+
+When triaging a report we assess the **CVSS environmental score**, not solely the base score. A finding with a high CVSS base score may be downgraded substantially once NetBox's deployment assumptions and trust boundaries are applied — for example, a "remote code execution" that in fact requires a permission we already designate as trusted (script or template authoring) is mitigated by design rather than by a code change.
+
+We use [CVSS v3.1/v4.0](https://www.first.org/cvss/) for scoring and the [STRIDE](https://en.wikipedia.org/wiki/STRIDE_%28security%29) categories above to reason about boundaries. If you believe you have a fix that closes an in-scope issue without degrading the affected feature, you are welcome to propose it alongside your report.
+
+## Reporting
+
+Before reporting, please confirm that the behavior you've observed is an in-scope vulnerability under this document and not an intended, privileged operation, and that it is reproducible in the current stable release of NetBox.
+
+To report a suspected vulnerability, follow the process in our [Security Policy](SECURITY.md). In summary, a report must:
+
+* Affect the most recent stable release of NetBox, or a current beta release;
+* Affect a NetBox instance installed and configured per the official documentation; and
+* Be reproducible following a prescribed set of instructions.
+
+Confidential reports may be sent to `security@netboxlabs.com`.

+ 2 - 0
contrib/generated_schema.json

@@ -625,6 +625,7 @@
                         "st",
                         "st",
                         "cs",
                         "cs",
                         "sn",
                         "sn",
+                        "mdc",
                         "sma-905",
                         "sma-905",
                         "sma-906",
                         "sma-906",
                         "urm-p2",
                         "urm-p2",
@@ -697,6 +698,7 @@
                         "st",
                         "st",
                         "cs",
                         "cs",
                         "sn",
                         "sn",
+                        "mdc",
                         "sma-905",
                         "sma-905",
                         "sma-906",
                         "sma-906",
                         "urm-p2",
                         "urm-p2",

+ 3 - 0
contrib/netbox.env

@@ -0,0 +1,3 @@
+# Optional overrides for a pip-installed NetBox. Do not put secrets here.
+# NetBox loads conf/configuration.py from NETBOX_ROOT automatically.
+NETBOX_ROOT=/opt/netbox

File diff ditekan karena terlalu besar
+ 228 - 179
contrib/openapi.json


+ 3 - 0
docs/configuration/required-parameters.md

@@ -146,6 +146,9 @@ REDIS = {
     It is highly recommended to keep the task and cache databases separate. Using the same database number on the
     It is highly recommended to keep the task and cache databases separate. Using the same database number on the
     same Redis instance for both may result in queued background tasks being lost during cache flushing events.
     same Redis instance for both may result in queued background tasks being lost during cache flushing events.
 
 
+!!! danger "Redis is a trusted component"
+    NetBox's background workers deserialize and execute jobs read from the `tasks` Redis database, so any party with write access to it can run arbitrary code on a worker. Redis must be treated as trusted infrastructure, on par with the PostgreSQL database: keep it bound to a private network and require authentication.
+
 ### UNIX Socket Support
 ### UNIX Socket Support
 
 
 Redis may alternatively be configured by specifying a complete URL instead of individual components. This approach supports the use of a UNIX socket connection. For example:
 Redis may alternatively be configured by specifying a complete URL instead of individual components. This approach supports the use of a UNIX socket connection. For example:

+ 3 - 0
docs/configuration/system.md

@@ -98,6 +98,9 @@ An ordered list of HTTP request headers inspected to determine the source IP add
 
 
 The client IP is used for source-address restrictions on API tokens and for logging failed login attempts.
 The client IP is used for source-address restrictions on API tokens and for logging failed login attempts.
 
 
+!!! warning "Client IP trust"
+    The headers listed here are trusted as the source of the client IP address. Trusting `X-Forwarded-For` (`HTTP_X_FORWARDED_FOR`) or `X-Real-IP` (`HTTP_X_REAL_IP`) is safe only when NetBox is deployed behind a reverse proxy that overwrites these headers with the real client address. If NetBox is reachable directly, or the proxy appends to or passes through a client-supplied value (NetBox uses the leftmost address, which the client controls when the proxy appends), a client can spoof its apparent IP address and defeat API token client IP restrictions. Deployments without a trusted proxy should set `HTTP_CLIENT_IP_HEADERS = ('REMOTE_ADDR',)`.
+
 ---
 ---
 
 
 ## HTTP_PROXIES
 ## HTTP_PROXIES

+ 12 - 0
docs/customization/custom-scripts.md

@@ -443,6 +443,18 @@ curl -X POST \
 http://netbox/api/extras/scripts/upload/
 http://netbox/api/extras/scripts/upload/
 ```
 ```
 
 
+### Updating an Uploaded Script
+
+An existing script module can be replaced in place by sending a `multipart/form-data` PUT or PATCH request to the module's detail URL. The module may be identified by its numeric ID or by its file name. The uploaded file name must match the existing module's file path, and the caller must have the `extras.change_scriptmodule` and `core.change_managedfile` permissions. The module's scripts are re-synchronized from the new content.
+
+```no-highlight
+curl -X PUT \
+-H "Authorization: Bearer $TOKEN" \
+-H "Accept: application/json; indent=4" \
+-F "file=@/path/to/myscript.py" \
+http://netbox/api/extras/scripts/upload/myscript.py/
+```
+
 ## Running Custom Scripts
 ## Running Custom Scripts
 
 
 !!! note
 !!! note

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

@@ -0,0 +1,126 @@
+# Building the Package
+
+NetBox package artifacts (a wheel and a source distribution) can be built and verified locally. During the v4.6.x preview period, published artifacts are for maintainer validation only. Installing NetBox via pip is not a supported installation path yet. Experimental support for installing from production PyPI is planned for NetBox v4.7.0. This page is intended for maintainers and contributors working on the packaging itself; routine development does not require building a package.
+
+The artifacts are always built by CI from a clean checkout (see `.github/workflows/release.yml`). A local build is useful for testing packaging changes before they are merged.
+
+## Prerequisites
+
+Install the minimum local build tooling (all three are also included in the `dev` optional dependency group):
+
+```no-highlight
+python -m pip install --upgrade build packaging twine
+```
+
+Building also requires a freshly rendered copy of the documentation site (see [Building](#building) below). The documentation toolchain (`zensical`, `mkdocs`, `mkdocs-material`, `mkdocstrings`) is pinned in `requirements.txt` rather than the `dev` group because it is also needed outside packaging, such as documentation previews and CI's `docs` job.
+
+## Building
+
+Render the documentation site at the repository root before building; both the wheel and the sdist bundle the rendered output, and the release workflow's `build` job renders in the same way:
+
+```no-highlight
+python -m pip install -r requirements.txt
+zensical build -c -s
+```
+
+Always render with `-c` (clean cache) and `-s` (strict mode, abort on warnings) so a stale cache or a degraded build cannot slip into the artifacts. This writes `netbox/project-static/docs/` (gitignored). Building without a prior render fails because the rendered docs directory is a required Hatch force-include: Hatchling raises `FileNotFoundError: Forced include not found` for the missing directory. A render that exits successfully but produces a partial site is caught by `scripts/verify_wheel_contents.py`, which requires both the site root (`index.html`) and a model documentation page (`models/dcim/device/index.html`) in the wheel.
+
+Build both the source distribution (sdist) and the wheel into `dist/`:
+
+```no-highlight
+python -m build
+```
+
+To build only the wheel (faster, and the form most useful for a quick local install test):
+
+```no-highlight
+python -m build --wheel
+```
+
+The package version and the wheel's runtime dependency metadata are both computed at build time by a Hatchling hook; see [Dynamic metadata](#dynamic-metadata) below.
+
+## Clean-tree caveat
+
+Always build release artifacts from a clean checkout. The build configuration keeps deployment-local files out of the artifacts: the Hatch excludes drop every `configuration*.py` and `ldap_config*.py` except the two tracked configuration templates (`configuration_example.py` and `configuration_testing.py`, which are force-included explicitly), and CI verifies the contents of both the wheel and the sdist before anything is published.
+
+These checks are defense in depth, not a license to build from a dirty tree: other untracked files under `netbox/` can still be picked up by a local build. CI builds from a clean checkout, so the published artifacts are unaffected. For a comparable local build, use a fresh `git clone` or a separate clean worktree rather than your day-to-day development tree.
+
+## Verifying
+
+Check the built artifacts for valid package metadata and README rendering:
+
+```no-highlight
+twine check dist/*
+```
+
+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
+python scripts/verify_wheel_metadata.py dist/*.whl
+```
+
+Confirm the artifacts ship only the two tracked configuration templates, and that the wheel carries the runtime-critical bundled data: `_data/release.yaml`, templates, translations, static assets, and the pre-rendered documentation site under `_data/docs/`. These are the same content checks CI runs before publishing:
+
+```no-highlight
+python scripts/verify_wheel_contents.py dist/*.whl
+python scripts/verify_sdist_contents.py dist/*.tar.gz
+```
+
+Confirm `requirements.txt` is still consistent with the maintainer policy in `base_requirements.txt` (the same drift guard CI runs before publishing):
+
+```no-highlight
+python scripts/verify_dependencies.py
+```
+
+## Test-installing the wheel
+
+Install the wheel into a throwaway virtual environment and run the system checks to confirm the package is importable and runnable:
+
+```no-highlight
+python -m venv /tmp/netbox-build-test
+/tmp/netbox-build-test/bin/python -m pip install --upgrade pip
+/tmp/netbox-build-test/bin/python -m pip install dist/*.whl
+PYTHONPATH=$PWD/scripts \
+NETBOX_CONFIGURATION=smoketest_configuration \
+NETBOX_ROOT=/tmp/netbox-build-test-root \
+NETBOX_SMOKETEST_BASE=/tmp/netbox-build-test-root \
+/tmp/netbox-build-test/bin/netbox check
+```
+
+Without configuration, a wheel-installed NetBox looks for `$NETBOX_ROOT/conf/configuration.py` (default `/opt/netbox/conf/configuration.py`), which normally does not exist on a development workstation. The environment variables above point `netbox check` at the same minimal configuration module used by the release workflow's smoke-test job (`scripts/smoketest_configuration.py`); run the command from the repository root so `PYTHONPATH` can find it. `NETBOX_SMOKETEST_BASE` sets the writable scratch directory under which the module creates its media, reports, and scripts roots; `NETBOX_ROOT` points the fixed collected-static root at the same directory. Any other importable configuration module works the same way via `NETBOX_CONFIGURATION` (and `PYTHONPATH`, if the configuration lives outside the package). To exercise the full post-install task sequence from the wheel, run `netbox upgrade --no-input` with the same environment against a throwaway database (the collected static files land under `$NETBOX_ROOT/static`); this is what the release workflow's smoke-test job does. The documentation ships pre-rendered in the wheel, so there is nothing to build on the instance; `--build-docs` remains a checkout-only convenience for rendering the documentation from its sources.
+
+## Packaging architecture
+
+This section is a developer-facing overview of how the package is assembled and how a pip-installed NetBox behaves at runtime. User-facing installation documentation for the pip install path will be added alongside experimental PyPI support (planned for NetBox v4.7.0); this page does not cover end-user installation steps.
+
+### Dynamic metadata
+
+`scripts/packaging/hatch_metadata.py` is a Hatchling metadata hook (wired in via `[tool.hatch.metadata.hooks.custom]`). It computes the package version from `netbox/release.yaml` and the runtime dependencies from the pinned `requirements.txt`, so the published wheel's `Requires-Dist` carries the exact versions NetBox is tested against. Both fields are declared `dynamic` in `pyproject.toml`; the optional-dependency extras stay static.
+
+### sdist and the sdist-to-wheel guard
+
+`python -m build` produces both an sdist and a wheel, with the wheel built from the sdist. The release workflow's `verify-sdist` job rebuilds a wheel from the candidate sdist and runs `scripts/verify_wheel_metadata.py` and `scripts/verify_wheel_contents.py` against it, so a missing build input (for example the metadata hook or `base_requirements.txt`) cannot regress unnoticed. The rendered documentation site is one such build input: it reaches the sdist through its own force-include (`[tool.hatch.build.targets.sdist.force-include]`), so this guard also fails if that force-include is removed or broken.
+
+### Wheel data layout
+
+Source assets that are not Python modules are force-included with a `netbox/netbox/_data/` target path by `[tool.hatch.build.targets.wheel.force-include]`; because the wheel's `sources = ["netbox"]` setting strips one leading `netbox/`, they install under `netbox/_data/`: templates, translations, the compiled `project-static` bundles, `release.yaml`, the pre-rendered documentation site (rendered by `zensical build` into `netbox/project-static/docs/` before packaging; see [Building](#building) above), the bundled deployment examples (`contrib/`, seven files, unmodified), and the two tracked configuration templates.
+
+The wheel bundles the rendered site itself, not the documentation sources. The documentation build is not run from the installed wheel, and there is nothing to build on the instance. In wheel mode, the default `DOCS_ROOT` and the STATICFILES `docs` prefix source both resolve to the same bundled `_data/docs` directory (see `resolve_install_paths()` in `netbox/netbox/settings_utils.py`), which `collectstatic` then picks up the same way it does for a checkout build. The sdist force-includes the same rendered site (`netbox/project-static/docs/`, kept alongside the markdown sources it was rendered from), so a wheel built from the sdist (the `verify-sdist` job, or `pip install <sdist>`) is identical in this respect.
+
+At runtime `settings.py` detects the bundled `_data` directory and resolves the install mode, `BASE_DIR`, `NETBOX_ROOT`, and the documentation roots through `resolve_install_paths()` in `netbox/netbox/settings_utils.py`: a wheel install (`_data` present) keeps package data under `_data` and mutable instance files under `NETBOX_ROOT`; a source checkout (no `_data`) keeps the historical layout, where both roots are the project directory.
+
+### Wheel-mode runtime
+
+A pip-installed NetBox keeps mutable instance state out of the immutable, disposable virtual environment. `settings.py` resolves `NETBOX_ROOT` (default `/opt/netbox`, overridable via the environment) as the instance root, defaults the writable paths (`MEDIA_ROOT`, `REPORTS_ROOT`, `SCRIPTS_ROOT`) beneath it, and fixes `STATIC_ROOT` to `$NETBOX_ROOT/static`; `STATIC_ROOT` is intentionally not a `configuration.py` parameter, so the collected static path cannot drift from the instance layout the bundled deployment examples expect. In a checkout `NETBOX_ROOT` equals `BASE_DIR`, so archive and Git installs are unaffected.
+
+Configuration loading is handled by `load_configuration()` in `netbox/netbox/settings_utils.py`. An explicit `NETBOX_CONFIGURATION` module always wins; otherwise, in wheel mode it prefers `NETBOX_ROOT/conf/configuration.py`, loading it by file path, and falls back to a legacy `NETBOX_ROOT/netbox/netbox/configuration.py` with a migration warning. The configuration directory is added to `sys.path` only while the configuration file executes, so sibling imports can resolve; `NETBOX_ROOT` itself is never added, which avoids a stale source tree shadowing the installed package. A checkout keeps importing `netbox.configuration`. For LDAP deployments, `settings.py` exposes the active configuration file's directory as the `CONFIGURATION_DIR` setting, and `load_ldap_config()` loads `ldap_config.py` from that same directory by default. This keeps the active LDAP configuration beside the active NetBox configuration, regardless of install method. One compatibility exception remains: in checkout mode only, when no sibling file exists, the historical `netbox/netbox/ldap_config.py` module is imported with a `RuntimeWarning`, so existing source installs that use a custom `NETBOX_CONFIGURATION` keep working.
+
+### Console script
+
+`pyproject.toml` registers a single entry point, `netbox` (`netbox.cli:main`). The wrapper resolves a few commands itself before importing Django, so they work without a configuration present:
+
+* `netbox version` / `netbox --version` print the installed package version.
+* `netbox setup` creates the local configuration files for the instance: `conf/__init__.py`, `conf/configuration.py` copied verbatim from the bundled `configuration_example.py` template, and an empty `local_requirements.txt`. It also copies the bundled deployment examples (gunicorn, systemd units, nginx, apache, uwsgi, `netbox.env`) unmodified into `<target>/contrib/`. The examples are copied as-is, and existing files are never overwritten; adapting and installing the examples (paths, systemd, the web server) remains the administrator's responsibility.
+* `netbox secret-key` prints a new 50-character `SECRET_KEY` value.
+
+These names are reserved by the wrapper. Every other command falls through to the Django management commands (`netbox upgrade`, `netbox check`, and so on), which require a valid configuration.

+ 38 - 8
docs/development/release-checklist.md

@@ -97,14 +97,23 @@ Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker)
 
 
 ### Update Python Dependencies
 ### Update Python Dependencies
 
 
-Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this:
+Before each release, update each of NetBox's Python dependencies to its most recent stable version. Loose runtime constraints (and per-package descriptions) live in `base_requirements.txt`; `requirements.txt` is the pinned, top-level dependency file consumed by the release archive, the git install flow (`upgrade.sh`), and the published wheel's dependency metadata. Optional dependency groups (for example `ldap`, `saml2`) are declared in `pyproject.toml`.
 
 
-1. Upgrade the installed version of all required packages in your environment (`pip install -U -r base_requirements.txt`).
-2. Run all tests and check that the UI and API function as expected.
-3. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
-4. Update the package versions in `requirements.txt` as appropriate.
+To update the pinned requirements:
 
 
-In cases where upgrading a dependency to its most recent release is breaking, it should be constrained to its current minor version in `base_requirements.txt` with an explanatory comment and revisited for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
+1. Review each constraint in `base_requirements.txt`.
+2. Upgrade the installed version of all required packages in your environment (`pip install -U -r base_requirements.txt`).
+3. Run all tests and check that the UI and API function as expected.
+4. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
+5. If upgrading a dependency is breaking, constrain it in `base_requirements.txt` with an explanatory comment and revisit it for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
+6. Update the pinned versions in `requirements.txt` to the versions you just tested. Keep `requirements.txt` in the existing bare `package==version` format (one top-level package per line, the same package set as `base_requirements.txt`).
+7. Verify there is no drift between the policy file and the pins:
+
+    ```no-highlight
+    python3 scripts/verify_dependencies.py
+    ```
+
+The published wheel's `Requires-Dist` is generated from `requirements.txt` at build time, so the package installs the same tested pins as the archive and git flows.
 
 
 ### Update UI Dependencies
 ### Update UI Dependencies
 
 
@@ -143,7 +152,7 @@ Then, compile these portable (`.po`) files for use in the application:
 ### Update Version and Changelog
 ### Update Version and Changelog
 
 
 * Update the version number and published date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
 * Update the version number and published date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
-* Copy the version number from `release.yaml` to `pyproject.toml` in the project root.
+* No manual `pyproject.toml` version edit is needed: the package version is derived automatically from `release.yaml` (`version` plus any `designation`) by the build backend.
 * Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
 * Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
 
 
 !!! tip
 !!! tip
@@ -196,4 +205,25 @@ Create a [new release](https://github.com/netbox-community/netbox/releases/new)
 * **Title:** Version and date (e.g. `v4.2.1 - 2025-01-17`)
 * **Title:** Version and date (e.g. `v4.2.1 - 2025-01-17`)
 * **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones
 * **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones
 
 
-Once created, the release will become available for users to install.
+Once created, the release will become available for users to install from GitHub.
+
+### Publish to Test PyPI
+
+Pushing a release tag triggers the Python package publishing workflow, which publishes the tagged release automatically to **Test PyPI** for maintainer validation. Installing NetBox via pip is not a supported installation path during the v4.6.x preview period; production PyPI publishing is planned for the v4.7.0 feature branch. A manual `workflow_dispatch` run publishes to Test PyPI only when the selected ref is a `v*` release tag; dispatching from a branch runs the build and verification jobs as a dry run without publishing.
+
+After a publish run completes:
+
+* Verify that the build, CLI smoke-test (`cli-smoke-test`), smoke-test, dependency-verification (`verify-dependencies`), and sdist-verification (`verify-sdist`) jobs succeeded. The dependency-verification job fails the release if `requirements.txt` has drifted from `base_requirements.txt` or if the built wheel's `Requires-Dist` does not match `requirements.txt`; the sdist-verification job fails it if the sdist ships unexpected configuration files or cannot rebuild a valid wheel.
+* Verify that the publish job used the expected trusted-publishing environment (`testpypi`).
+* Confirm that the new version is visible on Test PyPI.
+* Install the published wheel into a fresh virtual environment and run `netbox check` against a minimal configuration module. The preview artifact is published to Test PyPI while NetBox's pinned runtime dependencies are expected to resolve from PyPI; to avoid mixed-index dependency resolution during validation, install the pinned dependencies from PyPI first, then install the Test PyPI artifact without resolving dependencies again:
+
+    ```no-highlight
+    pip install -r requirements.txt
+    pip install --no-deps --index-url https://test.pypi.org/simple/ netbox==<version>
+    ```
+
+!!! note "Trusted publishing prerequisites"
+    Publishing requires a one-time setup by the project owners: a `netbox` project and a configured GitHub trusted publisher on Test PyPI, plus the corresponding `testpypi` GitHub Actions environment.
+
+The published package version is derived from `netbox/release.yaml` (the `version` field plus any `designation`, e.g. `beta1` becomes `4.7.0b1`), not from the git tag. Ensure the tag and `release.yaml` agree before tagging a pre-release.

+ 6 - 0
docs/installation/2-redis.md

@@ -16,6 +16,12 @@ redis-server -v
 
 
 You may wish to modify the Redis configuration at `/etc/redis.conf` or `/etc/redis/redis.conf`, however in most cases the default configuration is sufficient.
 You may wish to modify the Redis configuration at `/etc/redis.conf` or `/etc/redis/redis.conf`, however in most cases the default configuration is sufficient.
 
 
+!!! danger "Restrict access to Redis"
+    NetBox's background workers execute jobs read from Redis, so anyone able to write to the `tasks` database can run
+    arbitrary code on a worker. Treat Redis as trusted infrastructure: keep it bound to `localhost` (the default) or a
+    private network, and enable authentication if it is reachable by any other host. See
+    [Redis configuration](../configuration/required-parameters.md#redis) for details.
+
 ## Verify Service Status
 ## Verify Service Status
 
 
 Use the `redis-cli` utility to ensure the Redis service is functional:
 Use the `redis-cli` utility to ensure the Redis service is functional:

+ 20 - 0
docs/installation/5-http-server.md

@@ -95,3 +95,23 @@ If you are able to connect but receive a 502 (bad gateway) error, check the foll
 * The WSGI worker processes (gunicorn) are running (`systemctl status netbox` should show a status of "active (running)")
 * The WSGI worker processes (gunicorn) are running (`systemctl status netbox` should show a status of "active (running)")
 * Nginx/Apache is configured to connect to the port on which gunicorn is listening (default is 8001).
 * Nginx/Apache is configured to connect to the port on which gunicorn is listening (default is 8001).
 * SELinux is not preventing the reverse proxy connection. You may need to allow HTTP network connections with the command `setsebool -P httpd_can_network_connect 1`
 * SELinux is not preventing the reverse proxy connection. You may need to allow HTTP network connections with the command `setsebool -P httpd_can_network_connect 1`
+
+## What's Next?
+
+With NetBox up and running, you may want to extend its capabilities by installing one or more plugins. Plugins are optional components that add new models, views, integrations, and other functionality on top of core NetBox. Some of the most popular plugins include:
+
+* [**NetBox Branching**](https://github.com/netboxlabs/netbox-branching) — Create isolated, changeable branches of your NetBox data, allowing multiple users to work in parallel and merge their changes.
+* [**NetBox Custom Objects**](https://github.com/netboxlabs/netbox-custom-objects) — Define entirely new object types directly in the UI, without writing any code.
+* [**NetBox DNS**](https://github.com/sys4/netbox-plugin-dns) — Manage DNS zones, records, and related data as an authoritative source of truth.
+* [**NetBox BGP**](https://github.com/netbox-community/netbox-bgp) — Document and manage BGP sessions, communities, and routing policies.
+
+Installing a plugin generally involves adding its Python package to `/opt/netbox/local_requirements.txt`, enabling it in the `PLUGINS` list in `configuration.py`, and running NetBox's upgrade script:
+
+```no-highlight
+$ sudo sh -c "echo '<package>' >> /opt/netbox/local_requirements.txt"
+$ sudo /opt/netbox/upgrade.sh
+```
+
+Each plugin is different and may require additional configuration or setup steps, so always consult the plugin's own documentation as well as NetBox's [plugin installation guide](../plugins/installation.md) before getting started.
+
+To browse the full catalog of available plugins, visit [netboxlabs.com/plugins](https://netboxlabs.com/plugins/).

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

@@ -168,6 +168,9 @@ Or by a set of attributes which uniquely identify the rack:
 
 
 Note that if the provided parameters do not return exactly one object, a validation error is raised.
 Note that if the provided parameters do not return exactly one object, a validation error is raised.
 
 
+!!! note "Permissions"
+    When a related object is referenced by a set of attributes, the lookup is restricted to only those objects which the requesting user has permission to view. This prevents the enumeration of objects by their attributes. Referencing a related object directly by its numeric ID is always permitted, regardless of the user's view permissions for that object.
+
 ### Generic Relations
 ### Generic Relations
 
 
 Some objects within NetBox have attributes which can reference an object of multiple types, known as _generic relations_. For example, an IP address can be assigned to either a device interface _or_ a virtual machine interface. When making this assignment via the REST API, we must specify two attributes:
 Some objects within NetBox have attributes which can reference an object of multiple types, known as _generic relations_. For example, an IP address can be assigned to either a device interface _or_ a virtual machine interface. When making this assignment via the REST API, we must specify two attributes:
@@ -854,6 +857,8 @@ By default, a token can be used to perform all actions via the API that a user w
 
 
 Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)
 Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)
 
 
+The client IP address is determined from the HTTP headers configured by [`HTTP_CLIENT_IP_HEADERS`](../configuration/system.md#http_client_ip_headers); see the security note there regarding header trust.
+
 #### Creating Tokens for Other Users
 #### Creating Tokens for Other Users
 
 
 It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission by default to create their own tokens, this permission is required to enable the creation of tokens for other users.
 It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission by default to create their own tokens, this permission is required to enable the creation of tokens for other users.

+ 35 - 1
docs/release-notes/version-4.6.md

@@ -1,5 +1,39 @@
 # NetBox v4.6
 # NetBox v4.6
 
 
+## v4.6.5 (2026-07-14)
+
+### Enhancements
+
+* [#18828](https://github.com/netbox-community/netbox/issues/18828) - Add MDC connector type for fiber ports and cables
+* [#22544](https://github.com/netbox-community/netbox/issues/22544) - Provide a REST API method to update or overwrite an existing custom script module
+* [#22629](https://github.com/netbox-community/netbox/issues/22629) - Enforce a lower maximum uploaded image size (50 megapixels) than the Pillow default
+* [#22649](https://github.com/netbox-community/netbox/issues/22649) - Add Korean language support
+
+### Performance Improvements
+
+* [#22551](https://github.com/netbox-community/netbox/issues/22551) - Add a prefetch hint to the GraphQL `tags` field to avoid N+1 queries on list endpoints
+* [#22589](https://github.com/netbox-community/netbox/issues/22589) - Cache serializers to avoid repeated reinstantiation on the cables list REST API endpoint
+
+### Bug Fixes
+
+* [#22154](https://github.com/netbox-community/netbox/issues/22154) - Correct the OpenAPI schema for relation counts on nested (brief) object representations
+* [#22500](https://github.com/netbox-community/netbox/issues/22500) - Return the configured maintenance mode message for REST API requests
+* [#22521](https://github.com/netbox-community/netbox/issues/22521) - Honor `RAM_BASE_UNIT` for the default memory of a virtual machine type
+* [#22539](https://github.com/netbox-community/netbox/issues/22539) - Restore the available IPs button for users with constrained permissions
+* [#22566](https://github.com/netbox-community/netbox/issues/22566) - Avoid name collisions when a custom script's filename matches a core app label
+* [#22568](https://github.com/netbox-community/netbox/issues/22568) - Fix uncaught `ValueError` (HTTP 500) when an invalid `filter_id` query parameter is provided
+* [#22573](https://github.com/netbox-community/netbox/issues/22573) - Remove persistent scrollbar on the navigation menu in Chrome
+* [#22578](https://github.com/netbox-community/netbox/issues/22578) - Ensure shared objects are treated consistently across the UI and REST API
+* [#22582](https://github.com/netbox-community/netbox/issues/22582) - Use a theme-aware color for interface list row separators in dark mode
+* [#22598](https://github.com/netbox-community/netbox/issues/22598) - Fix `ValueError` exception when viewing background tasks under RQ 2.10
+* [#22617](https://github.com/netbox-community/netbox/issues/22617) - Require the "change" permission (rather than "add") when editing objects via the bulk import form
+* [#22626](https://github.com/netbox-community/netbox/issues/22626) - Ensure custom link names are escaped when rendering fails
+* [#22632](https://github.com/netbox-community/netbox/issues/22632) - Fix `ValueError` raised by object-level permission checks for cross-app proxy models
+* [#22652](https://github.com/netbox-community/netbox/issues/22652) - Explicitly disable autoescaping for config templates rendered via `SandboxedEnvironment`
+* [#22657](https://github.com/netbox-community/netbox/issues/22657) - Escape the exception message in the `render_widget` template tag before marking it safe
+
+---
+
 ## v4.6.4 (2026-06-30)
 ## v4.6.4 (2026-06-30)
 
 
 ### Enhancements
 ### Enhancements
@@ -67,7 +101,7 @@
 * [#22346](https://github.com/netbox-community/netbox/issues/22346) - Render SSO/SAML authentication failures as a login page message instead of an HTTP 500 error
 * [#22346](https://github.com/netbox-community/netbox/issues/22346) - Render SSO/SAML authentication failures as a login page message instead of an HTTP 500 error
 * [#22357](https://github.com/netbox-community/netbox/issues/22357) - Remove the unused `local_context_data` field from `dcim.Module` (which no longer inherits from `ConfigContextModel`)
 * [#22357](https://github.com/netbox-community/netbox/issues/22357) - Remove the unused `local_context_data` field from `dcim.Module` (which no longer inherits from `ConfigContextModel`)
 * [#22376](https://github.com/netbox-community/netbox/issues/22376) - Fix `AssertionError` in event rule script jobs when a device type has an image attached
 * [#22376](https://github.com/netbox-community/netbox/issues/22376) - Fix `AssertionError` in event rule script jobs when a device type has an image attached
-* [#22388](https://github.com/netbox-community/netbox/issues/22388) - Pin redis-py to <8.0 to avoid a startup failure on older Redis releases
+* [#22388](https://github.com/netbox-community/netbox/issues/22388) - Pin redis-py to 7.x to avoid a startup failure on older Redis releases
 * [#22397](https://github.com/netbox-community/netbox/issues/22397) - Fix `AttributeError` exception when an unauthenticated user attempts to export devices
 * [#22397](https://github.com/netbox-community/netbox/issues/22397) - Fix `AttributeError` exception when an unauthenticated user attempts to export devices
 * [#22399](https://github.com/netbox-community/netbox/issues/22399) - Enforce object permissions on the related object when serving static media
 * [#22399](https://github.com/netbox-community/netbox/issues/22399) - Enforce object permissions on the related object when serving static media
 * [#22427](https://github.com/netbox-community/netbox/issues/22427) - Validate `JSONFilter.path` to prevent ORM operator injection over JSONField contents in the GraphQL API
 * [#22427](https://github.com/netbox-community/netbox/issues/22427) - Validate `JSONFilter.path` to prevent ORM operator injection over JSONField contents in the GraphQL API

+ 1 - 0
mkdocs.yml

@@ -333,6 +333,7 @@ nav:
         - Internationalization: 'development/internationalization.md'
         - Internationalization: 'development/internationalization.md'
         - Translations: 'development/translations.md'
         - Translations: 'development/translations.md'
         - Release Checklist: 'development/release-checklist.md'
         - Release Checklist: 'development/release-checklist.md'
+        - Building the Package: 'development/building-the-package.md'
         - git Cheat Sheet: 'development/git-cheat-sheet.md'
         - git Cheat Sheet: 'development/git-cheat-sheet.md'
     - Release Notes:
     - Release Notes:
         - Summary: 'release-notes/index.md'
         - Summary: 'release-notes/index.md'

+ 1 - 1
netbox/circuits/migrations/0058_denormalization_triggers.py

@@ -12,7 +12,7 @@ class Migration(migrations.Migration):
     dependencies = [
     dependencies = [
         ('circuits', '0057_default_ordering_indexes'),
         ('circuits', '0057_default_ordering_indexes'),
         # Source tables (dcim_site, dcim_location) must already exist.
         # Source tables (dcim_site, dcim_location) must already exist.
-        ('dcim', '0238_ltree_paths'),
+        ('dcim', '0240_ltree_paths'),
     ]
     ]
 
 
     operations = [
     operations = [

+ 7 - 0
netbox/core/apps.py

@@ -4,6 +4,7 @@ from django.core.cache import cache
 from django.db import models
 from django.db import models
 from django.db.migrations.operations import AlterModelOptions
 from django.db.migrations.operations import AlterModelOptions
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
+from PIL import Image
 
 
 from core.events import *
 from core.events import *
 from netbox.events import EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING, EventType
 from netbox.events import EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING, EventType
@@ -16,6 +17,12 @@ AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name_plural')
 # Use our custom destructor to ignore certain attributes when calculating field migrations
 # Use our custom destructor to ignore certain attributes when calculating field migrations
 models.Field.deconstruct = custom_deconstruct
 models.Field.deconstruct = custom_deconstruct
 
 
+# Cap the maximum size of an image Pillow will decode, to mitigate decompression-bomb DoS attacks. Pillow raises a
+# DecompressionBombError when an image's declared dimensions exceed 2x this value, before allocating pixel buffers.
+# Django's & DRF's ImageField already convert that exception into a validation error, so no additional handling is
+# required; this single assignment covers all image upload paths (UI forms, REST API, and direct model saves).
+Image.MAX_IMAGE_PIXELS = 25_000_000
+
 
 
 class CoreConfig(AppConfig):
 class CoreConfig(AppConfig):
     name = "core"
     name = "core"

+ 107 - 0
netbox/core/management/commands/upgrade.py

@@ -0,0 +1,107 @@
+"""Run the NetBox application tasks required after installing or upgrading NetBox.
+
+The command runs the NetBox application-level tasks that prepare the database and
+static assets after the package and configuration are already in place - for both a
+fresh installation and an upgrade. It does not perform host or bootstrap work
+(creating the virtual environment, installing packages, configuring services); that
+stays in upgrade.sh and the documented pip steps.
+"""
+
+import os
+import subprocess
+
+from django.conf import settings
+from django.core.management import call_command
+from django.core.management.base import BaseCommand
+
+
+def _docs_source_root():
+    # mkdocs.yml sits beside the application root in a checkout. Wheels ship the
+    # pre-rendered site instead of the sources: no mkdocs.yml, the build is skipped.
+    candidate = os.path.dirname(settings.BASE_DIR)
+    if os.path.isfile(os.path.join(candidate, 'mkdocs.yml')):
+        return candidate
+    return None
+
+
+class Command(BaseCommand):
+    help = "Run the NetBox application tasks required after installing or upgrading NetBox."
+
+    def add_arguments(self, parser):
+        parser.add_argument('--no-input', action='store_true', dest='no_input',
+                            help="Do not prompt for user input.")
+        parser.add_argument('--readonly', action='store_true', dest='readonly',
+                            help="Skip all tasks that modify the database or filesystem "
+                                 "(no migrations, no static collection).")
+        parser.add_argument('--skip-migrations', action='store_true', dest='skip_migrations',
+                            help="Skip applying database migrations.")
+        parser.add_argument('--skip-static', action='store_true', dest='skip_static',
+                            help="Skip collecting static files.")
+        parser.add_argument('--skip-reindex', action='store_true', dest='skip_reindex',
+                            help="Skip rebuilding the search index.")
+        parser.add_argument('--build-docs', action='store_true', dest='build_docs',
+                            help="Build the local documentation (requires the documentation source tree).")
+
+    def handle(self, *args, **options):
+        out, style = self.stdout, self.style
+        out.write(style.SUCCESS("Running NetBox upgrade tasks..."))
+
+        # Database migrations (writes to the database)
+        if options['skip_migrations'] or options['readonly']:
+            out.write("Skipping database migrations.")
+        else:
+            out.write("Applying database migrations...")
+            call_command('migrate', interactive=not options['no_input'], stdout=out)
+
+        # Missing cable paths (writes to the database)
+        if options['readonly']:
+            out.write("Skipping cable path check.")
+        else:
+            out.write("Checking for missing cable paths...")
+            call_command('trace_paths', no_input=options['no_input'], stdout=out)
+
+        # Documentation (filesystem; needs the documentation source tree)
+        if options['readonly'] and options['build_docs']:
+            out.write("Skipping documentation build.")
+        elif options['build_docs']:
+            docs_root = _docs_source_root()
+            if docs_root is None:
+                out.write(style.WARNING(
+                    "Skipping documentation build; the documentation source tree is not available "
+                    "in this installation."
+                ))
+            else:
+                out.write("Building documentation...")
+                # -c cleans the cache; -s (strict) is deliberately omitted so a docs
+                # warning cannot abort an instance upgrade.
+                subprocess.run(['zensical', 'build', '-c'], cwd=docs_root, check=True)
+
+        # Static files (filesystem)
+        if options['skip_static'] or options['readonly']:
+            out.write("Skipping static file collection.")
+        else:
+            out.write("Collecting static files...")
+            call_command('collectstatic', interactive=not options['no_input'], stdout=out)
+
+        # Stale content types (writes to the database)
+        if options['readonly']:
+            out.write("Skipping stale content type removal.")
+        else:
+            out.write("Removing stale content types...")
+            call_command('remove_stale_contenttypes', interactive=not options['no_input'], stdout=out)
+
+        # Search index (writes to the database)
+        if options['skip_reindex'] or options['readonly']:
+            out.write("Skipping search index rebuild.")
+        else:
+            out.write("Rebuilding the search index (lazily)...")
+            call_command('reindex', lazy=True, stdout=out)
+
+        # Expired sessions (writes to the database)
+        if options['readonly']:
+            out.write("Skipping expired session cleanup.")
+        else:
+            out.write("Clearing expired sessions...")
+            call_command('clearsessions', stdout=out)
+
+        out.write(style.SUCCESS("Finished NetBox upgrade tasks."))

+ 2 - 2
netbox/core/tests/test_changelog.py

@@ -449,7 +449,7 @@ class ChangeLogAPITestCase(APITestCase):
         }
         }
         self.assertEqual(ObjectChange.objects.count(), 0)
         self.assertEqual(ObjectChange.objects.count(), 0)
         url = reverse('dcim-api:site-list')
         url = reverse('dcim-api:site-list')
-        self.add_permissions('dcim.add_site')
+        self.add_permissions('dcim.add_site', 'extras.view_tag')
 
 
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -481,7 +481,7 @@ class ChangeLogAPITestCase(APITestCase):
             ]
             ]
         }
         }
         self.assertEqual(ObjectChange.objects.count(), 0)
         self.assertEqual(ObjectChange.objects.count(), 0)
-        self.add_permissions('dcim.change_site')
+        self.add_permissions('dcim.change_site', 'extras.view_tag')
         url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
         url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
 
 
         response = self.client.put(url, data, format='json', **self.header)
         response = self.client.put(url, data, format='json', **self.header)

+ 76 - 1
netbox/core/tests/test_management_commands.py

@@ -1,3 +1,5 @@
+import os
+import tempfile
 from io import StringIO
 from io import StringIO
 from types import SimpleNamespace
 from types import SimpleNamespace
 from unittest.mock import MagicMock, patch
 from unittest.mock import MagicMock, patch
@@ -7,7 +9,7 @@ from django.core.management.base import CommandError
 from django.test import TestCase, override_settings
 from django.test import TestCase, override_settings
 
 
 from core.choices import DataSourceStatusChoices
 from core.choices import DataSourceStatusChoices
-from core.management.commands import nbshell
+from core.management.commands import nbshell, upgrade
 from core.management.commands.rqworker import DEFAULT_QUEUES
 from core.management.commands.rqworker import DEFAULT_QUEUES
 
 
 
 
@@ -315,3 +317,76 @@ class SyncDataSourceTestCase(TestCase):
         self.assertIn('[1] Syncing source-a', out.getvalue())
         self.assertIn('[1] Syncing source-a', out.getvalue())
         self.assertIn('[2] Syncing source-b', out.getvalue())
         self.assertIn('[2] Syncing source-b', out.getvalue())
         self.assertIn('Finished.', out.getvalue())
         self.assertIn('Finished.', out.getvalue())
+
+
+class UpgradeCommandTest(TestCase):
+    """The upgrade command orchestrates the application task sequence for installs and upgrades."""
+
+    def _run(self, **kwargs):
+        out = StringIO()
+        with (
+            patch('core.management.commands.upgrade.call_command') as cc,
+            patch('core.management.commands.upgrade.subprocess.run') as sub,
+        ):
+            call_command('upgrade', stdout=out, **kwargs)
+        return [c.args[0] for c in cc.call_args_list], cc, sub, out.getvalue()
+
+    def test_full_sequence_order(self):
+        seq, _, sub, _ = self._run()
+        self.assertEqual(seq, [
+            'migrate', 'trace_paths',
+            'collectstatic', 'remove_stale_contenttypes', 'reindex', 'clearsessions',
+        ])
+        sub.assert_not_called()  # docs not built by default
+
+    def test_readonly_skips_all_tasks_including_static(self):
+        """--readonly prints a skip message for every task, including the three without dedicated flags."""
+        seq, _, sub, out = self._run(readonly=True)
+        self.assertEqual(seq, [])
+        sub.assert_not_called()
+        self.assertIn('Skipping database migrations.', out)
+        self.assertIn('Skipping cable path check.', out)
+        self.assertIn('Skipping static file collection.', out)
+        self.assertIn('Skipping stale content type removal.', out)
+        self.assertIn('Skipping search index rebuild.', out)
+        self.assertIn('Skipping expired session cleanup.', out)
+
+    def test_skip_flags(self):
+        seq, _, _, _ = self._run(skip_migrations=True, skip_static=True, skip_reindex=True)
+        self.assertEqual(
+            seq,
+            ['trace_paths', 'remove_stale_contenttypes', 'clearsessions'],
+        )
+
+    def test_build_docs_invokes_zensical_when_sources_present(self):
+        with patch('core.management.commands.upgrade._docs_source_root', return_value='/repo'):
+            _, _, sub, _ = self._run(build_docs=True)
+        sub.assert_called_once()
+        self.assertEqual(sub.call_args.args[0], ['zensical', 'build', '-c'])
+
+    def test_build_docs_skipped_when_sources_absent(self):
+        with patch('core.management.commands.upgrade._docs_source_root', return_value=None):
+            _, _, sub, _ = self._run(build_docs=True)
+        sub.assert_not_called()
+
+    def test_readonly_with_build_docs_skips_docs(self):
+        with patch('core.management.commands.upgrade._docs_source_root', return_value='/repo'):
+            _, _, sub, _ = self._run(readonly=True, build_docs=True)
+        sub.assert_not_called()
+
+    def test_docs_source_root_checkout_shaped(self):
+        """mkdocs.yml beside the application root (checkout layout) is found."""
+        with tempfile.TemporaryDirectory() as root:
+            base_dir = os.path.join(root, 'netbox')
+            os.mkdir(base_dir)
+            open(os.path.join(root, 'mkdocs.yml'), 'w').close()
+            with override_settings(BASE_DIR=base_dir):
+                self.assertEqual(upgrade._docs_source_root(), root)
+
+    def test_docs_source_root_none_when_absent(self):
+        """No mkdocs.yml beside the application root returns None."""
+        with tempfile.TemporaryDirectory() as root:
+            base_dir = os.path.join(root, 'netbox')
+            os.mkdir(base_dir)
+            with override_settings(BASE_DIR=base_dir):
+                self.assertIsNone(upgrade._docs_source_root())

+ 20 - 16
netbox/dcim/api/serializers_/base.py

@@ -3,6 +3,7 @@ from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from dcim.models import FrontPort, FrontPortTemplate, PortMapping, PortTemplateMapping, RearPort, RearPortTemplate
 from dcim.models import FrontPort, FrontPortTemplate, PortMapping, PortTemplateMapping, RearPort, RearPortTemplate
+from dcim.utils import reconcile_port_mappings
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 
 
 __all__ = (
 __all__ = (
@@ -68,17 +69,25 @@ class PortSerializer(serializers.ModelSerializer):
             return PortTemplateMapping, 'rear_port'
             return PortTemplateMapping, 'rear_port'
         raise ValueError(f"Could not determine mapping details for {self.__class__}")
         raise ValueError(f"Could not determine mapping details for {self.__class__}")
 
 
+    def _reconcile_mappings(self, instance, mappings):
+        mapping_model, fk_name = self._mapper
+        other_field = 'rear_port' if fk_name == 'front_port' else 'front_port'
+
+        # Normalize the opposite-port FK from a model instance to its id so the mappings can be
+        # reconciled by value.
+        desired = []
+        for attrs in mappings:
+            attrs = dict(attrs)
+            if other_field in attrs:
+                attrs[f'{other_field}_id'] = attrs.pop(other_field).pk
+            desired.append(attrs)
+
+        reconcile_port_mappings(mapping_model, parent_field=fk_name, parent=instance, desired=desired)
+
     def create(self, validated_data):
     def create(self, validated_data):
         mappings = validated_data.pop('mappings', [])
         mappings = validated_data.pop('mappings', [])
         instance = super().create(validated_data)
         instance = super().create(validated_data)
-
-        # Create port mappings
-        mapping_model, fk_name = self._mapper
-        for attrs in mappings:
-            mapping_model.objects.create(**{
-                fk_name: instance,
-                **attrs,
-            })
+        self._reconcile_mappings(instance, mappings)
 
 
         return instance
         return instance
 
 
@@ -86,14 +95,9 @@ class PortSerializer(serializers.ModelSerializer):
         mappings = validated_data.pop('mappings', None)
         mappings = validated_data.pop('mappings', None)
         instance = super().update(instance, validated_data)
         instance = super().update(instance, validated_data)
 
 
+        # Only reconcile when the client supplied rear_ports; a PATCH that omits it leaves the
+        # existing mappings untouched.
         if mappings is not None:
         if mappings is not None:
-            # Update port mappings
-            mapping_model, fk_name = self._mapper
-            mapping_model.objects.filter(**{fk_name: instance}).delete()
-            for attrs in mappings:
-                mapping_model.objects.create(**{
-                    fk_name: instance,
-                    **attrs,
-                })
+            self._reconcile_mappings(instance, mappings)
 
 
         return instance
         return instance

+ 2 - 0
netbox/dcim/choices.py

@@ -1676,6 +1676,7 @@ class PortTypeChoices(ChoiceSet):
     TYPE_SPLICE = 'splice'
     TYPE_SPLICE = 'splice'
     TYPE_CS = 'cs'
     TYPE_CS = 'cs'
     TYPE_SN = 'sn'
     TYPE_SN = 'sn'
+    TYPE_MDC = 'mdc'
     TYPE_SMA_905 = 'sma-905'
     TYPE_SMA_905 = 'sma-905'
     TYPE_SMA_906 = 'sma-906'
     TYPE_SMA_906 = 'sma-906'
     TYPE_URM_P2 = 'urm-p2'
     TYPE_URM_P2 = 'urm-p2'
@@ -1747,6 +1748,7 @@ class PortTypeChoices(ChoiceSet):
                 Choice(TYPE_ST, 'ST'),
                 Choice(TYPE_ST, 'ST'),
                 Choice(TYPE_CS, 'CS'),
                 Choice(TYPE_CS, 'CS'),
                 Choice(TYPE_SN, 'SN'),
                 Choice(TYPE_SN, 'SN'),
+                Choice(TYPE_MDC, 'MDC'),
                 Choice(TYPE_SMA_905, 'SMA 905'),
                 Choice(TYPE_SMA_905, 'SMA 905'),
                 Choice(TYPE_SMA_906, 'SMA 906'),
                 Choice(TYPE_SMA_906, 'SMA 906'),
                 Choice(TYPE_URM_P2, 'URM-P2'),
                 Choice(TYPE_URM_P2, 'URM-P2'),

+ 17 - 38
netbox/dcim/forms/mixins.py

@@ -1,12 +1,10 @@
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
-from django.db import connection
-from django.db.models.signals import post_save
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from dcim.constants import LOCATION_SCOPE_TYPES
 from dcim.constants import LOCATION_SCOPE_TYPES
-from dcim.models import PortMapping, PortTemplateMapping
+from dcim.utils import reconcile_port_mappings
 from utilities.forms import GenericObjectFormMixin
 from utilities.forms import GenericObjectFormMixin
 from utilities.forms.fields import (
 from utilities.forms.fields import (
     CSVContentTypeField,
     CSVContentTypeField,
@@ -131,43 +129,24 @@ class FrontPortFormMixin(forms.Form):
     def _save_m2m(self):
     def _save_m2m(self):
         super()._save_m2m()
         super()._save_m2m()
 
 
-        # TODO: Can this be made more efficient?
-        # Delete existing rear port mappings
-        self.port_mapping_model.objects.filter(front_port_id=self.instance.pk).delete()
-
-        # Create new rear port mappings
-        mappings = []
-        if self.port_mapping_model is PortTemplateMapping:
-            params = {
-                'device_type_id': self.instance.device_type_id,
-                'module_type_id': self.instance.module_type_id,
-            }
-        else:
-            params = {
-                'device_id': self.instance.device_id,
-            }
+        # Build the desired set of mappings from the submitted rear port pairs, assigning front port
+        # positions in order. reconcile_port_mappings() then writes only the difference, so re-saving
+        # a front port without changing its wiring produces no writes (and no changelog churn).
+        desired = []
         for i, rp_position in enumerate(self.cleaned_data['rear_ports'], start=1):
         for i, rp_position in enumerate(self.cleaned_data['rear_ports'], start=1):
             rear_port_id, rear_port_position = rp_position.split(':')
             rear_port_id, rear_port_position = rp_position.split(':')
-            mappings.append(
-                self.port_mapping_model(**{
-                    **params,
-                    'front_port_id': self.instance.pk,
-                    'front_port_position': i,
-                    'rear_port_id': rear_port_id,
-                    'rear_port_position': rear_port_position,
-                })
-            )
-        self.port_mapping_model.objects.bulk_create(mappings)
-        # Send post_save signals
-        for mapping in mappings:
-            post_save.send(
-                sender=PortMapping,
-                instance=mapping,
-                created=True,
-                raw=False,
-                using=connection,
-                update_fields=None
-            )
+            desired.append({
+                'front_port_position': i,
+                'rear_port_id': int(rear_port_id),
+                'rear_port_position': int(rear_port_position),
+            })
+
+        reconcile_port_mappings(
+            self.port_mapping_model,
+            parent_field='front_port',
+            parent=self.instance,
+            desired=desired,
+        )
 
 
     def _get_rear_port_choices(self, parent_filter, front_port):
     def _get_rear_port_choices(self, parent_filter, front_port):
         """
         """

+ 16 - 0
netbox/dcim/migrations/0238_alter_cable__abs_length.py

@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0237_module_remove_local_context_data'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='cable',
+            name='_abs_length',
+            field=models.DecimalField(blank=True, decimal_places=4, max_digits=14, null=True),
+        ),
+    ]

+ 31 - 0
netbox/dcim/migrations/0239_add_portmapping_objectchange.py

@@ -0,0 +1,31 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("dcim", "0238_alter_cable__abs_length"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="portmapping",
+            name="created",
+            field=models.DateTimeField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name="portmapping",
+            name="last_updated",
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AddField(
+            model_name="porttemplatemapping",
+            name="created",
+            field=models.DateTimeField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name="porttemplatemapping",
+            name="last_updated",
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+    ]

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

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

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

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

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

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

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

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

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

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

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

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

+ 5 - 0
netbox/dcim/models/base.py

@@ -29,6 +29,8 @@ class PortMappingBase(models.Model):
         ),
         ),
     )
     )
 
 
+    # Change-logged but private: no public API/URL, and the delete-time UPDATE cascade onto the
+    # parent ports stays suppressed (see #21390, #22270).
     _netbox_private = True
     _netbox_private = True
 
 
     class Meta:
     class Meta:
@@ -44,6 +46,9 @@ class PortMappingBase(models.Model):
             ),
             ),
         )
         )
 
 
+    def __str__(self):
+        return f'{self.front_port}:{self.front_port_position} to {self.rear_port}:{self.rear_port_position}'
+
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 

+ 46 - 11
netbox/dcim/models/cables.py

@@ -133,7 +133,7 @@ class Cable(PrimaryModel):
     )
     )
     # Stores the normalized length (in meters) for database ordering
     # Stores the normalized length (in meters) for database ordering
     _abs_length = models.DecimalField(
     _abs_length = models.DecimalField(
-        max_digits=10,
+        max_digits=14,
         decimal_places=4,
         decimal_places=4,
         blank=True,
         blank=True,
         null=True
         null=True
@@ -1169,10 +1169,14 @@ class CablePath(models.Model):
 
 
     def get_total_length(self):
     def get_total_length(self):
         """
         """
-        Return a tuple containing the sum of the length of each cable in the path
-        and a flag indicating whether the length is definitive.
+        Return a tuple containing the sum of the length of each cable and the distance of each circuit
+        crossed by the path, and a flag indicating whether the length is definitive.
         """
         """
-        cable_ct = ObjectType.objects.get_for_model(Cable).pk
+        from circuits.models import CircuitTermination
+
+        object_types = ObjectType.objects.get_for_models(Cable, CircuitTermination)
+        cable_ct = object_types[Cable].pk
+        circuit_termination_ct = object_types[CircuitTermination].pk
 
 
         # Pre-cache cable lengths by ID
         # Pre-cache cable lengths by ID
         cable_ids = self.get_cable_ids()
         cable_ids = self.get_cable_ids()
@@ -1181,20 +1185,51 @@ class CablePath(models.Model):
             for cable in Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False).values('pk', '_abs_length')
             for cable in Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False).values('pk', '_abs_length')
         }
         }
 
 
+        # Pre-cache the circuit terminations within the path, along with their circuits
+        circuit_termination_ids = []
+        for node in self._nodes:
+            ct, pk = decompile_path_node(node)
+            if ct == circuit_termination_ct:
+                circuit_termination_ids.append(pk)
+        circuit_terminations = CircuitTermination.objects.select_related('circuit').in_bulk(circuit_termination_ids)
+
         # Iterate through each set of nodes in the path. For cables, add the length of the longest cable to the total
         # Iterate through each set of nodes in the path. For cables, add the length of the longest cable to the total
-        # length of the path.
+        # length of the path. Also map each set of nodes to its circuit terminations, keyed by circuit ID.
         total_length = 0
         total_length = 0
+        circuit_hops = []
         for node_set in self.path:
         for node_set in self.path:
             hop_length = 0
             hop_length = 0
+            hop_terminations = {}
             for node in node_set:
             for node in node_set:
                 ct, pk = decompile_path_node(node)
                 ct, pk = decompile_path_node(node)
-                if ct != cable_ct:
-                    break  # Not a cable
-                if pk in cables and cables[pk] > hop_length:
-                    hop_length = cables[pk]
+                if ct == cable_ct:
+                    if pk in cables and cables[pk] > hop_length:
+                        hop_length = cables[pk]
+                elif ct == circuit_termination_ct:
+                    termination = circuit_terminations.get(pk)
+                    if termination is not None:
+                        hop_terminations[termination.circuit_id] = termination
+                else:
+                    break  # Neither a cable nor a circuit termination
             total_length += hop_length
             total_length += hop_length
-
-        is_definitive = len(cables) == len(cable_ids)
+            circuit_hops.append(hop_terminations)
+
+        # Unresolvable circuit terminations may conceal a crossing, so they render the total non-definitive
+        is_definitive = len(cables) == len(cable_ids) and len(circuit_terminations) == len(set(circuit_termination_ids))
+
+        # A circuit crossing appears as two adjacent sets of opposing terminations of the same circuit. For each
+        # crossing, add the longest distance among the circuits crossed, mirroring the handling of parallel cables.
+        for near_hop, far_hop in itertools.pairwise(circuit_hops):
+            crossing_distance = 0
+            for circuit_id in near_hop.keys() & far_hop.keys():
+                if near_hop[circuit_id].term_side == far_hop[circuit_id].term_side:
+                    continue
+                distance = near_hop[circuit_id].circuit._abs_distance
+                if distance is None:
+                    is_definitive = False
+                elif distance > crossing_distance:
+                    crossing_distance = distance
+            total_length += crossing_distance
 
 
         return total_length, is_definitive
         return total_length, is_definitive
 
 

+ 6 - 1
netbox/dcim/models/device_component_templates.py

@@ -11,6 +11,7 @@ from dcim.models.base import PortMappingBase
 from dcim.models.mixins import InterfaceValidationMixin
 from dcim.models.mixins import InterfaceValidationMixin
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
+from netbox.models.features import ChangeLoggingMixin
 from netbox.models.ltree import LtreeManager, LtreeModel
 from netbox.models.ltree import LtreeManager, LtreeModel
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
@@ -543,7 +544,7 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
         }
         }
 
 
 
 
-class PortTemplateMapping(PortMappingBase):
+class PortTemplateMapping(ChangeLoggingMixin, PortMappingBase):
     """
     """
     Maps a FrontPortTemplate & position to a RearPortTemplate & position.
     Maps a FrontPortTemplate & position to a RearPortTemplate & position.
     """
     """
@@ -572,6 +573,10 @@ class PortTemplateMapping(PortMappingBase):
         related_name='mappings',
         related_name='mappings',
     )
     )
 
 
+    class Meta(PortMappingBase.Meta):
+        # Inherit the unique constraints from PortMappingBase.Meta.
+        pass
+
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 

+ 6 - 1
netbox/dcim/models/device_components.py

@@ -15,6 +15,7 @@ from dcim.models.base import PortMappingBase
 from dcim.models.mixins import InterfaceValidationMixin
 from dcim.models.mixins import InterfaceValidationMixin
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
 from netbox.models import NetBoxModel, OrganizationalModel
 from netbox.models import NetBoxModel, OrganizationalModel
+from netbox.models.features import ChangeLoggingMixin
 from netbox.models.ltree import LtreeManager, LtreeModel, SortPathField
 from netbox.models.ltree import LtreeManager, LtreeModel, SortPathField
 from netbox.models.mixins import OwnerMixin
 from netbox.models.mixins import OwnerMixin
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
@@ -1196,7 +1197,7 @@ class Interface(
 # Pass-through ports
 # Pass-through ports
 #
 #
 
 
-class PortMapping(PortMappingBase):
+class PortMapping(ChangeLoggingMixin, PortMappingBase):
     """
     """
     Maps a FrontPort & position to a RearPort & position.
     Maps a FrontPort & position to a RearPort & position.
     """
     """
@@ -1216,6 +1217,10 @@ class PortMapping(PortMappingBase):
         related_name='mappings',
         related_name='mappings',
     )
     )
 
 
+    class Meta(PortMappingBase.Meta):
+        # Inherit the unique constraints from PortMappingBase.Meta.
+        pass
+
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 

+ 5 - 2
netbox/dcim/models/mixins.py

@@ -72,15 +72,18 @@ class CachedScopeMixin(models.Model):
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    # SET_NULL, not CASCADE: these cache an ancestor of the actual scope, so deleting that
+    # ancestor must not delete this object. Deletion of a Region/SiteGroup that *is* the
+    # actual scope is handled independently via its GenericRelation to this model.
     _region = models.ForeignKey(
     _region = models.ForeignKey(
         to='dcim.Region',
         to='dcim.Region',
-        on_delete=models.CASCADE,
+        on_delete=models.SET_NULL,
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
     _site_group = models.ForeignKey(
     _site_group = models.ForeignKey(
         to='dcim.SiteGroup',
         to='dcim.SiteGroup',
-        on_delete=models.CASCADE,
+        on_delete=models.SET_NULL,
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )

+ 24 - 0
netbox/dcim/models/sites.py

@@ -43,6 +43,18 @@ class Region(ContactsMixin, NestedLtreeGroupModel):
         object_id_field='scope_id',
         object_id_field='scope_id',
         related_query_name='region'
         related_query_name='region'
     )
     )
+    clusters = GenericRelation(
+        to='virtualization.Cluster',
+        content_type_field='scope_type',
+        object_id_field='scope_id',
+        related_query_name='region'
+    )
+    wireless_lans = GenericRelation(
+        to='wireless.WirelessLAN',
+        content_type_field='scope_type',
+        object_id_field='scope_id',
+        related_query_name='region'
+    )
 
 
     class Meta:
     class Meta:
         ordering = ('sort_path',)
         ordering = ('sort_path',)
@@ -96,6 +108,18 @@ class SiteGroup(ContactsMixin, NestedLtreeGroupModel):
         object_id_field='scope_id',
         object_id_field='scope_id',
         related_query_name='site_group'
         related_query_name='site_group'
     )
     )
+    clusters = GenericRelation(
+        to='virtualization.Cluster',
+        content_type_field='scope_type',
+        object_id_field='scope_id',
+        related_query_name='site_group'
+    )
+    wireless_lans = GenericRelation(
+        to='wireless.WirelessLAN',
+        content_type_field='scope_type',
+        object_id_field='scope_id',
+        related_query_name='site_group'
+    )
 
 
     class Meta:
     class Meta:
         ordering = ('sort_path',)
         ordering = ('sort_path',)

+ 3 - 0
netbox/dcim/tests/query_counts.json

@@ -4,6 +4,7 @@
   "cablebundle:api_list_objects": 13,
   "cablebundle:api_list_objects": 13,
   "cablebundle:list_objects_with_permission": 20,
   "cablebundle:list_objects_with_permission": 20,
   "cabletermination:api_list_objects": 16,
   "cabletermination:api_list_objects": 16,
+  "consoleconnection:list_objects_with_permission": 29,
   "consoleport:api_list_objects": 14,
   "consoleport:api_list_objects": 14,
   "consoleport:list_objects_with_permission": 21,
   "consoleport:list_objects_with_permission": 21,
   "consoleporttemplate:api_list_objects": 11,
   "consoleporttemplate:api_list_objects": 11,
@@ -24,6 +25,7 @@
   "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": 21,
+  "interfaceconnection:list_objects_with_permission": 41,
   "interfacetemplate:api_list_objects": 11,
   "interfacetemplate:api_list_objects": 11,
   "inventoryitem:api_list_objects": 20,
   "inventoryitem:api_list_objects": 20,
   "inventoryitem:list_objects_with_permission": 23,
   "inventoryitem:list_objects_with_permission": 23,
@@ -49,6 +51,7 @@
   "moduletypeprofile:list_objects_with_permission": 20,
   "moduletypeprofile:list_objects_with_permission": 20,
   "platform:api_list_objects": 13,
   "platform:api_list_objects": 13,
   "platform:list_objects_with_permission": 21,
   "platform:list_objects_with_permission": 21,
+  "powerconnection:list_objects_with_permission": 29,
   "powerfeed:api_list_objects": 15,
   "powerfeed:api_list_objects": 15,
   "powerfeed:list_objects_with_permission": 22,
   "powerfeed:list_objects_with_permission": 22,
   "poweroutlet:api_list_objects": 14,
   "poweroutlet:api_list_objects": 14,

+ 359 - 2
netbox/dcim/tests/test_cablepaths2.py

@@ -1,10 +1,11 @@
 from unittest import skip
 from unittest import skip
 
 
-from circuits.models import CircuitTermination
-from dcim.choices import CableProfileChoices
+from circuits.models import Circuit, CircuitTermination, ProviderNetwork
+from dcim.choices import CableLengthUnitChoices, CableProfileChoices
 from dcim.models import *
 from dcim.models import *
 from dcim.svg import CableTraceSVG
 from dcim.svg import CableTraceSVG
 from dcim.tests.utils import BaseCablePathTestCase
 from dcim.tests.utils import BaseCablePathTestCase
+from netbox.choices import DistanceUnitChoices
 
 
 
 
 class CablePathTestCase(BaseCablePathTestCase):
 class CablePathTestCase(BaseCablePathTestCase):
@@ -1954,6 +1955,362 @@ class CablePathTestCase(BaseCablePathTestCase):
         CableTraceSVG(interfaces[1]).render()
         CableTraceSVG(interfaces[1]).render()
         CableTraceSVG(interfaces[2]).render()
         CableTraceSVG(interfaces[2]).render()
 
 
+    def test_226_total_length_via_circuit(self):
+        """
+        [IF1] --C1-- [CT1] [CT2] --C2-- [IF2]
+        """
+        self.circuit.distance = 10
+        self.circuit.distance_unit = DistanceUnitChoices.UNIT_KILOMETER
+        self.circuit.save()
+
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        circuittermination1 = CircuitTermination.objects.create(
+            circuit=self.circuit,
+            termination=self.site,
+            term_side='A'
+        )
+        circuittermination2 = CircuitTermination.objects.create(
+            circuit=self.circuit,
+            termination=self.site,
+            term_side='Z'
+        )
+
+        # Create cables
+        cable1 = Cable(
+            profile=CableProfileChoices.SINGLE_1C1P,
+            length=200,
+            length_unit=CableLengthUnitChoices.UNIT_METER,
+            a_terminations=[interface1],
+            b_terminations=[circuittermination1]
+        )
+        cable1.clean()
+        cable1.save()
+        cable2 = Cable(
+            profile=CableProfileChoices.SINGLE_1C1P,
+            length=200,
+            length_unit=CableLengthUnitChoices.UNIT_METER,
+            a_terminations=[circuittermination2],
+            b_terminations=[interface2]
+        )
+        cable2.clean()
+        cable2.save()
+
+        # Check for complete paths in both directions
+        paths = [
+            self.assertPathExists(
+                (interface1, cable1, circuittermination1, circuittermination2, cable2, interface2),
+                is_complete=True,
+                is_active=True
+            ),
+            self.assertPathExists(
+                (interface2, cable2, circuittermination2, circuittermination1, cable1, interface1),
+                is_complete=True,
+                is_active=True
+            ),
+        ]
+
+        # The crossed circuit's distance counts toward the total path length
+        for path in paths:
+            self.assertEqual(path.get_total_length(), (10400, True))
+
+        # An unset circuit distance makes the total length non-definitive
+        self.circuit.distance = None
+        self.circuit.save()
+        for path in paths:
+            self.assertEqual(path.get_total_length(), (400, False))
+
+        # A circuit distance of zero is a known value and remains definitive
+        self.circuit.distance = 0
+        self.circuit.distance_unit = DistanceUnitChoices.UNIT_KILOMETER
+        self.circuit.save()
+        for path in paths:
+            self.assertEqual(path.get_total_length(), (400, True))
+
+    def test_227_total_length_via_circuit_without_peer_termination(self):
+        """
+        [IF1] --C1-- [CT1]
+        """
+        self.circuit.distance = 10
+        self.circuit.distance_unit = DistanceUnitChoices.UNIT_KILOMETER
+        self.circuit.save()
+
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        circuittermination1 = CircuitTermination.objects.create(
+            circuit=self.circuit,
+            termination=self.site,
+            term_side='A'
+        )
+        cable1 = Cable(
+            profile=CableProfileChoices.SINGLE_1C1P,
+            length=200,
+            length_unit=CableLengthUnitChoices.UNIT_METER,
+            a_terminations=[interface1],
+            b_terminations=[circuittermination1]
+        )
+        cable1.clean()
+        cable1.save()
+
+        # The distance of a circuit which the path does not cross is excluded
+        path = self.assertPathExists(
+            (interface1, cable1, circuittermination1),
+            is_complete=False
+        )
+        self.assertEqual(path.get_total_length(), (200, True))
+
+    def test_228_total_length_via_multiple_circuits(self):
+        """
+        [IF1] --C1-- [CT1] [CT2] --C2-- [CT3] [CT4] --C3-- [IF2]
+        """
+        self.circuit.distance = 10
+        self.circuit.distance_unit = DistanceUnitChoices.UNIT_KILOMETER
+        self.circuit.save()
+        circuit2 = Circuit.objects.create(
+            provider=self.circuit.provider,
+            type=self.circuit.type,
+            cid='Circuit 2',
+            distance=5,
+            distance_unit=DistanceUnitChoices.UNIT_KILOMETER
+        )
+
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        circuittermination1 = CircuitTermination.objects.create(
+            circuit=self.circuit,
+            termination=self.site,
+            term_side='A'
+        )
+        circuittermination2 = CircuitTermination.objects.create(
+            circuit=self.circuit,
+            termination=self.site,
+            term_side='Z'
+        )
+        circuittermination3 = CircuitTermination.objects.create(
+            circuit=circuit2,
+            termination=self.site,
+            term_side='A'
+        )
+        circuittermination4 = CircuitTermination.objects.create(
+            circuit=circuit2,
+            termination=self.site,
+            term_side='Z'
+        )
+
+        # Create cables
+        cable1 = Cable(
+            profile=CableProfileChoices.SINGLE_1C1P,
+            length=200,
+            length_unit=CableLengthUnitChoices.UNIT_METER,
+            a_terminations=[interface1],
+            b_terminations=[circuittermination1]
+        )
+        cable1.clean()
+        cable1.save()
+        cable2 = Cable(
+            profile=CableProfileChoices.SINGLE_1C1P,
+            length=100,
+            length_unit=CableLengthUnitChoices.UNIT_METER,
+            a_terminations=[circuittermination2],
+            b_terminations=[circuittermination3]
+        )
+        cable2.clean()
+        cable2.save()
+        cable3 = Cable(
+            profile=CableProfileChoices.SINGLE_1C1P,
+            length=200,
+            length_unit=CableLengthUnitChoices.UNIT_METER,
+            a_terminations=[circuittermination4],
+            b_terminations=[interface2]
+        )
+        cable3.clean()
+        cable3.save()
+
+        # Check for complete paths in both directions
+        paths = [
+            self.assertPathExists(
+                (
+                    interface1, cable1, circuittermination1, circuittermination2, cable2, circuittermination3,
+                    circuittermination4, cable3, interface2,
+                ),
+                is_complete=True,
+                is_active=True
+            ),
+            self.assertPathExists(
+                (
+                    interface2, cable3, circuittermination4, circuittermination3, cable2, circuittermination2,
+                    circuittermination1, cable1, interface1,
+                ),
+                is_complete=True,
+                is_active=True
+            ),
+        ]
+
+        # Each crossed circuit adds its distance to the total path length
+        for path in paths:
+            self.assertEqual(path.get_total_length(), (15500, True))
+
+    def test_229_total_length_via_circuit_to_site(self):
+        """
+        [IF1] --C1-- [CT1] [CT2] --> [Site2]
+        """
+        self.circuit.distance = 10
+        self.circuit.distance_unit = DistanceUnitChoices.UNIT_KILOMETER
+        self.circuit.save()
+
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        site2 = Site.objects.create(name='Site 2', slug='site-2')
+        circuittermination1 = CircuitTermination.objects.create(
+            circuit=self.circuit,
+            termination=self.site,
+            term_side='A'
+        )
+        circuittermination2 = CircuitTermination.objects.create(
+            circuit=self.circuit,
+            termination=site2,
+            term_side='Z'
+        )
+        cable1 = Cable(
+            profile=CableProfileChoices.SINGLE_1C1P,
+            length=200,
+            length_unit=CableLengthUnitChoices.UNIT_METER,
+            a_terminations=[interface1],
+            b_terminations=[circuittermination1]
+        )
+        cable1.clean()
+        cable1.save()
+
+        # The distance of a circuit crossed to reach its far-side site counts toward the total length
+        path = self.assertPathExists(
+            (interface1, cable1, circuittermination1, circuittermination2, site2),
+            is_active=True
+        )
+        self.assertEqual(path.get_total_length(), (10200, True))
+
+    def test_230_total_length_via_circuit_to_providernetwork(self):
+        """
+        [IF1] --C1-- [CT1] [CT2] --> [PN1]
+        """
+        self.circuit.distance = 10
+        self.circuit.distance_unit = DistanceUnitChoices.UNIT_KILOMETER
+        self.circuit.save()
+
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        providernetwork = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.circuit.provider)
+        circuittermination1 = CircuitTermination.objects.create(
+            circuit=self.circuit,
+            termination=self.site,
+            term_side='A'
+        )
+        circuittermination2 = CircuitTermination.objects.create(
+            circuit=self.circuit,
+            termination=providernetwork,
+            term_side='Z'
+        )
+        cable1 = Cable(
+            profile=CableProfileChoices.SINGLE_1C1P,
+            length=200,
+            length_unit=CableLengthUnitChoices.UNIT_METER,
+            a_terminations=[interface1],
+            b_terminations=[circuittermination1]
+        )
+        cable1.clean()
+        cable1.save()
+
+        # The distance of a circuit crossed to reach a provider network counts toward the total length
+        path = self.assertPathExists(
+            (interface1, cable1, circuittermination1, circuittermination2, providernetwork),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(path.get_total_length(), (10200, True))
+
+    def test_231_total_length_via_parallel_circuits(self):
+        """
+        [IF1] --C1-- [CT1_A] [CT1_Z] --C2-- [IF2]
+                     [CT2_A] [CT2_Z]
+        """
+        self.circuit.distance = 10
+        self.circuit.distance_unit = DistanceUnitChoices.UNIT_KILOMETER
+        self.circuit.save()
+        circuit2 = Circuit.objects.create(
+            provider=self.circuit.provider,
+            type=self.circuit.type,
+            cid='Circuit 2',
+            distance=5,
+            distance_unit=DistanceUnitChoices.UNIT_KILOMETER
+        )
+
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        circuittermination1_A = CircuitTermination.objects.create(
+            circuit=self.circuit,
+            termination=self.site,
+            term_side='A'
+        )
+        circuittermination1_Z = CircuitTermination.objects.create(
+            circuit=self.circuit,
+            termination=self.site,
+            term_side='Z'
+        )
+        circuittermination2_A = CircuitTermination.objects.create(
+            circuit=circuit2,
+            termination=self.site,
+            term_side='A'
+        )
+        circuittermination2_Z = CircuitTermination.objects.create(
+            circuit=circuit2,
+            termination=self.site,
+            term_side='Z'
+        )
+
+        # Create cables
+        cable1 = Cable(
+            length=200,
+            length_unit=CableLengthUnitChoices.UNIT_METER,
+            a_terminations=[interface1],
+            b_terminations=[circuittermination1_A, circuittermination2_A]
+        )
+        cable1.clean()
+        cable1.save()
+        cable2 = Cable(
+            length=200,
+            length_unit=CableLengthUnitChoices.UNIT_METER,
+            a_terminations=[circuittermination1_Z, circuittermination2_Z],
+            b_terminations=[interface2]
+        )
+        cable2.clean()
+        cable2.save()
+
+        # Check for complete paths in both directions
+        paths = [
+            self.assertPathExists(
+                (
+                    interface1, cable1, (circuittermination1_A, circuittermination2_A),
+                    (circuittermination1_Z, circuittermination2_Z), cable2, interface2,
+                ),
+                is_complete=True,
+                is_active=True
+            ),
+            self.assertPathExists(
+                (
+                    interface2, cable2, (circuittermination1_Z, circuittermination2_Z),
+                    (circuittermination1_A, circuittermination2_A), cable1, interface1,
+                ),
+                is_complete=True,
+                is_active=True
+            ),
+        ]
+
+        # Parallel circuits crossed in the same hop count only the longest distance
+        for path in paths:
+            self.assertEqual(path.get_total_length(), (10400, True))
+
+        # An unset distance on one parallel circuit keeps the longest known distance but is non-definitive
+        circuit2.distance = None
+        circuit2.save()
+        for path in paths:
+            self.assertEqual(path.get_total_length(), (10400, False))
+
     def test_304_add_port_mapping_between_connected_ports(self):
     def test_304_add_port_mapping_between_connected_ports(self):
         """
         """
         [IF1] --C1-- [FP1] [RP1] --C2-- [IF2]
         [IF1] --C1-- [FP1] [RP1] --C2-- [IF2]

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

@@ -1,3 +1,5 @@
+from decimal import Decimal
+
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 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, tag
@@ -2415,6 +2417,32 @@ class CableTestCase(TestCase):
         interface = Interface(device=device, name='tmp', cable=cable)
         interface = Interface(device=device, name='tmp', cable=cable)
         self.assertIsNone(interface.path)
         self.assertIsNone(interface.path)
 
 
+    def test_cable_length_normalization_large_kilometer_value(self):
+        """
+        A large kilometer length must pass validation and fit in the normalized length field.
+        """
+        cable = Cable.objects.first()
+        cable.length = Decimal('1234')
+        cable.length_unit = CableLengthUnitChoices.UNIT_KILOMETER
+        cable.full_clean()
+        cable.save()
+        cable.refresh_from_db()
+
+        self.assertEqual(cable._abs_length, Decimal('1234000.0000'))
+
+    def test_cable_length_normalization_maximum_mile_value(self):
+        """
+        The maximum length value expressed in miles must fit in the normalized length field.
+        """
+        cable = Cable.objects.first()
+        cable.length = Decimal('999999.99')
+        cable.length_unit = CableLengthUnitChoices.UNIT_MILE
+        cable.full_clean()
+        cable.save()
+        cable.refresh_from_db()
+
+        self.assertEqual(cable._abs_length, Decimal('1609343983.9066'))
+
 
 
 class CableTerminationTestCase(TestCase):
 class CableTerminationTestCase(TestCase):
 
 
@@ -2730,6 +2758,70 @@ class VCPositionTokenTestCase(TestCase):
         interface = device.interfaces.get(name='ge-2/1/0')
         interface = device.interfaces.get(name='ge-2/1/0')
         self.assertEqual(interface.label, 'Member 2 / Slot 1')
         self.assertEqual(interface.label, 'Member 2 / Slot 1')
 
 
+    @tag('regression')  # Ref: #22707
+    def test_vc_position_token_interface_bridge_device_type_template(self):
+        site = Site.objects.first()
+        device_type = DeviceType.objects.first()
+        device_role = DeviceRole.objects.first()
+
+        bridge_template = InterfaceTemplate.objects.create(
+            device_type=device_type,
+            name='br-{vc_position}',
+            type='bridge',
+        )
+        InterfaceTemplate.objects.create(
+            device_type=device_type,
+            name='ge-{vc_position}/0/1',
+            type='1000base-t',
+            bridge=bridge_template,
+        )
+        vc = VirtualChassis.objects.create(name='Test VC 5')
+        device = Device.objects.create(
+            name='Device VC 5', device_type=device_type, role=device_role,
+            site=site, virtual_chassis=vc, vc_position=5,
+        )
+
+        interface = device.interfaces.get(name='ge-5/0/1')
+        self.assertEqual(interface.bridge, device.interfaces.get(name='br-5'))
+
+    @tag('regression')  # Ref: #22707
+    def test_vc_position_token_port_mapping_device_type_template(self):
+        site = Site.objects.first()
+        device_type = DeviceType.objects.first()
+        device_role = DeviceRole.objects.first()
+
+        rear_port_template = RearPortTemplate.objects.create(
+            device_type=device_type,
+            name='rp-{vc_position}/1',
+            type=PortTypeChoices.TYPE_LC,
+            positions=1,
+        )
+        front_port_template = FrontPortTemplate.objects.create(
+            device_type=device_type,
+            name='fp-{vc_position}/1',
+            type=PortTypeChoices.TYPE_LC,
+            positions=1,
+        )
+        PortTemplateMapping.objects.create(
+            device_type=device_type,
+            front_port=front_port_template,
+            front_port_position=1,
+            rear_port=rear_port_template,
+            rear_port_position=1,
+        )
+        vc = VirtualChassis.objects.create(name='Test VC 6')
+        device = Device.objects.create(
+            name='Device VC 6', device_type=device_type, role=device_role,
+            site=site, virtual_chassis=vc, vc_position=6,
+        )
+
+        front_port = FrontPort.objects.get(device=device, name='fp-6/1')
+        rear_port = RearPort.objects.get(device=device, name='rp-6/1')
+        mapping = PortMapping.objects.get(device=device, front_port=front_port)
+        self.assertEqual(mapping.rear_port, rear_port)
+        self.assertEqual(mapping.front_port_position, 1)
+        self.assertEqual(mapping.rear_port_position, 1)
+
 
 
 class SiteSignalTestCase(TestCase):
 class SiteSignalTestCase(TestCase):
 
 

+ 361 - 0
netbox/dcim/tests/test_port_mappings.py

@@ -0,0 +1,361 @@
+import uuid
+
+from django.contrib.contenttypes.models import ContentType
+from django.test import RequestFactory, TestCase, tag
+from django.urls import reverse
+from rest_framework import status
+
+from core.choices import ObjectChangeActionChoices
+from core.models import ObjectChange
+from dcim.choices import PortTypeChoices
+from dcim.models import (
+    Device,
+    DeviceRole,
+    DeviceType,
+    FrontPort,
+    FrontPortTemplate,
+    Manufacturer,
+    PortMapping,
+    PortTemplateMapping,
+    RearPort,
+    RearPortTemplate,
+    Site,
+)
+from dcim.utils import reconcile_port_mappings
+from netbox.context_managers import event_tracking
+from users.models import User
+from utilities.testing import APITestCase
+
+
+def _build_request(user):
+    request = RequestFactory().get('/')
+    request.id = uuid.uuid4()
+    request.user = user
+    return request
+
+
+class ReconcilePortMappingsTestCase(TestCase):
+    """
+    Exercise dcim.utils.reconcile_port_mappings and confirm that PortMapping now participates in
+    change logging (#22644): only the difference is written, so unchanged mappings keep their PK and
+    emit no ObjectChange.
+    """
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create_user(username='testuser', password='pw')
+
+        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', color='ff0000')
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        cls.device = Device.objects.create(device_type=device_type, role=role, name='Device 1', site=site)
+
+        cls.front_port = FrontPort.objects.create(
+            device=cls.device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, positions=4
+        )
+        cls.rear_ports = [
+            RearPort.objects.create(
+                device=cls.device, name=f'Rear Port {i}', type=PortTypeChoices.TYPE_8P8C, positions=4
+            )
+            for i in range(1, 4)
+        ]
+
+    def _desired(self, *pairs):
+        # Each pair is (front_port_position, rear_port, rear_port_position).
+        return [
+            {'front_port_position': fpp, 'rear_port_id': rp.pk, 'rear_port_position': rpp}
+            for fpp, rp, rpp in pairs
+        ]
+
+    def _reconcile(self, desired):
+        request = _build_request(self.user)
+        with event_tracking(request):
+            reconcile_port_mappings(PortMapping, parent_field='front_port', parent=self.front_port, desired=desired)
+
+    def _mapping_changes(self, action=None):
+        changes = ObjectChange.objects.filter(changed_object_type=ContentType.objects.get_for_model(PortMapping))
+        if action is not None:
+            changes = changes.filter(action=action)
+        return changes
+
+    def _current_pks(self):
+        return set(PortMapping.objects.filter(front_port=self.front_port).values_list('pk', flat=True))
+
+    def test_create_records_objectchange(self):
+        self._reconcile(self._desired(
+            (1, self.rear_ports[0], 1),
+            (2, self.rear_ports[1], 1),
+        ))
+
+        self.assertEqual(PortMapping.objects.filter(front_port=self.front_port).count(), 2)
+        self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_CREATE).count(), 2)
+
+    @tag('regression')  # Ref: #22644
+    def test_noop_resave_writes_nothing(self):
+        # Re-saving a front port without changing its wiring must not mint new PKs or emit changelog
+        # entries — this is what previously broke branch merges (colliding on the unique constraint
+        # when both sides replayed DELETE(old-pk) + CREATE(new-pk)).
+        desired = self._desired(
+            (1, self.rear_ports[0], 1),
+            (2, self.rear_ports[1], 1),
+        )
+        self._reconcile(desired)
+        original_pks = self._current_pks()
+        ObjectChange.objects.all().delete()
+
+        self._reconcile(desired)
+
+        self.assertEqual(self._mapping_changes().count(), 0)
+        self.assertEqual(self._current_pks(), original_pks)
+
+    def test_repointing_a_slot_deletes_and_creates(self):
+        self._reconcile(self._desired((1, self.rear_ports[0], 1)))
+        original_pk = self._current_pks().pop()
+        ObjectChange.objects.all().delete()
+
+        # Same front port position, different rear port: the slot is re-pointed.
+        self._reconcile(self._desired((1, self.rear_ports[1], 1)))
+
+        mappings = PortMapping.objects.filter(front_port=self.front_port)
+        self.assertEqual(mappings.count(), 1)
+        mapping = mappings.first()
+        self.assertEqual(mapping.rear_port_id, self.rear_ports[1].pk)
+        self.assertNotEqual(mapping.pk, original_pk)
+        self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_DELETE).count(), 1)
+        self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_CREATE).count(), 1)
+
+    def test_unchanged_rows_survive_alongside_changed_rows(self):
+        self._reconcile(self._desired(
+            (1, self.rear_ports[0], 1),
+            (2, self.rear_ports[1], 1),
+        ))
+        unchanged_pk = PortMapping.objects.get(front_port=self.front_port, front_port_position=1).pk
+        ObjectChange.objects.all().delete()
+
+        # Position 1 is untouched; position 2 is re-pointed to a third rear port.
+        self._reconcile(self._desired(
+            (1, self.rear_ports[0], 1),
+            (2, self.rear_ports[2], 1),
+        ))
+
+        self.assertEqual(PortMapping.objects.get(front_port=self.front_port, front_port_position=1).pk, unchanged_pk)
+        self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_CREATE).count(), 1)
+        self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_DELETE).count(), 1)
+
+    def test_removing_a_mapping_records_delete(self):
+        self._reconcile(self._desired(
+            (1, self.rear_ports[0], 1),
+            (2, self.rear_ports[1], 1),
+        ))
+        ObjectChange.objects.all().delete()
+
+        self._reconcile(self._desired((1, self.rear_ports[0], 1)))
+
+        self.assertEqual(PortMapping.objects.filter(front_port=self.front_port).count(), 1)
+        self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_DELETE).count(), 1)
+        self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_CREATE).count(), 0)
+
+    def test_swapping_positions_preserves_constraints(self):
+        # Two front port positions pointing at the same rear port's positions 1 and 2.
+        self._reconcile(self._desired(
+            (1, self.rear_ports[0], 1),
+            (2, self.rear_ports[0], 2),
+        ))
+
+        # Swap their rear port positions. Reconcile deletes both changed rows before recreating, so
+        # the transient state never violates the (rear_port, rear_port_position) unique constraint.
+        self._reconcile(self._desired(
+            (1, self.rear_ports[0], 2),
+            (2, self.rear_ports[0], 1),
+        ))
+
+        self.assertEqual(
+            PortMapping.objects.get(front_port=self.front_port, front_port_position=1).rear_port_position, 2
+        )
+        self.assertEqual(
+            PortMapping.objects.get(front_port=self.front_port, front_port_position=2).rear_port_position, 1
+        )
+
+    def test_direct_delete_records_objectchange(self):
+        self._reconcile(self._desired((1, self.rear_ports[0], 1)))
+        mapping = PortMapping.objects.get(front_port=self.front_port)
+        mapping_pk = mapping.pk
+        ObjectChange.objects.all().delete()
+
+        request = _build_request(self.user)
+        with event_tracking(request):
+            mapping.delete()
+
+        self.assertTrue(
+            self._mapping_changes(ObjectChangeActionChoices.ACTION_DELETE).filter(changed_object_id=mapping_pk).exists()
+        )
+
+
+class ReconcilePortTemplateMappingsTestCase(TestCase):
+    """
+    Confirm reconcile_port_mappings and change logging behave identically for PortTemplateMapping.
+    """
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create_user(username='testuser', password='pw')
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        cls.device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+        )
+
+        cls.front_port = FrontPortTemplate.objects.create(
+            device_type=cls.device_type, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, positions=4
+        )
+        cls.rear_ports = [
+            RearPortTemplate.objects.create(
+                device_type=cls.device_type, name=f'Rear Port {i}', type=PortTypeChoices.TYPE_8P8C, positions=4
+            )
+            for i in range(1, 3)
+        ]
+
+    def _desired(self, *pairs):
+        return [
+            {'front_port_position': fpp, 'rear_port_id': rp.pk, 'rear_port_position': rpp}
+            for fpp, rp, rpp in pairs
+        ]
+
+    def _reconcile(self, desired):
+        request = _build_request(self.user)
+        with event_tracking(request):
+            reconcile_port_mappings(
+                PortTemplateMapping,
+                parent_field='front_port',
+                parent=self.front_port,
+                desired=desired,
+            )
+
+    def _mapping_changes(self, action=None):
+        changes = ObjectChange.objects.filter(
+            changed_object_type=ContentType.objects.get_for_model(PortTemplateMapping)
+        )
+        if action is not None:
+            changes = changes.filter(action=action)
+        return changes
+
+    def test_create_records_objectchange(self):
+        self._reconcile(self._desired((1, self.rear_ports[0], 1)))
+
+        mapping = PortTemplateMapping.objects.get(front_port=self.front_port)
+        self.assertEqual(mapping.device_type_id, self.device_type.pk)
+        self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_CREATE).count(), 1)
+
+    @tag('regression')  # Ref: #22644
+    def test_noop_resave_writes_nothing(self):
+        desired = self._desired((1, self.rear_ports[0], 1))
+        self._reconcile(desired)
+        original_pk = PortTemplateMapping.objects.get(front_port=self.front_port).pk
+        ObjectChange.objects.all().delete()
+
+        self._reconcile(desired)
+
+        self.assertEqual(self._mapping_changes().count(), 0)
+        self.assertEqual(PortTemplateMapping.objects.get(front_port=self.front_port).pk, original_pk)
+
+
+class PortMappingAPITestCase(APITestCase):
+    """
+    Exercise the reconcile behaviour through PortSerializer.create()/update() over the REST API,
+    covering the model-instance -> _id normalization in PortSerializer._reconcile_mappings.
+    """
+    @classmethod
+    def setUpTestData(cls):
+        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', color='ff0000')
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        cls.device = Device.objects.create(device_type=device_type, role=role, name='Device 1', site=site)
+
+        cls.rear_ports = [
+            RearPort.objects.create(
+                device=cls.device, name=f'Rear Port {i}', type=PortTypeChoices.TYPE_8P8C, positions=4
+            )
+            for i in range(1, 4)
+        ]
+
+        # An existing front port with two mappings, for the update/PATCH tests.
+        cls.front_port = FrontPort.objects.create(
+            device=cls.device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, positions=2
+        )
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=cls.device, front_port=cls.front_port, front_port_position=1,
+                rear_port=cls.rear_ports[0], rear_port_position=1,
+            ),
+            PortMapping(
+                device=cls.device, front_port=cls.front_port, front_port_position=2,
+                rear_port=cls.rear_ports[1], rear_port_position=1,
+            ),
+        ])
+
+    def _mapping_pks(self, front_port):
+        return set(PortMapping.objects.filter(front_port=front_port).values_list('pk', flat=True))
+
+    def _mapping_creates(self):
+        return ObjectChange.objects.filter(
+            changed_object_type=ContentType.objects.get_for_model(PortMapping),
+            action=ObjectChangeActionChoices.ACTION_CREATE,
+        )
+
+    def test_create_records_mappings_and_changelog(self):
+        # Confirms PortSerializer.create() + _reconcile_mappings normalization (rear_port instance ->
+        # rear_port_id) writes the mappings and records their ObjectChanges.
+        self.add_permissions('dcim.add_frontport', 'dcim.view_frontport', 'dcim.view_rearport', 'dcim.view_device')
+        data = {
+            'device': self.device.pk,
+            'name': 'Front Port 2',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 2,
+            'rear_ports': [
+                {'position': 1, 'rear_port': self.rear_ports[2].pk, 'rear_port_position': 1},
+                {'position': 2, 'rear_port': self.rear_ports[2].pk, 'rear_port_position': 2},
+            ],
+        }
+        response = self.client.post(reverse('dcim-api:frontport-list'), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
+        front_port = FrontPort.objects.get(pk=response.data['id'])
+        mappings = PortMapping.objects.filter(front_port=front_port).order_by('front_port_position')
+        self.assertEqual(mappings.count(), 2)
+        self.assertEqual(mappings[0].rear_port_id, self.rear_ports[2].pk)
+        self.assertEqual(mappings[1].rear_port_position, 2)
+        self.assertEqual(
+            self._mapping_creates().filter(changed_object_id__in=mappings.values_list('pk', flat=True)).count(), 2
+        )
+
+    def test_update_omitting_rear_ports_preserves_mappings(self):
+        # A PATCH that omits rear_ports must leave the existing mappings untouched.
+        self.add_permissions('dcim.change_frontport', 'dcim.view_frontport')
+        url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.front_port.pk})
+        original_pks = self._mapping_pks(self.front_port)
+
+        response = self.client.patch(url, {'description': 'Updated'}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        self.assertEqual(self._mapping_pks(self.front_port), original_pks)
+
+    def test_update_rewires_mappings(self):
+        # Re-point both slots to new rear port pairs; reconcile deletes the old rows and creates the
+        # replacements.
+        self.add_permissions('dcim.change_frontport', 'dcim.view_frontport', 'dcim.view_rearport', 'dcim.view_device')
+        url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.front_port.pk})
+        original_pks = self._mapping_pks(self.front_port)
+
+        data = {
+            'rear_ports': [
+                {'position': 1, 'rear_port': self.rear_ports[2].pk, 'rear_port_position': 3},
+                {'position': 2, 'rear_port': self.rear_ports[2].pk, 'rear_port_position': 4},
+            ],
+        }
+        response = self.client.patch(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        mappings = PortMapping.objects.filter(front_port=self.front_port).order_by('front_port_position')
+        self.assertEqual([m.rear_port_id for m in mappings], [self.rear_ports[2].pk, self.rear_ports[2].pk])
+        self.assertEqual([m.rear_port_position for m in mappings], [3, 4])
+        self.assertTrue(self._mapping_pks(self.front_port).isdisjoint(original_pks))

+ 120 - 0
netbox/dcim/tests/test_views.py

@@ -1308,6 +1308,22 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         # run base test
         # run base test
         super().test_bulk_update_objects_with_permission()
         super().test_bulk_update_objects_with_permission()
 
 
+    def test_bulk_update_objects_without_change_permission(self):
+        # ModuleTypeImportView declares these as additional_permissions, so they're required to reach the view
+        self.add_permissions(
+            'dcim.add_consoleporttemplate',
+            'dcim.add_consoleserverporttemplate',
+            'dcim.add_powerporttemplate',
+            'dcim.add_poweroutlettemplate',
+            'dcim.add_interfacetemplate',
+            'dcim.add_frontporttemplate',
+            'dcim.add_rearporttemplate',
+            'dcim.add_modulebaytemplate',
+        )
+
+        # run base test
+        super().test_bulk_update_objects_without_change_permission()
+
     @tag('regression')
     @tag('regression')
     def test_bulk_import_objects_with_permission(self):
     def test_bulk_import_objects_with_permission(self):
         self.add_permissions(
         self.add_permissions(
@@ -4205,6 +4221,110 @@ class CableTestCase(
         self.assertEqual(self._get_queryset().count(), initial_count)
         self.assertEqual(self._get_queryset().count(), initial_count)
 
 
 
 
+#
+# Connections
+#
+
+class ConnectionsListViewTestCaseMixin:
+    """
+    Shared behavior for the read-only connection list views.
+
+    These views list components whose cable paths are complete, but their URL names
+    do not follow the <model>_list pattern assumed by ModelViewTestCase.
+    """
+    url_base = None
+
+    def _get_base_url(self):
+        return self.url_base
+
+    def _get_queryset(self):
+        return self.model.objects.filter(_path__is_complete=True)
+
+
+class ConsoleConnectionsListViewTestCase(
+    ConnectionsListViewTestCaseMixin,
+    ViewTestCases.ListObjectsViewTestCase
+):
+    model = ConsolePort
+    url_base = 'dcim:console_connections_{}'
+    query_count_model_label = 'consoleconnection'
+
+    @classmethod
+    def setUpTestData(cls):
+        device = create_test_device('Device 1')
+        peer_device = create_test_device('Device 2')
+
+        console_ports = ConsolePort.objects.bulk_create((
+            ConsolePort(device=device, name='Console Port 1'),
+            ConsolePort(device=device, name='Console Port 2'),
+            ConsolePort(device=device, name='Console Port 3'),
+        ))
+        console_server_ports = ConsoleServerPort.objects.bulk_create((
+            ConsoleServerPort(device=peer_device, name='Console Server Port 1'),
+            ConsoleServerPort(device=peer_device, name='Console Server Port 2'),
+            ConsoleServerPort(device=peer_device, name='Console Server Port 3'),
+        ))
+
+        for console_port, console_server_port in zip(console_ports, console_server_ports):
+            Cable(a_terminations=[console_port], b_terminations=[console_server_port]).save()
+
+
+class PowerConnectionsListViewTestCase(
+    ConnectionsListViewTestCaseMixin,
+    ViewTestCases.ListObjectsViewTestCase
+):
+    model = PowerPort
+    url_base = 'dcim:power_connections_{}'
+    query_count_model_label = 'powerconnection'
+
+    @classmethod
+    def setUpTestData(cls):
+        device = create_test_device('Device 1')
+        peer_device = create_test_device('Device 2')
+
+        power_ports = PowerPort.objects.bulk_create((
+            PowerPort(device=device, name='Power Port 1'),
+            PowerPort(device=device, name='Power Port 2'),
+            PowerPort(device=device, name='Power Port 3'),
+        ))
+        power_outlets = PowerOutlet.objects.bulk_create((
+            PowerOutlet(device=peer_device, name='Power Outlet 1'),
+            PowerOutlet(device=peer_device, name='Power Outlet 2'),
+            PowerOutlet(device=peer_device, name='Power Outlet 3'),
+        ))
+
+        for power_port, power_outlet in zip(power_ports, power_outlets):
+            Cable(a_terminations=[power_port], b_terminations=[power_outlet]).save()
+
+
+class InterfaceConnectionsListViewTestCase(
+    ConnectionsListViewTestCaseMixin,
+    ViewTestCases.ListObjectsViewTestCase
+):
+    model = Interface
+    url_base = 'dcim:interface_connections_{}'
+    query_count_model_label = 'interfaceconnection'
+
+    @classmethod
+    def setUpTestData(cls):
+        device = create_test_device('Device 1')
+        peer_device = create_test_device('Device 2')
+
+        interfaces = Interface.objects.bulk_create((
+            Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+        ))
+        peer_interfaces = Interface.objects.bulk_create((
+            Interface(device=peer_device, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=peer_device, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=peer_device, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+        ))
+
+        for interface, peer_interface in zip(interfaces, peer_interfaces):
+            Cable(a_terminations=[interface], b_terminations=[peer_interface]).save()
+
+
 class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = VirtualChassis
     model = VirtualChassis
 
 

+ 56 - 4
netbox/dcim/utils.py

@@ -166,12 +166,15 @@ def update_interface_bridges(device, interface_templates, module=None):
     Interface = apps.get_model('dcim', 'Interface')
     Interface = apps.get_model('dcim', 'Interface')
 
 
     for interface_template in interface_templates.exclude(bridge=None):
     for interface_template in interface_templates.exclude(bridge=None):
-        interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
+        interface = Interface.objects.get(
+            device=device,
+            name=interface_template.resolve_name(module=module, device=device)
+        )
 
 
         if interface_template.bridge:
         if interface_template.bridge:
             interface.bridge = Interface.objects.get(
             interface.bridge = Interface.objects.get(
                 device=device,
                 device=device,
-                name=interface_template.bridge.resolve_name(module=module)
+                name=interface_template.bridge.resolve_name(module=module, device=device)
             )
             )
             interface.full_clean()
             interface.full_clean()
             interface.save()
             interface.save()
@@ -196,8 +199,8 @@ def create_port_mappings(device, device_or_module_type, module=None):
     # Replicate PortMappings
     # Replicate PortMappings
     mappings = []
     mappings = []
     for template in templates:
     for template in templates:
-        front_port = front_ports.get(template.front_port.resolve_name(module=module))
-        rear_port = rear_ports.get(template.rear_port.resolve_name(module=module))
+        front_port = front_ports.get(template.front_port.resolve_name(module=module, device=device))
+        rear_port = rear_ports.get(template.rear_port.resolve_name(module=module, device=device))
         mappings.append(
         mappings.append(
             PortMapping(
             PortMapping(
                 device_id=front_port.device_id,
                 device_id=front_port.device_id,
@@ -207,4 +210,53 @@ def create_port_mappings(device, device_or_module_type, module=None):
                 rear_port_position=template.rear_port_position,
                 rear_port_position=template.rear_port_position,
             )
             )
         )
         )
+    # Bulk-created (no per-mapping ObjectChange) to match how every other component is instantiated.
     PortMapping.objects.bulk_create(mappings)
     PortMapping.objects.bulk_create(mappings)
+
+
+def reconcile_port_mappings(mapping_model, parent_field, parent, desired):
+    """
+    Reconcile a parent port's mappings against `desired`, writing only the difference so unchanged
+    mappings keep their PK (and emit no changelog entry). Changed/removed rows are deleted before
+    replacements are created, all in one transaction, so position swaps don't trip the unique
+    constraint. Per-row create()/delete() let the change-logging signals fire naturally.
+
+    Args:
+        mapping_model: PortMapping or PortTemplateMapping.
+        parent_field: 'front_port' or 'rear_port' — the side being edited; its '<parent_field>_position'
+            is each mapping's stable identity within the set.
+        parent: the parent instance (FrontPort/RearPort or their templates).
+        desired: iterable of dicts of mapping field values EXCLUDING the parent FK, using '<field>_id'
+            for the opposite-port FK, e.g. {'front_port_position': 1, 'rear_port_id': 5,
+            'rear_port_position': 2}. save() derives device/device_type/module_type from the front port.
+    """
+    key_field = f'{parent_field}_position'
+    other_field = 'rear_port' if parent_field == 'front_port' else 'front_port'
+    value_fields = (f'{other_field}_id', f'{other_field}_position')
+
+    def target(source):
+        # The comparable "value" of a mapping: the opposite port and its position. Two mappings with
+        # the same parent-side position but a different target represent a re-pointing of that slot.
+        get = source.get if isinstance(source, dict) else lambda f: getattr(source, f)
+        return tuple(get(f) for f in value_fields)
+
+    desired_by_key = {d[key_field]: d for d in desired}
+
+    with transaction.atomic(using=router.db_for_write(mapping_model)):
+        # Lock the parent's existing mappings for the duration of the reconcile. Two requests editing
+        # the same port would otherwise read the same snapshot and race, the second colliding on a
+        # unique constraint when it recreates rows the first has already committed.
+        existing = {
+            getattr(m, key_field): m
+            for m in mapping_model.objects.filter(**{parent_field: parent}).select_for_update()
+        }
+
+        # Delete rows that no longer exist or whose target changed (before creating, to free the slots)
+        for key, mapping in existing.items():
+            if key not in desired_by_key or target(mapping) != target(desired_by_key[key]):
+                mapping.delete()
+
+        # Create rows that are new or whose target changed
+        for key, attrs in desired_by_key.items():
+            if key not in existing or target(existing[key]) != target(attrs):
+                mapping_model.objects.create(**{parent_field: parent, **attrs})

+ 10 - 0
netbox/extras/api/mixins.py

@@ -15,9 +15,19 @@ __all__ = (
     'ConfigContextQuerySetMixin',
     'ConfigContextQuerySetMixin',
     'ConfigTemplateRenderMixin',
     'ConfigTemplateRenderMixin',
     'RenderConfigMixin',
     'RenderConfigMixin',
+    'SharedObjectQuerySetMixin',
 )
 )
 
 
 
 
+class SharedObjectQuerySetMixin:
+    """
+    Restrict the queryset to shared objects, or those owned by the current user, unless the user is a superuser.
+    This mirrors the visibility enforced in the UI by extras.utils.SharedObjectViewMixin.
+    """
+    def get_queryset(self):
+        return super().get_queryset().restrict_to_shared(self.request.user)
+
+
 class ConfigContextQuerySetMixin:
 class ConfigContextQuerySetMixin:
     """
     """
     Used by viewsets for config context models (Device, VirtualMachine).
     Used by viewsets for config context models (Device, VirtualMachine).

+ 9 - 0
netbox/extras/api/serializers_/journaling.py

@@ -38,6 +38,15 @@ class JournalEntrySerializer(NetBoxModelSerializer):
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'created')
         brief_fields = ('id', 'url', 'display', 'created')
 
 
+    def get_fields(self):
+        fields = super().get_fields()
+
+        # Make created_by field read-only if updating an existing JournalEntry.
+        if self.instance is not None:
+            fields['created_by'].read_only = True
+
+        return fields
+
     def validate(self, data):
     def validate(self, data):
 
 
         # Validate that the parent object exists
         # Validate that the parent object exists

+ 45 - 6
netbox/extras/api/serializers_/scripts.py

@@ -1,7 +1,7 @@
 import logging
 import logging
 
 
 from django.core.files.storage import storages
 from django.core.files.storage import storages
-from django.db import IntegrityError
+from django.db import IntegrityError, router, transaction
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
@@ -38,11 +38,21 @@ class ScriptModuleSerializer(ValidatedModelSerializer):
         file = data.pop('file', None)
         file = data.pop('file', None)
         data['file_root'] = ManagedFileRootPathChoices.SCRIPTS
         data['file_root'] = ManagedFileRootPathChoices.SCRIPTS
 
 
-        # Reject duplicates before writing to storage so a failed upload can't touch the existing file
-        if file is not None and ScriptModule.objects.filter(
-            file_root=ManagedFileRootPathChoices.SCRIPTS, file_path=file.name
-        ).exists():
-            raise serializers.ValidationError(_("A script module with this file name already exists."))
+        if self.instance is None:
+            # Reject duplicates before writing to storage so a failed upload can't touch the existing file
+            if file is not None and ScriptModule.objects.filter(
+                file_root=ManagedFileRootPathChoices.SCRIPTS, file_path=file.name
+            ).exists():
+                raise serializers.ValidationError(_("A script module with this file name already exists."))
+        elif file is None:
+            # Replacing a module's content requires a file upload, even for a partial update
+            raise serializers.ValidationError({'file': _("This field is required.")})
+        elif file.name != self.instance.file_path:
+            raise serializers.ValidationError({
+                'file': _(
+                    "The uploaded file name must match the existing file path ({path})."
+                ).format(path=self.instance.file_path)
+            })
 
 
         data = super().validate(data)
         data = super().validate(data)
         data.pop('file_root', None)
         data.pop('file_root', None)
@@ -85,6 +95,35 @@ class ScriptModuleSerializer(ValidatedModelSerializer):
                 except Exception:
                 except Exception:
                     logger.warning(f"Failed to delete orphaned script file '{file_path}' from storage.")
                     logger.warning(f"Failed to delete orphaned script file '{file_path}' from storage.")
 
 
+    def update(self, instance, validated_data):
+        file = validated_data.pop('file')
+        storage = storages.create_storage(storages.backends["scripts"])
+
+        # Overwrite the existing file in place, keeping file_path stable
+        file.seek(0)
+        saved_path = storage.save(instance.file_path, file)
+        if saved_path != instance.file_path:
+            # The backend saved under an alternate name instead of overwriting; drop the orphan and reject
+            try:
+                storage.delete(saved_path)
+            except Exception:
+                logger.warning(f"Failed to delete orphaned script file '{saved_path}' from storage.")
+            raise serializers.ValidationError({
+                'file': _(
+                    "The scripts storage backend did not overwrite the existing file. Ensure the "
+                    "backend is configured to allow overwrites."
+                )
+            })
+
+        # Discard any cached class discovery so save() re-syncs from the new content
+        instance.__dict__.pop('module_scripts', None)
+        instance.last_updated = local_now()
+        # Keep Script row sync all-or-nothing; the storage write above cannot join the transaction
+        with transaction.atomic(using=router.db_for_write(ScriptModule)):
+            instance.save()
+
+        return instance
+
 
 
 class ScriptSerializer(ValidatedModelSerializer):
 class ScriptSerializer(ValidatedModelSerializer):
     description = serializers.SerializerMethodField(read_only=True)
     description = serializers.SerializerMethodField(read_only=True)

+ 26 - 6
netbox/extras/api/views.py

@@ -6,12 +6,13 @@ from rest_framework import status
 from rest_framework.decorators import action
 from rest_framework.decorators import action
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.generics import RetrieveUpdateDestroyAPIView
 from rest_framework.generics import RetrieveUpdateDestroyAPIView
-from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin
+from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin
 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 rest_framework.viewsets import ModelViewSet
 
 
+from core.choices import ManagedFileRootPathChoices
 from extras import filtersets
 from extras import filtersets
 from extras.jobs import ScriptJob
 from extras.jobs import ScriptJob
 from extras.models import *
 from extras.models import *
@@ -27,7 +28,7 @@ from utilities.request import copy_safe_request
 from utilities.rqworker import any_workers_for_queue
 from utilities.rqworker import any_workers_for_queue
 
 
 from . import serializers
 from . import serializers
-from .mixins import ConfigTemplateRenderMixin
+from .mixins import ConfigTemplateRenderMixin, SharedObjectQuerySetMixin
 
 
 
 
 class ExtrasRootView(APIRootView):
 class ExtrasRootView(APIRootView):
@@ -126,7 +127,7 @@ class ExportTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet):
 # Saved filters
 # Saved filters
 #
 #
 
 
-class SavedFilterViewSet(NetBoxModelViewSet):
+class SavedFilterViewSet(SharedObjectQuerySetMixin, NetBoxModelViewSet):
     metadata_class = ContentTypeMetadata
     metadata_class = ContentTypeMetadata
     queryset = SavedFilter.objects.all()
     queryset = SavedFilter.objects.all()
     serializer_class = serializers.SavedFilterSerializer
     serializer_class = serializers.SavedFilterSerializer
@@ -137,7 +138,7 @@ class SavedFilterViewSet(NetBoxModelViewSet):
 # Table Configs
 # Table Configs
 #
 #
 
 
-class TableConfigViewSet(NetBoxModelViewSet):
+class TableConfigViewSet(SharedObjectQuerySetMixin, NetBoxModelViewSet):
     metadata_class = ContentTypeMetadata
     metadata_class = ContentTypeMetadata
     queryset = TableConfig.objects.all()
     queryset = TableConfig.objects.all()
     serializer_class = serializers.TableConfigSerializer
     serializer_class = serializers.TableConfigSerializer
@@ -283,9 +284,28 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
 # Scripts
 # Scripts
 #
 #
 
 
-class ScriptModuleViewSet(ObjectValidationMixin, CreateModelMixin, BaseViewSet):
-    queryset = ScriptModule.objects.all()
+class ScriptModuleViewSet(ObjectValidationMixin, CreateModelMixin, UpdateModelMixin, BaseViewSet):
+    queryset = ScriptModule.objects.filter(file_root=ManagedFileRootPathChoices.SCRIPTS)
     serializer_class = serializers.ScriptModuleSerializer
     serializer_class = serializers.ScriptModuleSerializer
+    lookup_value_regex = '[^/]+'  # Allow dots
+
+    def get_object(self):
+        """
+        Retrieve a ScriptModule by numeric ID or by file name (e.g. my_script.py).
+        """
+        queryset = self.filter_queryset(self.get_queryset())
+        lookup = self.kwargs.get(self.lookup_url_kwarg or self.lookup_field, '')
+
+        # Support lookup by numeric PK or by file_path. Treat all-decimal values as PKs
+        # to preserve normal detail-route behavior; otherwise resolve the value as a
+        # script module filename, e.g. "myscript.py".
+        if lookup.isdecimal():
+            obj = get_object_or_404(queryset, pk=int(lookup))
+        else:
+            obj = get_object_or_404(queryset, file_path=lookup)
+
+        self.check_object_permissions(self.request, obj)
+        return obj
 
 
 
 
 @extend_schema_view(
 @extend_schema_view(

+ 5 - 0
netbox/extras/constants.py

@@ -28,6 +28,11 @@ IMAGE_ATTACHMENT_IMAGE_FORMATS = {
 # Template Export
 # Template Export
 DEFAULT_MIME_TYPE = 'text/plain; charset=utf-8'
 DEFAULT_MIME_TYPE = 'text/plain; charset=utf-8'
 
 
+# Scripts
+# Prefix applied to dynamically-loaded script/report module names so that a script whose filename
+# matches a core app or package (e.g. "circuits.py") cannot shadow it in sys.modules.
+SCRIPT_MODULE_NAME_PREFIX = '_netbox_script_module_'
+
 # Webhooks
 # Webhooks
 HTTP_CONTENT_TYPE_JSON = 'application/json'
 HTTP_CONTENT_TYPE_JSON = 'application/json'
 
 

+ 31 - 3
netbox/extras/dashboard/widgets.py

@@ -2,7 +2,7 @@ import logging
 import uuid
 import uuid
 from functools import cached_property
 from functools import cached_property
 from hashlib import sha256
 from hashlib import sha256
-from urllib.parse import urlencode
+from urllib.parse import urlencode, urlparse
 
 
 import feedparser
 import feedparser
 import requests
 import requests
@@ -16,7 +16,9 @@ from django.utils.translation import gettext as _
 
 
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.choices import BookmarkOrderingChoices
 from extras.choices import BookmarkOrderingChoices
+from netbox.config import get_config
 from utilities.choices import Choice
 from utilities.choices import Choice
+from utilities.html import clean_html
 from utilities.object_types import object_type_identifier, object_type_name
 from utilities.object_types import object_type_identifier, object_type_name
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
 from utilities.proxy import resolve_proxies
 from utilities.proxy import resolve_proxies
@@ -357,7 +359,9 @@ class RSSFeedWidget(DashboardWidget):
     def cache_key(self):
     def cache_key(self):
         url = self.config['feed_url']
         url = self.config['feed_url']
         url_checksum = sha256(url.encode('utf-8')).hexdigest()
         url_checksum = sha256(url.encode('utf-8')).hexdigest()
-        return f'dashboard_rss_{url_checksum}'
+        # The version segment invalidates entries cached by a pre-sanitization release: such
+        # entries live under the old key and are never read, so they can't be served unsanitized.
+        return f'dashboard_rss_2_{url_checksum}'
 
 
     def get_feed(self):
     def get_feed(self):
         if self.config.get('requires_internet') and settings.ISOLATED_DEPLOYMENT:
         if self.config.get('requires_internet') and settings.ISOLATED_DEPLOYMENT:
@@ -365,7 +369,8 @@ class RSSFeedWidget(DashboardWidget):
                 'isolated_deployment': True,
                 'isolated_deployment': True,
             }
             }
 
 
-        # Fetch RSS content from cache if available
+        # Fetch RSS content from cache if available. Cached content is always sanitized before
+        # it is written (see below), so no sanitization is needed on read.
         if feed_content := cache.get(self.cache_key):
         if feed_content := cache.get(self.cache_key):
             return {
             return {
                 'feed': feedparser.FeedParserDict(feed_content),
                 'feed': feedparser.FeedParserDict(feed_content),
@@ -391,6 +396,8 @@ class RSSFeedWidget(DashboardWidget):
             # Cap number of entries
             # Cap number of entries
             max_entries = self.config.get('max_entries')
             max_entries = self.config.get('max_entries')
             feed['entries'] = feed['entries'][:max_entries]
             feed['entries'] = feed['entries'][:max_entries]
+            # Sanitize feed-controlled content before caching/rendering
+            self.sanitize_entries(feed['entries'])
             # Cache the feed content
             # Cache the feed content
             cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
             cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
 
 
@@ -398,6 +405,27 @@ class RSSFeedWidget(DashboardWidget):
             'feed': feed,
             'feed': feed,
         }
         }
 
 
+    @staticmethod
+    def sanitize_entries(entries):
+        """
+        Sanitize feed-controlled entry content in place. The feed URL is untrusted external
+        content, so we must guard against dangerous URL schemes (e.g. javascript:) in entry
+        links and sanitize entry summaries as defense-in-depth.
+        """
+        allowed_schemes = get_config().ALLOWED_URL_SCHEMES
+        for entry in entries:
+            # Blank any link whose scheme isn't permitted (blocks javascript:, data:, etc.).
+            # This is the load-bearing control: the template renders entry.link into an href.
+            if link := entry.get('link'):
+                result = urlparse(link)
+                if result.scheme and result.scheme.lower() not in allowed_schemes:
+                    entry['link'] = ''
+            # Sanitize the summary HTML as defense-in-depth. The template renders entry.summary
+            # with auto-escaping (not |safe), so this is not currently load-bearing; it guards
+            # against a future change that renders the summary as markup.
+            if summary := entry.get('summary'):
+                entry['summary'] = clean_html(summary, allowed_schemes)
+
 
 
 @register_widget
 @register_widget
 class BookmarksWidget(DashboardWidget):
 class BookmarksWidget(DashboardWidget):

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

@@ -344,6 +344,12 @@ class JournalEntryImportForm(NetBoxModelImportForm):
             'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags'
             'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags'
         )
         )
 
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        # If not creating a new JournalEntry, disable the created_by field
+        if self.instance and not self.instance._state.adding:
+            self.fields['created_by'].disabled = True
+
 
 
 class NotificationGroupImportForm(CSVModelForm):
 class NotificationGroupImportForm(CSVModelForm):
     users = CSVModelMultipleChoiceField(
     users = CSVModelMultipleChoiceField(

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

@@ -82,7 +82,9 @@ class JournalEntriesMixin:
 @strawberry.type
 @strawberry.type
 class TagsMixin:
 class TagsMixin:
 
 
-    tags: list[Annotated['TagType', strawberry.lazy('.types')]]
+    tags: list[Annotated['TagType', strawberry.lazy('.types')]] = strawberry_django.field(
+        prefetch_related=['tags'],
+    )
 
 
 
 
 @strawberry.type
 @strawberry.type

+ 14 - 2
netbox/extras/graphql/types.py

@@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Annotated
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 from strawberry.scalars import JSON
 from strawberry.scalars import JSON
+from strawberry.types import Info
 
 
 from core.graphql.mixins import SyncedDataMixin
 from core.graphql.mixins import SyncedDataMixin
 from extras import models
 from extras import models
@@ -48,6 +49,17 @@ __all__ = (
 )
 )
 
 
 
 
+class SharedObjectMixin:
+    """
+    Restrict the queryset to shared objects, or those owned by the current user, unless the user is a superuser.
+    This mirrors the visibility enforced in the UI (extras.utils.SharedObjectViewMixin) and the REST API.
+    """
+    @classmethod
+    def get_queryset(cls, queryset, info: Info, **kwargs):
+        queryset = super().get_queryset(queryset, info, **kwargs)
+        return queryset.restrict_to_shared(info.context.request.user)
+
+
 @strawberry_django.type(
 @strawberry_django.type(
     models.ConfigContextProfile,
     models.ConfigContextProfile,
     fields='__all__',
     fields='__all__',
@@ -184,7 +196,7 @@ class NotificationGroupType(ObjectType):
     filters=SavedFilterFilter,
     filters=SavedFilterFilter,
     pagination=True
     pagination=True
 )
 )
-class SavedFilterType(OwnerMixin, ObjectType):
+class SavedFilterType(SharedObjectMixin, OwnerMixin, ObjectType):
     user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
     user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
 
 
 
 
@@ -203,7 +215,7 @@ class SubscriptionType(ObjectType):
     filters=TableConfigFilter,
     filters=TableConfigFilter,
     pagination=True
     pagination=True
 )
 )
-class TableConfigType(ObjectType):
+class TableConfigType(SharedObjectMixin, ObjectType):
     object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None
     object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None
     user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
     user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
 
 

+ 30 - 1
netbox/extras/models/configs.py

@@ -1,4 +1,7 @@
 import copy
 import copy
+import os
+import re
+import sys
 import traceback
 import traceback
 
 
 import jsonschema
 import jsonschema
@@ -464,6 +467,16 @@ class ConfigTemplate(
         self.template_code = self.data_file.data_as_string
         self.template_code = self.data_file.data_as_string
     sync_data.alters_data = True
     sync_data.alters_data = True
 
 
+    def get_environment_params(self):
+        """
+        Config templates render plain text (network configs, scripts), not HTML. Force
+        autoescape off so environment_params cannot enable it and create a latent XSS sink
+        if output is ever rendered in an HTML context.
+        """
+        params = super().get_environment_params()
+        params['autoescape'] = False
+        return params
+
     def format_render_error(self, exc):
     def format_render_error(self, exc):
         """
         """
         Return a formatted error string for a rendering exception. When debug is enabled, the full
         Return a formatted error string for a rendering exception. When debug is enabled, the full
@@ -471,7 +484,23 @@ class ConfigTemplate(
         is returned.
         is returned.
         """
         """
         if self.debug:
         if self.debug:
-            return ''.join(traceback.format_exception(exc))
+            # Strip deployment-specific path prefixes from File "..." lines to avoid disclosing
+            # the server's filesystem layout. install_root covers all NetBox source files plus
+            # any venv co-located inside the repo. When the venv lives outside the repo
+            # (the typical production pattern, e.g. ~/.venv/netbox/), sys.prefix differs from
+            # sys.base_prefix and the venv root is stripped separately so that the deployment
+            # user's home directory is not exposed. Stdlib paths not under either prefix are
+            # left as-is — they reveal only standard OS locations, not deployment structure.
+            install_root = os.path.dirname(settings.BASE_DIR) + os.sep
+            prefixes_to_strip = [install_root]
+            if sys.prefix != sys.base_prefix:
+                venv_root = sys.prefix + os.sep
+                if venv_root != install_root:
+                    prefixes_to_strip.append(venv_root)
+            tb = ''.join(traceback.format_exception(exc))
+            for prefix in prefixes_to_strip:
+                tb = re.sub(r'(File ")' + re.escape(prefix), r'\1', tb)
+            return tb
         if isinstance(exc, TemplateError):
         if isinstance(exc, TemplateError):
             parts = [f"{type(exc).__name__}: {exc}"]
             parts = [f"{type(exc).__name__}: {exc}"]
             if getattr(exc, 'name', None):
             if getattr(exc, 'name', None):

+ 8 - 4
netbox/extras/models/mixins.py

@@ -13,7 +13,7 @@ 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 core.models import ObjectType
 from core.models import ObjectType
-from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_ALLOWED
+from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_ALLOWED, SCRIPT_MODULE_NAME_PREFIX
 from extras.utils import filename_from_model, filename_from_object
 from extras.utils import filename_from_model, filename_from_object
 from utilities.jinja2 import render_jinja2
 from utilities.jinja2 import render_jinja2
 
 
@@ -71,12 +71,16 @@ class PythonModuleMixin:
         Load the module using importlib, but use a custom loader to use django-storages
         Load the module using importlib, but use a custom loader to use django-storages
         instead of the file system.
         instead of the file system.
         """
         """
-        spec = importlib.util.spec_from_file_location(self.python_name, self.name)
+        # Load the module under a namespaced name rather than its bare name. Using the bare name
+        # (e.g. "circuits") would replace the like-named core app package in sys.modules, breaking
+        # app and migration graph resolution.
+        module_name = f'{SCRIPT_MODULE_NAME_PREFIX}{self.python_name}'
+        spec = importlib.util.spec_from_file_location(module_name, self.name)
         if spec is None:
         if spec is None:
             raise ModuleNotFoundError(f"Could not find module: {self.python_name}")
             raise ModuleNotFoundError(f"Could not find module: {self.python_name}")
         loader = CustomStoragesLoader(self.name)
         loader = CustomStoragesLoader(self.name)
         module = importlib.util.module_from_spec(spec)
         module = importlib.util.module_from_spec(spec)
-        sys.modules[self.python_name] = module
+        sys.modules[module_name] = module
         loader.exec_module(module)
         loader.exec_module(module)
 
 
         return module
         return module
@@ -255,7 +259,7 @@ class RenderTemplateMixin(models.Model):
             extension = f'.{self.file_extension}' if self.file_extension else ''
             extension = f'.{self.file_extension}' if self.file_extension else ''
             if self.file_name:
             if self.file_name:
                 filename = self.file_name
                 filename = self.file_name
-            elif queryset:
+            elif queryset is not None:
                 filename = filename_from_model(queryset.model)
                 filename = filename_from_model(queryset.model)
             elif context:
             elif context:
                 filename = filename_from_object(context)
                 filename = filename_from_object(context)

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

@@ -18,6 +18,7 @@ from extras.choices import *
 from extras.conditions import ConditionSet, InvalidCondition
 from extras.conditions import ConditionSet, InvalidCondition
 from extras.constants import *
 from extras.constants import *
 from extras.models.mixins import RenderTemplateMixin
 from extras.models.mixins import RenderTemplateMixin
+from extras.querysets import SharedObjectQuerySet
 from extras.utils import image_upload
 from extras.utils import image_upload
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.events import get_event_type_choices
 from netbox.events import get_event_type_choices
@@ -519,6 +520,8 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
         verbose_name=_('parameters')
         verbose_name=_('parameters')
     )
     )
 
 
+    objects = SharedObjectQuerySet.as_manager()
+
     clone_fields = (
     clone_fields = (
         'object_types', 'weight', 'enabled', 'parameters',
         'object_types', 'weight', 'enabled', 'parameters',
     )
     )
@@ -606,6 +609,8 @@ class TableConfig(CloningMixin, ChangeLoggedModel):
         null=True,
         null=True,
     )
     )
 
 
+    objects = SharedObjectQuerySet.as_manager()
+
     clone_fields = ('object_type', 'table', 'enabled', 'shared', 'columns', 'ordering')
     clone_fields = ('object_type', 'table', 'enabled', 'shared', 'columns', 'ordering')
 
 
     class Meta:
     class Meta:

+ 18 - 0
netbox/extras/querysets.py

@@ -9,6 +9,7 @@ __all__ = (
     'ConfigContextModelQuerySet',
     'ConfigContextModelQuerySet',
     'ConfigContextQuerySet',
     'ConfigContextQuerySet',
     'NotificationQuerySet',
     'NotificationQuerySet',
+    'SharedObjectQuerySet',
 )
 )
 
 
 
 
@@ -199,3 +200,20 @@ class NotificationQuerySet(RestrictedQuerySet):
         Return only unread notifications.
         Return only unread notifications.
         """
         """
         return self.filter(read__isnull=True)
         return self.filter(read__isnull=True)
+
+
+class SharedObjectQuerySet(RestrictedQuerySet):
+
+    def restrict_to_shared(self, user):
+        """
+        Restrict the queryset to objects which are shared or owned by the given user. Superusers are exempt;
+        anonymous users see only shared objects. This enforces consistent visibility across the UI, REST API,
+        and GraphQL API.
+        """
+        if user.is_superuser:
+            return self
+        if user.is_anonymous:
+            return self.filter(shared=True)
+        return self.filter(
+            Q(shared=True) | Q(user=user)
+        )

+ 8 - 2
netbox/extras/scripts.py

@@ -12,6 +12,7 @@ from django.utils.translation import gettext as _
 
 
 from core.choices import JobNotificationChoices
 from core.choices import JobNotificationChoices
 from extras.choices import LogLevelChoices
 from extras.choices import LogLevelChoices
+from extras.constants import SCRIPT_MODULE_NAME_PREFIX
 from extras.models import ScriptModule
 from extras.models import ScriptModule
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
@@ -349,7 +350,12 @@ class BaseScript:
 
 
     @classproperty
     @classproperty
     def module(self):
     def module(self):
-        return self.__module__
+        # Strip the internal prefix applied when the module is loaded (see #22566) so that
+        # user-facing names (full_name, logger namespaces) reflect the original script filename.
+        name = self.__module__
+        if name.startswith(SCRIPT_MODULE_NAME_PREFIX):
+            name = name[len(SCRIPT_MODULE_NAME_PREFIX):]
+        return name
 
 
     @classproperty
     @classproperty
     def class_name(self):
     def class_name(self):
@@ -361,7 +367,7 @@ class BaseScript:
 
 
     @classmethod
     @classmethod
     def root_module(cls):
     def root_module(cls):
-        return cls.__module__.split(".")[0]
+        return cls.module.split(".")[0]
 
 
     # Author-defined attributes
     # Author-defined attributes
 
 

+ 4 - 4
netbox/extras/templatetags/custom_links.py

@@ -68,8 +68,8 @@ def custom_links(context, obj):
                         rendered['link'], rendered['link_target'], button_class, rendered['text']
                         rendered['link'], rendered['link_target'], button_class, rendered['text']
                     )
                     )
             except Exception as e:
             except Exception as e:
-                template_code += f'<a class="btn btn-sm btn-outline-secondary" disabled="disabled" title="{e}">' \
-                                 f'<i class="mdi mdi-alert"></i> {cl.name}</a>\n'
+                template_code += f'<a class="btn btn-sm btn-outline-secondary" disabled="disabled" ' \
+                                 f'title="{escape(e)}"><i class="mdi mdi-alert"></i> {escape(cl.name)}</a>\n'
 
 
     # Add grouped links to template
     # Add grouped links to template
     for group, links in group_names.items():
     for group, links in group_names.items():
@@ -84,8 +84,8 @@ def custom_links(context, obj):
                     )
                     )
             except Exception as e:
             except Exception as e:
                 links_rendered.append(
                 links_rendered.append(
-                    f'<li><a class="dropdown-item" disabled="disabled" title="{e}"><span class="text-muted">'
-                    f'<i class="mdi mdi-alert"></i> {cl.name}</span></a></li>'
+                    f'<li><a class="dropdown-item" disabled="disabled" title="{escape(e)}"><span class="text-muted">'
+                    f'<i class="mdi mdi-alert"></i> {escape(cl.name)}</span></a></li>'
                 )
                 )
 
 
         if links_rendered:
         if links_rendered:

+ 2 - 1
netbox/extras/templatetags/dashboard.py

@@ -1,4 +1,5 @@
 from django import template
 from django import template
+from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
@@ -19,6 +20,6 @@ def render_widget(context, widget):
               <span class="text-danger"><i class="mdi mdi-alert"></i></span>
               <span class="text-danger"><i class="mdi mdi-alert"></i></span>
               {message1}
               {message1}
             </p>
             </p>
-            <p class="font-monospace ps-3">{e}</p>
+            <p class="font-monospace ps-3">{escape(e)}</p>
             <p>{message2}</p>
             <p>{message2}</p>
         """)
         """)

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

@@ -19,7 +19,7 @@
   "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": 15,
+  "journalentry:api_list_objects": 16,
   "journalentry:list_objects_with_permission": 24,
   "journalentry:list_objects_with_permission": 24,
   "notification:api_list_objects": 12,
   "notification:api_list_objects": 12,
   "notificationgroup:api_list_objects": 11,
   "notificationgroup:api_list_objects": 11,

+ 516 - 5
netbox/extras/tests/test_api.py

@@ -1,10 +1,13 @@
 import datetime
 import datetime
 import hashlib
 import hashlib
 import io
 import io
+import json
+from contextlib import contextmanager
 from unittest.mock import MagicMock, patch
 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.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
@@ -18,7 +21,7 @@ from extras.models import *
 from extras.scripts import BooleanVar, IntegerVar, StringVar
 from extras.scripts import BooleanVar, IntegerVar, StringVar
 from extras.scripts import Script as PythonClass
 from extras.scripts import Script as PythonClass
 from users.constants import TOKEN_PREFIX
 from users.constants import TOKEN_PREFIX
-from users.models import Group, 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
 
 
@@ -446,7 +449,24 @@ class CustomLinkTestCase(APIViewTestCases.APIViewTestCase):
             custom_link.object_types.set([site_type])
             custom_link.object_types.set([site_type])
 
 
 
 
-class SavedFilterTestCase(APIViewTestCases.APIViewTestCase):
+class SharedObjectAPITestMixin:
+    """
+    Helpers for testing the shared/owner visibility enforced on SavedFilter and TableConfig.
+    """
+    def _grant_view_permission_and_authenticate(self, user, model):
+        """
+        Grant `user` an unconstrained view permission on `model`, create an API token, and return the
+        corresponding authentication header.
+        """
+        obj_perm = ObjectPermission(name=f'{model._meta.model_name} view', actions=['view'])
+        obj_perm.save()
+        obj_perm.users.add(user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(model))
+        token = Token.objects.create(user=user)
+        return {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'}
+
+
+class SavedFilterTestCase(SharedObjectAPITestMixin, APIViewTestCases.APIViewTestCase):
     model = SavedFilter
     model = SavedFilter
     brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
     create_data = [
@@ -518,8 +538,55 @@ class SavedFilterTestCase(APIViewTestCases.APIViewTestCase):
         for i, savedfilter in enumerate(saved_filters):
         for i, savedfilter in enumerate(saved_filters):
             savedfilter.object_types.set([site_type])
             savedfilter.object_types.set([site_type])
 
 
+    def test_private_filter_not_visible_to_other_users(self):
+        """
+        A private (shared=False) SavedFilter owned by another user must not be exposed via the REST API, even to
+        a user holding an unconstrained view permission.
+        """
+        site_type = ObjectType.objects.get_for_model(Site)
+        owner = User.objects.create_user(username='filter-owner')
+        private_filter = SavedFilter.objects.create(
+            name='Private Filter',
+            slug='private-filter',
+            user=owner,
+            shared=False,
+            parameters={'status': ['active']},
+        )
+        private_filter.object_types.set([site_type])
+
+        # Grant an unconstrained view permission (the common case)
+        self.add_permissions('extras.view_savedfilter')
+
+        # The private filter must not appear in the list
+        response = self.client.get(self._get_list_url(), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        returned_ids = [obj['id'] for obj in response.data['results']]
+        self.assertNotIn(private_filter.pk, returned_ids)
+
+        # The private filter must not be retrievable directly
+        response = self.client.get(self._get_detail_url(private_filter), **self.header)
+        self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
+
+        # The private filter must not be exposed via GraphQL either
+        query = '{ saved_filter_list { id } }'
+        response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        returned_ids = [int(obj['id']) for obj in data['data']['saved_filter_list']]
+        self.assertNotIn(private_filter.pk, returned_ids)
+
+        # The owner, however, must still be able to access their own private filter
+        owner_header = self._grant_view_permission_and_authenticate(owner, SavedFilter)
+        response = self.client.get(self._get_detail_url(private_filter), **owner_header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **owner_header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        returned_ids = [int(obj['id']) for obj in data['data']['saved_filter_list']]
+        self.assertIn(private_filter.pk, returned_ids)
+
 
 
-class TableConfigTestCase(APIViewTestCases.APIViewTestCase):
+class TableConfigTestCase(SharedObjectAPITestMixin, APIViewTestCases.APIViewTestCase):
     model = TableConfig
     model = TableConfig
     brief_fields = ['description', 'display', 'id', 'name', 'object_type', 'table', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'object_type', 'table', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -547,6 +614,7 @@ class TableConfigTestCase(APIViewTestCases.APIViewTestCase):
                 object_type=site_type,
                 object_type=site_type,
                 table=site_table_name,
                 table=site_table_name,
                 user=users[0],
                 user=users[0],
+                shared=True,
                 columns=['name', 'status'],
                 columns=['name', 'status'],
             ),
             ),
             TableConfig(
             TableConfig(
@@ -554,6 +622,7 @@ class TableConfigTestCase(APIViewTestCases.APIViewTestCase):
                 object_type=site_type,
                 object_type=site_type,
                 table=site_table_name,
                 table=site_table_name,
                 user=users[1],
                 user=users[1],
+                shared=True,
                 columns=['name', 'region'],
                 columns=['name', 'region'],
             ),
             ),
             TableConfig(
             TableConfig(
@@ -561,6 +630,7 @@ class TableConfigTestCase(APIViewTestCases.APIViewTestCase):
                 object_type=site_type,
                 object_type=site_type,
                 table=site_table_name,
                 table=site_table_name,
                 user=users[2],
                 user=users[2],
+                shared=True,
                 columns=['name', 'tenant'],
                 columns=['name', 'tenant'],
             ),
             ),
         )
         )
@@ -589,6 +659,54 @@ class TableConfigTestCase(APIViewTestCases.APIViewTestCase):
             },
             },
         ]
         ]
 
 
+    def test_private_table_config_not_visible_to_other_users(self):
+        """
+        A private (shared=False) TableConfig owned by another user must not be exposed via the REST API, even to
+        a user holding an unconstrained view permission.
+        """
+        site_type = ObjectType.objects.get_for_model(Site)
+        site_table_name = get_table_for_model(Site).__name__
+        owner = User.objects.create_user(username='tableconfig-owner')
+        private_config = TableConfig.objects.create(
+            name='Private Table Config',
+            object_type=site_type,
+            table=site_table_name,
+            user=owner,
+            shared=False,
+            columns=['name', 'status'],
+        )
+
+        # Grant an unconstrained view permission (the common case)
+        self.add_permissions('extras.view_tableconfig')
+
+        # The private table config must not appear in the list
+        response = self.client.get(self._get_list_url(), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        returned_ids = [obj['id'] for obj in response.data['results']]
+        self.assertNotIn(private_config.pk, returned_ids)
+
+        # The private table config must not be retrievable directly
+        response = self.client.get(self._get_detail_url(private_config), **self.header)
+        self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
+
+        # The private table config must not be exposed via GraphQL either
+        query = '{ table_config_list { id } }'
+        response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        returned_ids = [int(obj['id']) for obj in data['data']['table_config_list']]
+        self.assertNotIn(private_config.pk, returned_ids)
+
+        # The owner, however, must still be able to access their own private table config
+        owner_header = self._grant_view_permission_and_authenticate(owner, TableConfig)
+        response = self.client.get(self._get_detail_url(private_config), **owner_header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **owner_header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        returned_ids = [int(obj['id']) for obj in data['data']['table_config_list']]
+        self.assertIn(private_config.pk, returned_ids)
+
 
 
 class BookmarkTestCase(
 class BookmarkTestCase(
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.GetObjectViewTestCase,
@@ -810,6 +928,12 @@ class JournalEntryTestCase(APIViewTestCases.APIViewTestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
+        users = (
+            User(username='User 1'),
+            User(username='User 2'),
+        )
+        User.objects.bulk_create(users)
+
         user = User.objects.first()
         user = User.objects.first()
         site = Site.objects.create(name='Site 1', slug='site-1')
         site = Site.objects.create(name='Site 1', slug='site-1')
 
 
@@ -850,6 +974,25 @@ class JournalEntryTestCase(APIViewTestCases.APIViewTestCase):
             },
             },
         ]
         ]
 
 
+    def test_immutable_created_by(self):
+        """
+        Verify that created_by can't be changed for existing objects
+        """
+        entry = JournalEntry.objects.first()
+        created_by_before = entry.created_by_id
+        # select user different from the one currently set
+        change_user = User.objects.exclude(pk=created_by_before).only('id').first()
+
+        url = reverse('extras-api:journalentry-detail', kwargs={'pk': entry.pk})
+        self.add_permissions('extras.change_journalentry')
+        response = self.client.patch(url, {'created_by': change_user.id}, format='json', **self.header)
+
+        self.assertEqual(response.status_code, 200)
+
+        entry.refresh_from_db()
+        created_by_after = entry.created_by_id
+        self.assertEqual(created_by_before, created_by_after)
+
 
 
 class ConfigContextProfileTestCase(APIViewTestCases.APIViewTestCase):
 class ConfigContextProfileTestCase(APIViewTestCases.APIViewTestCase):
     model = ConfigContextProfile
     model = ConfigContextProfile
@@ -1623,17 +1766,22 @@ class NotificationTestCase(APIViewTestCases.APIViewTestCase):
 
 
 
 
 class _InMemoryScriptStorage:
 class _InMemoryScriptStorage:
-    """Stateful stand-in for the scripts storage backend; mimics allow_overwrite=True."""
+    """Stateful stand-in for the scripts storage backend; mirrors its allow_overwrite option."""
 
 
-    def __init__(self):
+    def __init__(self, allow_overwrite=True):
         self.files = {}
         self.files = {}
+        self.allow_overwrite = allow_overwrite
 
 
     def save(self, name, content):
     def save(self, name, content):
+        if not self.allow_overwrite and name in self.files:
+            name = f'{name}.1'
         content.seek(0)
         content.seek(0)
         self.files[name] = content.read()
         self.files[name] = content.read()
         return name
         return name
 
 
     def open(self, name, mode='rb'):
     def open(self, name, mode='rb'):
+        if name not in self.files:
+            raise FileNotFoundError(name)
         return io.BytesIO(self.files[name])
         return io.BytesIO(self.files[name])
 
 
     def delete(self, name):
     def delete(self, name):
@@ -1656,6 +1804,18 @@ class ScriptModuleTestCase(APITestCase):
         super().setUp()
         super().setUp()
         self.url = reverse('extras-api:scriptmodule-list')  # /api/extras/scripts/upload/
         self.url = reverse('extras-api:scriptmodule-list')  # /api/extras/scripts/upload/
 
 
+    @contextmanager
+    def _patched_script_storage(self, fake_storage):
+        """Patch both storage entry points (serializer writes, module imports) to the given fake."""
+        with (
+            patch('extras.api.serializers_.scripts.storages') as serializer_storages,
+            patch('extras.models.mixins.storages') as module_storages,
+        ):
+            serializer_storages.create_storage.return_value = fake_storage
+            serializer_storages.backends = {'scripts': {}}
+            module_storages.__getitem__.return_value = fake_storage
+            yield
+
     def test_upload_script_module_without_permission(self):
     def test_upload_script_module_without_permission(self):
         script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n    pass\n"
         script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n    pass\n"
         upload_file = SimpleUploadedFile('test_upload.py', script_content, content_type='text/plain')
         upload_file = SimpleUploadedFile('test_upload.py', script_content, content_type='text/plain')
@@ -1765,3 +1925,354 @@ class ScriptModuleTestCase(APITestCase):
         self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
         self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
         response = self.client.post(self.url, {}, format='json', **self.header)
         response = self.client.post(self.url, {}, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+    def test_update_script_module(self):
+        """A PATCH with a new file replaces the stored content and re-syncs the module's scripts."""
+        self.add_permissions(
+            'extras.add_scriptmodule', 'core.add_managedfile',
+            'extras.change_scriptmodule', 'core.change_managedfile',
+        )
+        original_content = (
+            b"from extras.scripts import Script\n\n\n"
+            b"class ProbeScript(Script):\n    def run(self, data, commit):\n        return 'v1'\n"
+        )
+        updated_content = (
+            b"from extras.scripts import Script\n\n\n"
+            b"class ProbeScriptV2(Script):\n    def run(self, data, commit):\n        return 'v2'\n"
+        )
+        fake_storage = _InMemoryScriptStorage()
+
+        with self._patched_script_storage(fake_storage):
+            response = self.client.post(
+                self.url,
+                {'file': SimpleUploadedFile('zz_update.py', original_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            module_id = response.data['id']
+            detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': module_id})
+
+            response = self.client.patch(
+                detail_url,
+                {'file': SimpleUploadedFile('zz_update.py', updated_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        self.assertEqual(response.data['id'], module_id)
+        self.assertEqual(response.data['file_path'], 'zz_update.py')
+        self.assertIsNotNone(response.data['last_updated'])
+        self.assertEqual(fake_storage.files['zz_update.py'], updated_content)
+        # Script classes are re-synced from the new content
+        self.assertFalse(Script.objects.filter(module_id=module_id, name='ProbeScript').exists())
+        self.assertTrue(Script.objects.filter(module_id=module_id, name='ProbeScriptV2').exists())
+        self.assertEqual(ScriptModule.objects.filter(file_path='zz_update.py').count(), 1)
+
+    def test_update_script_module_without_file_fails(self):
+        """A PATCH without a file upload is rejected and leaves the stored content unchanged."""
+        self.add_permissions(
+            'extras.add_scriptmodule', 'core.add_managedfile',
+            'extras.change_scriptmodule', 'core.change_managedfile',
+        )
+        script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n    pass\n"
+        fake_storage = _InMemoryScriptStorage()
+
+        with self._patched_script_storage(fake_storage):
+            response = self.client.post(
+                self.url,
+                {'file': SimpleUploadedFile('zz_nofile.py', script_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
+
+            response = self.client.patch(detail_url, {}, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('file', response.data)
+        self.assertEqual(fake_storage.files['zz_nofile.py'], script_content)
+
+    def test_update_script_module_without_permission(self):
+        """A PATCH without change permissions returns 403 and leaves the stored content unchanged."""
+        self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
+        script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n    pass\n"
+        fake_storage = _InMemoryScriptStorage()
+
+        with self._patched_script_storage(fake_storage):
+            response = self.client.post(
+                self.url,
+                {'file': SimpleUploadedFile('zz_forbidden.py', script_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
+
+            response = self.client.patch(
+                detail_url,
+                {'file': SimpleUploadedFile('zz_forbidden.py', script_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+        self.assertEqual(fake_storage.files['zz_forbidden.py'], script_content)
+
+    def test_update_script_module_by_file_name(self):
+        """A module can be updated by addressing it with its file name instead of its numeric ID."""
+        self.add_permissions(
+            'extras.add_scriptmodule', 'core.add_managedfile',
+            'extras.change_scriptmodule', 'core.change_managedfile',
+        )
+        original_content = (
+            b"from extras.scripts import Script\n\n\n"
+            b"class ProbeScript(Script):\n    def run(self, data, commit):\n        return 'v1'\n"
+        )
+        updated_content = original_content.replace(b"'v1'", b"'v2'")
+        fake_storage = _InMemoryScriptStorage()
+
+        with self._patched_script_storage(fake_storage):
+            response = self.client.post(
+                self.url,
+                {'file': SimpleUploadedFile('zz_by_name.py', original_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            module_id = response.data['id']
+            detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': 'zz_by_name.py'})
+
+            response = self.client.put(
+                detail_url,
+                {'file': SimpleUploadedFile('zz_by_name.py', updated_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        self.assertEqual(response.data['id'], module_id)
+        self.assertEqual(fake_storage.files['zz_by_name.py'], updated_content)
+
+    def test_update_script_module_rejects_mismatched_file_name(self):
+        """An update whose uploaded file name differs from the module's file path is rejected."""
+        self.add_permissions(
+            'extras.add_scriptmodule', 'core.add_managedfile',
+            'extras.change_scriptmodule', 'core.change_managedfile',
+        )
+        original_content = (
+            b"from extras.scripts import Script\n\n\n"
+            b"class ProbeScript(Script):\n    def run(self, data, commit):\n        return 'v1'\n"
+        )
+        fake_storage = _InMemoryScriptStorage()
+
+        with self._patched_script_storage(fake_storage):
+            response = self.client.post(
+                self.url,
+                {'file': SimpleUploadedFile('zz_mismatch.py', original_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
+
+            response = self.client.patch(
+                detail_url,
+                {'file': SimpleUploadedFile('zz_other.py', original_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(fake_storage.files['zz_mismatch.py'], original_content)
+        self.assertNotIn('zz_other.py', fake_storage.files)
+
+    def test_update_script_module_storage_name_mismatch_fails(self):
+        """If the storage backend saves under an alternate name, the update is rejected and rolled back."""
+        self.add_permissions(
+            'extras.add_scriptmodule', 'core.add_managedfile',
+            'extras.change_scriptmodule', 'core.change_managedfile',
+        )
+        original_content = (
+            b"from extras.scripts import Script\n\n\n"
+            b"class ProbeScript(Script):\n    def run(self, data, commit):\n        return 'v1'\n"
+        )
+        updated_content = original_content.replace(b"'v1'", b"'v2'")
+        fake_storage = _InMemoryScriptStorage(allow_overwrite=False)
+
+        with self._patched_script_storage(fake_storage):
+            response = self.client.post(
+                self.url,
+                {'file': SimpleUploadedFile('zz_suffix.py', original_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            module = ScriptModule.objects.get(file_path='zz_suffix.py')
+            detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': module.pk})
+
+            response = self.client.patch(
+                detail_url,
+                {'file': SimpleUploadedFile('zz_suffix.py', updated_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        # Original file intact, alternate-name orphan removed
+        self.assertEqual(fake_storage.files['zz_suffix.py'], original_content)
+        self.assertNotIn('zz_suffix.py.1', fake_storage.files)
+        module.refresh_from_db()
+        self.assertEqual(module.file_path, 'zz_suffix.py')
+
+    def test_update_faulty_script_module_preserves_existing_module(self):
+        """An update with invalid script content is rejected before storage or Script rows change."""
+        self.add_permissions(
+            'extras.add_scriptmodule', 'core.add_managedfile',
+            'extras.change_scriptmodule', 'core.change_managedfile',
+        )
+        original_content = (
+            b"from extras.scripts import Script\n\n\n"
+            b"class ProbeScript(Script):\n    def run(self, data, commit):\n        return 'v1'\n"
+        )
+        # 'extras.script' is invalid; the correct module is 'extras.scripts'
+        faulty_content = b"from extras.script import Script\nclass TestScript(Script):\n    pass\n"
+        fake_storage = _InMemoryScriptStorage()
+
+        with self._patched_script_storage(fake_storage):
+            response = self.client.post(
+                self.url,
+                {'file': SimpleUploadedFile('zz_faulty_update.py', original_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            module_id = response.data['id']
+            detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': module_id})
+
+            response = self.client.patch(
+                detail_url,
+                {'file': SimpleUploadedFile('zz_faulty_update.py', faulty_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(fake_storage.files['zz_faulty_update.py'], original_content)
+        self.assertTrue(Script.objects.filter(module_id=module_id, name='ProbeScript').exists())
+
+    def test_update_script_module_not_found(self):
+        """An update addressing a nonexistent module returns 404 for both lookup styles."""
+        self.add_permissions('extras.change_scriptmodule', 'core.change_managedfile')
+        for lookup in ('999999', 'zz_missing.py', '½'):
+            detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': lookup})
+            response = self.client.patch(detail_url, {}, format='json', **self.header)
+            self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
+
+    def test_update_script_module_via_put_without_file_fails(self):
+        """A PUT without a file upload is rejected by the field-level required check."""
+        self.add_permissions(
+            'extras.add_scriptmodule', 'core.add_managedfile',
+            'extras.change_scriptmodule', 'core.change_managedfile',
+        )
+        script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n    pass\n"
+        fake_storage = _InMemoryScriptStorage()
+
+        with self._patched_script_storage(fake_storage):
+            response = self.client.post(
+                self.url,
+                {'file': SimpleUploadedFile('zz_put_nofile.py', script_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
+
+            response = self.client.put(detail_url, {}, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('file', response.data)
+        self.assertEqual(fake_storage.files['zz_put_nofile.py'], script_content)
+
+    def test_update_script_module_rolls_back_scripts_on_save_failure(self):
+        """A failed Script sync during an update rolls back all Script row changes."""
+        self.add_permissions(
+            'extras.add_scriptmodule', 'core.add_managedfile',
+            'extras.change_scriptmodule', 'core.change_managedfile',
+        )
+        original_content = (
+            b"from extras.scripts import Script\n\n\n"
+            b"class ProbeScript(Script):\n    def run(self, data, commit):\n        return 'v1'\n"
+        )
+        updated_content = (
+            b"from extras.scripts import Script\n\n\n"
+            b"class ProbeScriptV2(Script):\n    def run(self, data, commit):\n        return 'v2'\n"
+        )
+        fake_storage = _InMemoryScriptStorage()
+
+        with self._patched_script_storage(fake_storage):
+            response = self.client.post(
+                self.url,
+                {'file': SimpleUploadedFile('zz_rollback.py', original_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            module_id = response.data['id']
+            detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': module_id})
+
+            # Fail the sync mid-way: the old Script row is already deleted when create() raises
+            with patch(
+                'extras.models.scripts.Script.objects.create',
+                side_effect=IntegrityError('Simulated database error'),
+            ):
+                with self.assertRaises(IntegrityError):
+                    self.client.patch(
+                        detail_url,
+                        {'file': SimpleUploadedFile('zz_rollback.py', updated_content, content_type='text/plain')},
+                        format='multipart',
+                        **self.header,
+                    )
+
+        # DB is all-or-nothing; storage keeps the new content and a retried update re-syncs the rows
+        self.assertTrue(Script.objects.filter(module_id=module_id, name='ProbeScript').exists())
+        self.assertFalse(Script.objects.filter(module_id=module_id, name='ProbeScriptV2').exists())
+        self.assertEqual(fake_storage.files['zz_rollback.py'], updated_content)
+
+    def test_update_script_module_with_missing_stored_file(self):
+        """An update succeeds when the stored file is missing, re-creating it from the upload."""
+        self.add_permissions(
+            'extras.add_scriptmodule', 'core.add_managedfile',
+            'extras.change_scriptmodule', 'core.change_managedfile',
+        )
+        original_content = (
+            b"from extras.scripts import Script\n\n\n"
+            b"class ProbeScript(Script):\n    def run(self, data, commit):\n        return 'v1'\n"
+        )
+        updated_content = original_content.replace(b"'v1'", b"'v2'")
+        fake_storage = _InMemoryScriptStorage()
+
+        with self._patched_script_storage(fake_storage):
+            response = self.client.post(
+                self.url,
+                {'file': SimpleUploadedFile('zz_lost_file.py', original_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
+
+            # Simulate a file removed from storage outside NetBox
+            del fake_storage.files['zz_lost_file.py']
+
+            response = self.client.patch(
+                detail_url,
+                {'file': SimpleUploadedFile('zz_lost_file.py', updated_content, content_type='text/plain')},
+                format='multipart',
+                **self.header,
+            )
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(fake_storage.files['zz_lost_file.py'], updated_content)

+ 1 - 1
netbox/extras/tests/test_customfields.py

@@ -1569,7 +1569,7 @@ class CustomFieldAPITestCase(APITestCase):
         site1 = Site.objects.get(name='Site 1')
         site1 = Site.objects.get(name='Site 1')
         vlans = VLAN.objects.all()[:3]
         vlans = VLAN.objects.all()[:3]
         url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
         url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
-        self.add_permissions('dcim.change_site')
+        self.add_permissions('dcim.change_site', 'ipam.view_vlan')
 
 
         # Set related objects by PK
         # Set related objects by PK
         data = {
         data = {

+ 110 - 2
netbox/extras/tests/test_dashboard.py

@@ -1,6 +1,10 @@
-from django.test import TestCase, tag
+from unittest.mock import MagicMock, patch
 
 
-from extras.dashboard.widgets import ObjectListWidget
+from django.core.cache import cache
+from django.test import RequestFactory, TestCase, tag
+
+from extras.dashboard.widgets import ObjectListWidget, RSSFeedWidget
+from extras.templatetags.dashboard import render_widget
 
 
 
 
 class ObjectListWidgetTestCase(TestCase):
 class ObjectListWidgetTestCase(TestCase):
@@ -46,3 +50,107 @@ class ObjectListWidgetTestCase(TestCase):
         widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
         widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
         rendered = widget.render(mock_request)
         rendered = widget.render(mock_request)
         self.assertTrue('Unable to load content. Could not resolve list URL for:' in rendered)
         self.assertTrue('Unable to load content. Could not resolve list URL for:' in rendered)
+
+
+class RSSFeedWidgetSanitizationTestCase(TestCase):
+    """
+    Feed entry content is externally controlled and untrusted. Links must be validated against
+    ALLOWED_URL_SCHEMES so dangerous schemes (e.g. javascript:) cannot become clickable XSS sinks.
+    """
+
+    @tag('regression')
+    def test_sanitize_entries_blanks_disallowed_schemes(self):
+        entries = [
+            {'link': 'javascript:alert(document.cookie)', 'title': 't1'},
+            {'link': 'JavaScript:alert(1)', 'title': 't2'},  # case-insensitive
+            {'link': 'data:text/html,<script>alert(1)</script>', 'title': 't3'},
+            {'link': 'vbscript:msgbox(1)', 'title': 't4'},
+        ]
+        RSSFeedWidget.sanitize_entries(entries)
+        for entry in entries:
+            self.assertEqual(entry['link'], '', msg=f"Failed to blank {entry['title']}")
+
+    @tag('regression')
+    def test_sanitize_entries_preserves_allowed_links(self):
+        entries = [
+            {'link': 'https://example.com/post', 'title': 't1'},
+            {'link': 'http://example.com/post', 'title': 't2'},
+            {'link': 'mailto:user@example.com', 'title': 't3'},
+            {'link': '/relative/path', 'title': 't4'},  # schemeless relative link
+        ]
+        expected = [e['link'] for e in entries]
+        RSSFeedWidget.sanitize_entries(entries)
+        self.assertEqual([e['link'] for e in entries], expected)
+
+    @tag('regression')
+    def test_sanitize_entries_cleans_summary_html(self):
+        entries = [
+            {'link': 'https://example.com', 'title': 't1', 'summary': '<b>ok</b><script>alert(1)</script>'},
+        ]
+        RSSFeedWidget.sanitize_entries(entries)
+        self.assertNotIn('<script>', entries[0]['summary'])
+        self.assertIn('<b>ok</b>', entries[0]['summary'])
+
+    @tag('regression')
+    def test_get_feed_sanitizes_before_caching(self):
+        """
+        Fetched feed content must be sanitized before it is rendered or written to the cache,
+        so a poisoned link is never stored or served.
+        """
+        widget = RSSFeedWidget(config={
+            'feed_url': 'https://example.com/feed.xml',
+            'requires_internet': False,
+            'max_entries': 10,
+            'cache_timeout': 3600,
+        })
+        rss = (
+            b'<?xml version="1.0"?>'
+            b'<rss version="2.0"><channel><title>t</title>'
+            b'<link>http://example.com</link><description>d</description>'
+            b'<item><title>evil</title><link>javascript:alert(1)</link>'
+            b'<description>d</description></item>'
+            b'</channel></rss>'
+        )
+        mock_response = MagicMock()
+        mock_response.content = rss
+
+        with (
+            patch('extras.dashboard.widgets.requests.get', return_value=mock_response),
+            patch('extras.dashboard.widgets.resolve_proxies', return_value={}),
+        ):
+            result = widget.get_feed()
+
+        # The rendered feed is sanitized...
+        self.assertEqual(result['feed']['entries'][0]['link'], '')
+        # ...and the cached copy is sanitized too (never stored poisoned).
+        cached = cache.get(widget.cache_key)
+        self.assertEqual(cached['entries'][0]['link'], '')
+
+
+class RenderWidgetTemplateTagTestCase(TestCase):
+
+    def _make_context(self):
+        request = RequestFactory().get('/')
+        return {'request': request}
+
+    def test_render_widget_escapes_exception_html(self):
+        """Exception text with HTML special chars must be escaped, not rendered as markup."""
+
+        class BrokenWidget:
+            def render(self, request):
+                raise Exception('<script>alert(1)</script>')
+
+        output = render_widget(self._make_context(), BrokenWidget())
+        self.assertIn('&lt;script&gt;', output)
+        self.assertNotIn('<script>', output)
+
+    def test_render_widget_escapes_exception_angle_brackets(self):
+        """Angle brackets in exception messages are escaped."""
+
+        class BrokenWidget:
+            def render(self, request):
+                raise ValueError('invalid value: <bad>')
+
+        output = render_widget(self._make_context(), BrokenWidget())
+        self.assertIn('&lt;bad&gt;', output)
+        self.assertNotIn('<bad>', output)

+ 4 - 4
netbox/extras/tests/test_event_rules.py

@@ -190,7 +190,7 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
             ]
             ]
         }
         }
         url = reverse('dcim-api:site-list')
         url = reverse('dcim-api:site-list')
-        self.add_permissions('dcim.add_site')
+        self.add_permissions('dcim.add_site', 'extras.view_tag')
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(Site.objects.count(), 1)
         self.assertEqual(Site.objects.count(), 1)
@@ -241,7 +241,7 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
             },
             },
         ]
         ]
         url = reverse('dcim-api:site-list')
         url = reverse('dcim-api:site-list')
-        self.add_permissions('dcim.add_site')
+        self.add_permissions('dcim.add_site', 'extras.view_tag')
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(Site.objects.count(), 3)
         self.assertEqual(Site.objects.count(), 3)
@@ -275,7 +275,7 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
             ]
             ]
         }
         }
         url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
         url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
-        self.add_permissions('dcim.change_site')
+        self.add_permissions('dcim.change_site', 'extras.view_tag')
         response = self.client.patch(url, data, format='json', **self.header)
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
 
@@ -332,7 +332,7 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
             },
             },
         ]
         ]
         url = reverse('dcim-api:site-list')
         url = reverse('dcim-api:site-list')
-        self.add_permissions('dcim.change_site')
+        self.add_permissions('dcim.change_site', 'extras.view_tag')
         response = self.client.patch(url, data, format='json', **self.header)
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
 

+ 86 - 3
netbox/extras/tests/test_models.py

@@ -1,15 +1,20 @@
 import io
 import io
+import os
+import sys
 import tempfile
 import tempfile
 from pathlib import Path
 from pathlib import Path
 from types import SimpleNamespace
 from types import SimpleNamespace
 from unittest.mock import patch
 from unittest.mock import patch
 
 
+from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.files.base import ContentFile
 from django.core.files.base import ContentFile
 from django.core.files.storage import Storage
 from django.core.files.storage import Storage
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.core.files.uploadedfile import SimpleUploadedFile
+from django.db import connection
 from django.forms import ValidationError
 from django.forms import ValidationError
 from django.test import TestCase, tag
 from django.test import TestCase, tag
+from django.test.utils import CaptureQueriesContext
 from jinja2 import DebugUndefined, StrictUndefined, TemplateError, TemplateSyntaxError, UndefinedError
 from jinja2 import DebugUndefined, StrictUndefined, TemplateError, TemplateSyntaxError, UndefinedError
 from PIL import Image
 from PIL import Image
 
 
@@ -1200,6 +1205,32 @@ class ConfigTemplateDebugTestCase(TestCase):
         with self.assertRaises(TemplateSyntaxError):
         with self.assertRaises(TemplateSyntaxError):
             render_jinja2("{% debug %}", {}, debug=False)
             render_jinja2("{% debug %}", {}, debug=False)
 
 
+    def test_format_render_error_debug_redacts_install_path(self):
+        """format_render_error() strips the repo install-path prefix from debug tracebacks."""
+        t = ConfigTemplate(name='redact-test', template_code='hello', debug=True)
+        try:
+            raise ValueError("deliberate test error")
+        except ValueError as exc:
+            result = t.format_render_error(exc)
+        install_root = os.path.dirname(settings.BASE_DIR) + os.sep
+        self.assertIn('Traceback', result)
+        self.assertNotIn(install_root, result)
+        # Also verify the venv prefix is stripped when running inside a virtualenv.
+        if sys.prefix != sys.base_prefix:
+            venv_root = sys.prefix + os.sep
+            if venv_root != install_root:
+                self.assertNotIn(venv_root, result)
+
+    def test_format_render_error_non_debug_returns_concise_message(self):
+        """format_render_error() returns a one-line message (no traceback) when debug=False."""
+        t = ConfigTemplate(name='nodebug-test', template_code='hello', debug=False)
+        try:
+            raise TemplateError("bad template")
+        except TemplateError as exc:
+            result = t.format_render_error(exc)
+        self.assertNotIn('Traceback', result)
+        self.assertIn('TemplateError', result)
+
 
 
 class JinjaEnvFilterTestCase(TestCase):
 class JinjaEnvFilterTestCase(TestCase):
     """
     """
@@ -1310,6 +1341,24 @@ class RenderTemplateMixinRenderTestCase(TestCase):
         self.assertNotEqual(plain.render(ctx), trimmed.render(ctx))
         self.assertNotEqual(plain.render(ctx), trimmed.render(ctx))
         self.assertEqual(trimmed.render(ctx).strip(), 'VALUE')
         self.assertEqual(trimmed.render(ctx).strip(), 'VALUE')
 
 
+    def test_configtemplate_autoescape_always_disabled(self):
+        """
+        ConfigTemplate renders plain text (network configs, scripts); autoescape must stay off
+        even if environment_params explicitly requests it (#22652).
+        """
+        t = ConfigTemplate(name='autoescape', template_code='{{ value }}', environment_params={'autoescape': True})
+        self.assertEqual(t.render({'value': '<script>'}), '<script>')
+
+    def test_exporttemplate_autoescape_is_configurable(self):
+        """
+        Unlike ConfigTemplate, ExportTemplate output may legitimately be HTML, so an explicit
+        autoescape=True in environment_params must be honored rather than forced off.
+        """
+        et = ExportTemplate(
+            name='autoescape', template_code='{{ value }}', environment_params={'autoescape': True}
+        )
+        self.assertEqual(et.render({'value': '<script>'}), '&lt;script&gt;')
+
     def test_environment_params_undefined_path_import(self):
     def test_environment_params_undefined_path_import(self):
         # Default Undefined renders nothing for a missing variable.
         # Default Undefined renders nothing for a missing variable.
         default = ConfigTemplate(name='default', template_code='{{ missing }}')
         default = ConfigTemplate(name='default', template_code='{{ missing }}')
@@ -1339,8 +1388,9 @@ class RenderTemplateMixinRenderTestCase(TestCase):
 
 
     def test_get_environment_params_handles_none(self):
     def test_get_environment_params_handles_none(self):
         # The environment_params field may be cleared; ensure the mixin returns a dict (not None).
         # The environment_params field may be cleared; ensure the mixin returns a dict (not None).
+        # ConfigTemplate always forces autoescape off (#22652).
         t = ConfigTemplate(name='empty', template_code='ok', environment_params=None)
         t = ConfigTemplate(name='empty', template_code='ok', environment_params=None)
-        self.assertEqual(t.get_environment_params(), {})
+        self.assertEqual(t.get_environment_params(), {'autoescape': False})
 
 
     def test_get_environment_params_resolves_path_imports(self):
     def test_get_environment_params_resolves_path_imports(self):
         t = ConfigTemplate(
         t = ConfigTemplate(
@@ -1398,6 +1448,37 @@ class RenderTemplateMixinResponseTestCase(TestCase):
         response = t.render_to_response(queryset=Site.objects.all())
         response = t.render_to_response(queryset=Site.objects.all())
         self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
         self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
 
 
+    def test_response_attachment_filename_from_empty_queryset(self):
+        """An empty (but non-None) queryset must still yield a model-derived filename."""
+        t = ExportTemplate(
+            name='t',
+            template_code='{% for obj in queryset %}{{ obj.name }}{% endfor %}',
+            file_extension='txt',
+            as_attachment=True,
+        )
+        response = t.render_to_response(queryset=Site.objects.none())
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
+
+    def test_response_attachment_does_not_force_queryset_evaluation(self):
+        """A template that never references `queryset` must not force it to be evaluated."""
+        Site.objects.bulk_create([Site(name=f'Site {i}', slug=f'site-{i}') for i in range(5)])
+        t = ExportTemplate(
+            name='t',
+            template_code='static output',  # deliberately does not reference `queryset`
+            file_extension='txt',
+            as_attachment=True,
+        )
+        with CaptureQueriesContext(connection) as ctx:
+            t.render_to_response(queryset=Site.objects.all())
+
+        table = Site._meta.db_table
+        site_queries = [q for q in ctx.captured_queries if table in q['sql']]
+        self.assertEqual(
+            site_queries, [],
+            f"render_to_response() queried {table} even though the template never "
+            f"references `queryset`:\n{site_queries}"
+        )
+
     def test_response_attachment_filename_from_device_context(self):
     def test_response_attachment_filename_from_device_context(self):
         t = ConfigTemplate(name='t', template_code='ok', as_attachment=True)
         t = ConfigTemplate(name='t', template_code='ok', as_attachment=True)
         device = SimpleNamespace(name='router1')
         device = SimpleNamespace(name='router1')
@@ -1666,9 +1747,11 @@ class JinjaEnvironmentParamsIntegrationTestCase(TestCase):
         self.assertEqual(template.environment_params['undefined'], 'jinja2.StrictUndefined')
         self.assertEqual(template.environment_params['undefined'], 'jinja2.StrictUndefined')
 
 
     def test_none_environment_params(self):
     def test_none_environment_params(self):
+        # ConfigTemplate always forces autoescape off (#22652).
         template = self._make_template(None)
         template = self._make_template(None)
-        self.assertEqual(template.get_environment_params(), {})
+        self.assertEqual(template.get_environment_params(), {'autoescape': False})
 
 
     def test_empty_environment_params(self):
     def test_empty_environment_params(self):
+        # ConfigTemplate always forces autoescape off (#22652).
         template = self._make_template({})
         template = self._make_template({})
-        self.assertEqual(template.get_environment_params(), {})
+        self.assertEqual(template.get_environment_params(), {'autoescape': False})

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

@@ -1,11 +1,16 @@
+import io
+import sys
 from datetime import UTC, date, datetime
 from datetime import UTC, date, datetime
 from decimal import Decimal
 from decimal import Decimal
+from unittest.mock import patch
 
 
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.test import TestCase
 from django.test import TestCase
 from netaddr import IPAddress, IPNetwork
 from netaddr import IPAddress, IPNetwork
 
 
 from dcim.models import DeviceRole
 from dcim.models import DeviceRole
+from extras.constants import SCRIPT_MODULE_NAME_PREFIX
+from extras.models import ScriptModule
 from extras.scripts import *
 from extras.scripts import *
 
 
 CHOICES = (
 CHOICES = (
@@ -388,3 +393,49 @@ class ScriptVariablesTestCase(TestCase):
         self.assertEqual(form.cleaned_data['var1'], input_datetime)
         self.assertEqual(form.cleaned_data['var1'], input_datetime)
         # Validate required=False works for this Var type
         # Validate required=False works for this Var type
         self.assertEqual(form.cleaned_data['var2'], None)
         self.assertEqual(form.cleaned_data['var2'], None)
+
+
+class ScriptModuleLoadingTestCase(TestCase):
+
+    def test_module_does_not_shadow_core_app(self):
+        """
+        Loading a custom script whose filename matches a core app label must not replace that
+        app's package in sys.modules. Regression test for issue #22566.
+        """
+        import circuits  # The real core app package
+
+        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='circuits.py')
+        namespaced_key = f'{SCRIPT_MODULE_NAME_PREFIX}circuits'
+        self.addCleanup(lambda: sys.modules.pop(namespaced_key, None))
+
+        with patch('extras.models.mixins.storages') as mock_storages:
+            mock_storages.__getitem__.return_value = _Storage()
+            loaded = module.get_module()
+
+            # The script module is registered under the private, namespaced key, and its own
+            # __name__ matches that key (i.e. sys.modules[module.__name__] resolves to the module)
+            self.assertIs(sys.modules[namespaced_key], loaded)
+            self.assertEqual(loaded.__name__, namespaced_key)
+
+            # The namespacing must not leak into the derived script name stored in the database
+            self.assertEqual(next(iter(module.module_scripts)), 'TestScript')
+
+            # Nor into the user-facing names exposed on the Script class (used for logger
+            # namespaces, page headers, etc.): these must reflect the original filename.
+            script_class = loaded.TestScript
+            self.assertEqual(script_class.module, 'circuits')
+            self.assertEqual(script_class.full_name, 'circuits.TestScript')
+            self.assertEqual(script_class.root_module(), 'circuits')
+
+        # The real circuits app must be untouched and remain an importable package
+        self.assertIs(sys.modules['circuits'], circuits)
+        self.assertTrue(hasattr(circuits, '__path__'))

+ 1 - 1
netbox/extras/tests/test_tags.py

@@ -50,7 +50,7 @@ class TaggedItemTestCase(APITestCase):
                 {"name": "New Tag"},
                 {"name": "New Tag"},
             ]
             ]
         }
         }
-        self.add_permissions('dcim.change_site')
+        self.add_permissions('dcim.change_site', 'extras.view_tag')
         url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
         url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
 
 
         response = self.client.patch(url, data, format='json', **self.header)
         response = self.client.patch(url, data, format='json', **self.header)

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

@@ -128,3 +128,64 @@ class CustomLinkRequestSanitizationTest(TestCase):
         self.assertIn('/dcim/sites/1/', self.render_link('{{ request.path }}'))
         self.assertIn('/dcim/sites/1/', self.render_link('{{ request.path }}'))
         self.assertIn('bar', self.render_link("{{ request.GET.get('foo') }}"))
         self.assertIn('bar', self.render_link("{{ request.GET.get('foo') }}"))
         self.assertIn('admin', self.render_link('{{ request.user }}'))
         self.assertIn('admin', self.render_link('{{ request.user }}'))
+
+
+class CustomLinkRenderErrorEscapingTest(TestCase):
+    """
+    When CustomLink.render() raises, the exception-fallback markup must escape the (attacker-controllable)
+    CustomLink name before it is returned via mark_safe() (see NB-3004).
+    """
+
+    XSS_NAME = '<img src=x onerror=alert(1)>'
+    ESCAPED_NAME = '&lt;img src=x onerror=alert(1)&gt;'
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site = Site.objects.create(name='Site 1', slug='site-1')
+
+    def render(self, user):
+        request = RequestFactory().get('/')
+        request.user = user
+        context = {
+            'request': request,
+            'user': user,
+            'perms': PermWrapper(user),
+        }
+        return custom_links(context, self.site)
+
+    def make_user_with_view_permission(self, username):
+        user = User.objects.create_user(username=username)
+        permission = ObjectPermission.objects.create(name=f'{username} custom links', actions=['view'])
+        permission.object_types.set([ObjectType.objects.get_for_model(CustomLink)])
+        permission.users.set([user])
+        # Re-fetch to clear any cached permissions
+        return User.objects.get(pk=user.pk)
+
+    def test_render_error_escapes_name(self):
+        # A CustomLink whose render() raises must have its name escaped in the error fallback.
+        custom_link = CustomLink.objects.create(
+            name=self.XSS_NAME,
+            enabled=True,
+            link_text='{{ 1 / 0 }}',  # Raises ZeroDivisionError during render
+            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('user1'))
+        self.assertNotIn(self.XSS_NAME, rendered)
+        self.assertIn(self.ESCAPED_NAME, rendered)
+
+    def test_render_error_escapes_grouped_name(self):
+        # The grouped-link error fallback must likewise escape the name.
+        custom_link = CustomLink.objects.create(
+            name=self.XSS_NAME,
+            enabled=True,
+            group_name='Group 1',
+            link_text='{{ 1 / 0 }}',  # Raises ZeroDivisionError during render
+            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('user2'))
+        self.assertNotIn(self.XSS_NAME, rendered)
+        self.assertIn(self.ESCAPED_NAME, rendered)

+ 1 - 9
netbox/extras/utils.py

@@ -6,7 +6,6 @@ from django.core.exceptions import ImproperlyConfigured, SuspiciousFileOperation
 from django.core.files.storage import Storage, default_storage
 from django.core.files.storage import Storage, default_storage
 from django.core.files.utils import validate_file_name
 from django.core.files.utils import validate_file_name
 from django.db import models
 from django.db import models
-from django.db.models import Q
 from taggit.managers import _TaggableManager
 from taggit.managers import _TaggableManager
 
 
 from netbox.context import current_request
 from netbox.context import current_request
@@ -32,14 +31,7 @@ class SharedObjectViewMixin:
         """
         """
         Return only shared objects, or those owned by the current user, unless this is a superuser.
         Return only shared objects, or those owned by the current user, unless this is a superuser.
         """
         """
-        queryset = super().get_queryset(request)
-        if request.user.is_superuser:
-            return queryset
-        if request.user.is_anonymous:
-            return queryset.filter(shared=True)
-        return queryset.filter(
-            Q(shared=True) | Q(user=request.user)
-        )
+        return super().get_queryset(request).restrict_to_shared(request.user)
 
 
 
 
 def filename_from_model(model: models.Model) -> str:
 def filename_from_model(model: models.Model) -> str:

+ 2 - 3
netbox/generate_secret_key.py

@@ -1,6 +1,5 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
 # This script will generate a random 50-character string suitable for use as a SECRET_KEY.
 # This script will generate a random 50-character string suitable for use as a SECRET_KEY.
-import secrets
+from utilities.secret_key import generate_secret_key
 
 
-charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
-print(''.join(secrets.choice(charset) for _ in range(50)))
+print(generate_secret_key())

+ 1 - 1
netbox/ipam/choices.py

@@ -69,7 +69,7 @@ class IPAddressStatusChoices(ChoiceSet):
         Choice(STATUS_ACTIVE, _('Active'), color='blue', description=_('Provisioned and in use')),
         Choice(STATUS_ACTIVE, _('Active'), color='blue', description=_('Provisioned and in use')),
         Choice(STATUS_RESERVED, _('Reserved'), color='cyan', description=_('Designated for future use')),
         Choice(STATUS_RESERVED, _('Reserved'), color='cyan', description=_('Designated for future use')),
         Choice(STATUS_DEPRECATED, _('Deprecated'), color='red', description=_('No longer in use')),
         Choice(STATUS_DEPRECATED, _('Deprecated'), color='red', description=_('No longer in use')),
-        Choice(STATUS_DHCP, _('DHCP'), color='green', description=_('Assigned dynamically via DHCP')),
+        Choice(STATUS_DHCP, _('DHCP'), color='purple', description=_('Assigned dynamically via DHCP')),
         Choice(
         Choice(
             STATUS_SLAAC,
             STATUS_SLAAC,
             _('SLAAC'),
             _('SLAAC'),

+ 1 - 1
netbox/ipam/filtersets.py

@@ -1070,7 +1070,7 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         method='get_for_site'
         method='get_for_site'
     )
     )
     available_on_device = django_filters.ModelChoiceFilter(
     available_on_device = django_filters.ModelChoiceFilter(
-        queryset=Device.objects.all(),
+        queryset=Device.objects.select_related('cluster'),
         method='get_for_device'
         method='get_for_device'
     )
     )
     available_on_virtualmachine = django_filters.ModelChoiceFilter(
     available_on_virtualmachine = django_filters.ModelChoiceFilter(

+ 27 - 0
netbox/ipam/migrations/0093_alter_prefix__region_alter_prefix__site_group.py

@@ -0,0 +1,27 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0239_add_portmapping_objectchange'),
+        ('ipam', '0092_iprange_host_indexes'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='prefix',
+            name='_region',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.region'
+            ),
+        ),
+        migrations.AlterField(
+            model_name='prefix',
+            name='_site_group',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.sitegroup'
+            ),
+        ),
+    ]

+ 2 - 2
netbox/ipam/migrations/0093_denormalization_triggers.py → netbox/ipam/migrations/0094_denormalization_triggers.py

@@ -10,9 +10,9 @@ from utilities.migration import InstallDenormalizationTrigger
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('ipam', '0092_iprange_host_indexes'),
+        ('ipam', '0093_alter_prefix__region_alter_prefix__site_group'),
         # Source tables (dcim_site, dcim_location) must already exist.
         # Source tables (dcim_site, dcim_location) must already exist.
-        ('dcim', '0238_ltree_paths'),
+        ('dcim', '0240_ltree_paths'),
     ]
     ]
 
 
     operations = [
     operations = [

+ 12 - 0
netbox/ipam/querysets.py

@@ -298,6 +298,18 @@ class VLANQuerySet(RestrictedQuerySet):
 
 
         # Find all relevant VLANGroups
         # Find all relevant VLANGroups
         q = Q()
         q = Q()
+        if device.cluster_id:
+            # The Device's physical scope is evaluated below. For valid assignments,
+            # the Cluster's physical scope is already represented by that hierarchy.
+            q |= Q(
+                scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
+                scope_id=device.cluster_id
+            )
+            if device.cluster.group_id:
+                q |= Q(
+                    scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
+                    scope_id=device.cluster.group_id
+                )
         if device.site.region:
         if device.site.region:
             q |= Q(
             q |= Q(
                 scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
                 scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),

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

@@ -2188,6 +2188,26 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'available_on_device': device_id}
         params = {'available_on_device': device_id}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)  # 5 scoped + 1 global group + 1 global
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)  # 5 scoped + 1 global group + 1 global
 
 
+    def test_available_on_device_cluster_scopes(self):
+        device = Device.objects.get(name='Device 1')
+        device.cluster = Cluster.objects.get(name='Cluster 1')
+        device.save(update_fields=('cluster',))
+
+        params = {'available_on_device': device.pk}
+        vlans = self.filterset(params, self.queryset).qs
+
+        # VLANs from groups scoped to the assigned cluster or its cluster group
+        self.assertIn(VLAN.objects.get(name='Cluster 1'), vlans)
+        self.assertIn(VLAN.objects.get(name='Cluster Group 1'), vlans)
+        # VLANs from groups scoped to unrelated clusters or cluster groups
+        self.assertNotIn(VLAN.objects.get(name='Cluster 2'), vlans)
+        self.assertNotIn(VLAN.objects.get(name='Cluster Group 2'), vlans)
+        # Site, location, rack and global availability is unchanged
+        self.assertEqual(
+            set(vlans.values_list('vid', flat=True)),
+            {1, 4, 7, 10, 13, 16, 19, 500, 1000}
+        )
+
     def test_available_on_virtualmachine(self):
     def test_available_on_virtualmachine(self):
         vm_id = VirtualMachine.objects.first().pk
         vm_id = VirtualMachine.objects.first().pk
         params = {'available_on_virtualmachine': vm_id}
         params = {'available_on_virtualmachine': vm_id}

+ 75 - 1
netbox/ipam/tests/test_models.py

@@ -5,7 +5,7 @@ from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.test import TestCase, override_settings
 from django.test import TestCase, override_settings
 from netaddr import IPNetwork, IPSet
 from netaddr import IPNetwork, IPSet
 
 
-from dcim.models import Site, SiteGroup
+from dcim.models import Location, Region, Site, SiteGroup
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import SERVICE_PORT_MAX, SERVICE_PORT_MIN
 from ipam.constants import SERVICE_PORT_MAX, SERVICE_PORT_MIN
 from ipam.models import *
 from ipam.models import *
@@ -1262,6 +1262,80 @@ class PrefixTestCase(TestCase):
         duplicate_prefix = Prefix(vrf=vrf, prefix=IPNetwork('192.0.2.0/24'))
         duplicate_prefix = Prefix(vrf=vrf, prefix=IPNetwork('192.0.2.0/24'))
         self.assertRaises(ValidationError, duplicate_prefix.clean)
         self.assertRaises(ValidationError, duplicate_prefix.clean)
 
 
+    # Regression test for #22682
+    def test_deleting_site_group_does_not_delete_prefix_scoped_to_member_site(self):
+        sitegroup = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
+        site = Site.objects.create(name='Site 1', slug='site-1', group=sitegroup)
+        prefix = Prefix.objects.create(prefix=IPNetwork('10.0.0.0/24'), scope=site)
+
+        sitegroup.delete()
+
+        site.refresh_from_db()
+        prefix.refresh_from_db()
+        self.assertIsNone(site.group)
+        self.assertEqual(prefix.scope, site)
+        self.assertIsNone(prefix._site_group_id)
+
+    # Regression test for #22682
+    def test_deleting_region_does_not_delete_prefix_scoped_to_member_site(self):
+        region = Region.objects.create(name='Region 1', slug='region-1')
+        site = Site.objects.create(name='Site 2', slug='site-2', region=region)
+        prefix = Prefix.objects.create(prefix=IPNetwork('10.0.1.0/24'), scope=site)
+
+        region.delete()
+
+        site.refresh_from_db()
+        prefix.refresh_from_db()
+        self.assertIsNone(site.region)
+        self.assertEqual(prefix.scope, site)
+        self.assertIsNone(prefix._region_id)
+
+    # Regression test for #22682
+    def test_deleting_site_group_does_not_delete_prefix_scoped_to_member_location(self):
+        sitegroup = SiteGroup.objects.create(name='Site Group 3', slug='site-group-3')
+        site = Site.objects.create(name='Site 3', slug='site-3', group=sitegroup)
+        location = Location.objects.create(name='Location 1', slug='location-1', site=site)
+        prefix = Prefix.objects.create(prefix=IPNetwork('10.0.4.0/24'), scope=location)
+
+        sitegroup.delete()
+
+        site.refresh_from_db()
+        prefix.refresh_from_db()
+        self.assertIsNone(site.group)
+        self.assertEqual(prefix.scope, location)
+        self.assertIsNone(prefix._site_group_id)
+
+    # Regression test for #22682
+    def test_deleting_region_does_not_delete_prefix_scoped_to_member_location(self):
+        region = Region.objects.create(name='Region 3', slug='region-3')
+        site = Site.objects.create(name='Site 4', slug='site-4', region=region)
+        location = Location.objects.create(name='Location 2', slug='location-2', site=site)
+        prefix = Prefix.objects.create(prefix=IPNetwork('10.0.5.0/24'), scope=location)
+
+        region.delete()
+
+        site.refresh_from_db()
+        prefix.refresh_from_db()
+        self.assertIsNone(site.region)
+        self.assertEqual(prefix.scope, location)
+        self.assertIsNone(prefix._region_id)
+
+    def test_deleting_site_group_scoped_to_it_directly_still_deletes_prefix(self):
+        sitegroup = SiteGroup.objects.create(name='Site Group 2', slug='site-group-2')
+        prefix = Prefix.objects.create(prefix=IPNetwork('10.0.2.0/24'), scope=sitegroup)
+
+        sitegroup.delete()
+
+        self.assertFalse(Prefix.objects.filter(pk=prefix.pk).exists())
+
+    def test_deleting_region_scoped_to_it_directly_still_deletes_prefix(self):
+        region = Region.objects.create(name='Region 2', slug='region-2')
+        prefix = Prefix.objects.create(prefix=IPNetwork('10.0.3.0/24'), scope=region)
+
+        region.delete()
+
+        self.assertFalse(Prefix.objects.filter(pk=prefix.pk).exists())
+
 
 
 class PrefixHierarchyTestCase(TestCase):
 class PrefixHierarchyTestCase(TestCase):
     """
     """

+ 8 - 2
netbox/ipam/views.py

@@ -1,4 +1,5 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import EmptyResultSet
 from django.db.models import Prefetch
 from django.db.models import Prefetch
 from django.db.models.expressions import RawSQL
 from django.db.models.expressions import RawSQL
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
@@ -569,8 +570,13 @@ class ChildAvailabilityMixin:
 
 
     @staticmethod
     @staticmethod
     def _where_signature(queryset):
     def _where_signature(queryset):
-        # query.where is Django-internal, but it is the closest signal for "narrowed by a filter".
-        return str(queryset.query.where)
+        # Compare compiled SQL rather than str(query.where): the WHERE tree embeds default
+        # object reprs (memory addresses) for permission-constraint subqueries, so two
+        # otherwise identical querysets built via restrict() never match (#22539).
+        try:
+            return queryset.query.get_compiler(using=queryset.db).as_sql()
+        except EmptyResultSet:
+            return None
 
 
     def _set_children_filtered(self, is_filtered):
     def _set_children_filtered(self, is_filtered):
         self._child_queryset_is_filtered = is_filtered
         self._child_queryset_is_filtered = is_filtered

+ 8 - 0
netbox/netbox/__main__.py

@@ -0,0 +1,8 @@
+"""Allow `python -m netbox` to behave like the `netbox` console script."""
+
+import sys
+
+from .cli import main
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 13 - 1
netbox/netbox/api/serializers/base.py

@@ -4,6 +4,7 @@ from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
+from netbox.api.fields import RelatedObjectCountField
 from utilities.api import get_related_object_by_attrs
 from utilities.api import get_related_object_by_attrs
 
 
 from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField
 from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField
@@ -49,7 +50,9 @@ class BaseModelSerializer(serializers.ModelSerializer):
         # identifying a related object.
         # identifying a related object.
         if self.nested:
         if self.nested:
             queryset = self.Meta.model.objects.all()
             queryset = self.Meta.model.objects.all()
-            return get_related_object_by_attrs(queryset, data)
+            request = self.context.get('request')
+            user = request.user if request else None
+            return get_related_object_by_attrs(queryset, data, user=user)
 
 
         return super().to_internal_value(data)
         return super().to_internal_value(data)
 
 
@@ -69,6 +72,15 @@ class BaseModelSerializer(serializers.ModelSerializer):
         for field_name in set(self._omit_fields):
         for field_name in set(self._omit_fields):
             fields.pop(field_name, None)
             fields.pop(field_name, None)
 
 
+        # Related object counts are populated by annotations applied to the viewset's queryset, but these
+        # annotations are not applied when the object is represented as a nested (brief) related object. Omit
+        # these fields when serializing a nested object to avoid advertising fields that will never be populated
+        # (and which would otherwise be declared as required in the generated OpenAPI schema). See #22154.
+        if self.nested:
+            for field_name, field in list(fields.items()):
+                if isinstance(field, RelatedObjectCountField):
+                    fields.pop(field_name)
+
         return fields
         return fields
 
 
     @extend_schema_field(OpenApiTypes.STR)
     @extend_schema_field(OpenApiTypes.STR)

+ 8 - 2
netbox/netbox/api/serializers/generic.py

@@ -22,6 +22,10 @@ class GenericObjectSerializer(serializers.Serializer):
     object_id = serializers.IntegerField()
     object_id = serializers.IntegerField()
     object = serializers.SerializerMethodField(read_only=True)
     object = serializers.SerializerMethodField(read_only=True)
 
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._serializer_cache = {}
+
     def to_internal_value(self, data):
     def to_internal_value(self, data):
         data = super().to_internal_value(data)
         data = super().to_internal_value(data)
         model = data['object_type'].model_class()
         model = data['object_type'].model_class()
@@ -40,5 +44,7 @@ class GenericObjectSerializer(serializers.Serializer):
 
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
     @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_object(self, obj):
     def get_object(self, obj):
-        serializer = get_serializer_for_model(obj)
-        return serializer(obj, nested=True, context=self.context).data
+        if obj.__class__ not in self._serializer_cache:
+            self._serializer_cache[obj.__class__] = get_serializer_for_model(obj)(nested=True, context=self.context)
+        serializer = self._serializer_cache[obj.__class__]
+        return serializer.to_representation(obj)

+ 3 - 1
netbox/netbox/api/serializers/nested.py

@@ -17,7 +17,9 @@ class WritableNestedSerializer(BaseModelSerializer):
     """
     """
     def to_internal_value(self, data):
     def to_internal_value(self, data):
         queryset = self.Meta.model.objects.all()
         queryset = self.Meta.model.objects.all()
-        return get_related_object_by_attrs(queryset, data)
+        request = self.context.get('request')
+        user = request.user if request else None
+        return get_related_object_by_attrs(queryset, data, user=user)
 
 
 
 
 # Declared here for use by PrimaryModelSerializer
 # Declared here for use by PrimaryModelSerializer

+ 23 - 21
netbox/netbox/authentication/__init__.py

@@ -1,14 +1,15 @@
 import logging
 import logging
 from collections import defaultdict
 from collections import defaultdict
 
 
+from django.apps import apps
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth.backends import ModelBackend
 from django.contrib.auth.backends import ModelBackend
 from django.contrib.auth.backends import RemoteUserBackend as _RemoteUserBackend
 from django.contrib.auth.backends import RemoteUserBackend as _RemoteUserBackend
 from django.contrib.auth.models import AnonymousUser
 from django.contrib.auth.models import AnonymousUser
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 from django.db.models import Q
 from django.db.models import Q
-from django.utils.translation import gettext_lazy as _
 
 
+from netbox.settings_utils import load_ldap_config
 from users.constants import CONSTRAINT_TOKEN_USER
 from users.constants import CONSTRAINT_TOKEN_USER
 from users.models import Group, ObjectPermission, User
 from users.models import Group, ObjectPermission, User
 from utilities.permissions import (
 from utilities.permissions import (
@@ -137,12 +138,18 @@ class ObjectPermissionMixin:
         if obj is None:
         if obj is None:
             return True
             return True
 
 
-        # Sanity check: Ensure that the requested permission applies to the specified object
-        model = obj._meta.concrete_model
-        if model._meta.label_lower != '.'.join((app_label, model_name)):
-            raise ValueError(_("Invalid permission {permission} for model {model}").format(
-                permission=perm, model=model
-            ))
+        # Sanity check: the permission must apply to the object's model. Permissions may name proxy
+        # models, so compare concrete models and evaluate constraints via the permission model's manager.
+        try:
+            permission_model = apps.get_model(app_label, model_name)
+        except LookupError:
+            logger = logging.getLogger('netbox.auth.ObjectPermissionBackend')
+            logger.warning(f"Permission {perm} does not reference a valid model")
+            return False
+        if permission_model._meta.concrete_model is not obj._meta.concrete_model:
+            logger = logging.getLogger('netbox.auth.ObjectPermissionBackend')
+            logger.debug(f"Permission {perm} is not valid for {obj._meta.label_lower} objects")
+            return False
 
 
         # Compile a QuerySet filter that matches all instances of the specified model
         # Compile a QuerySet filter that matches all instances of the specified model
         tokens = {
         tokens = {
@@ -153,7 +160,7 @@ class ObjectPermissionMixin:
         # Permission to perform the requested action on the object depends on whether the specified object matches
         # Permission to perform the requested action on the object depends on whether the specified object matches
         # the specified constraints. Note that this check is made against the *database* record representing the object,
         # the specified constraints. Note that this check is made against the *database* record representing the object,
         # not the instance itself.
         # not the instance itself.
-        return model.objects.filter(qs_filter, pk=obj.pk).exists()
+        return permission_model.objects.filter(qs_filter, pk=obj.pk).exists()
 
 
 
 
 class ObjectPermissionBackend(ObjectPermissionMixin, ModelBackend):
 class ObjectPermissionBackend(ObjectPermissionMixin, ModelBackend):
@@ -338,15 +345,10 @@ class LDAPBackend:
                 )
                 )
             raise e
             raise e
 
 
-        try:
-            from netbox import ldap_config
-        except ModuleNotFoundError as e:
-            if getattr(e, 'name') == 'ldap_config':
-                raise ImproperlyConfigured(
-                    "LDAP configuration file not found: Check that ldap_config.py has been created alongside "
-                    "configuration.py."
-                )
-            raise e
+        ldap_config = load_ldap_config(
+            settings.CONFIGURATION_DIR,
+            allow_legacy_fallback=settings.NETBOX_INSTALL_MODE == 'checkout',
+        )
 
 
         try:
         try:
             getattr(ldap_config, 'AUTH_LDAP_SERVER_URI')
             getattr(ldap_config, 'AUTH_LDAP_SERVER_URI')
@@ -358,11 +360,11 @@ class LDAPBackend:
         obj = NBLDAPBackend()
         obj = NBLDAPBackend()
 
 
         # Read LDAP configuration parameters from ldap_config.py instead of settings.py
         # Read LDAP configuration parameters from ldap_config.py instead of settings.py
-        settings = LDAPSettings()
+        ldap_settings = LDAPSettings()
         for param in dir(ldap_config):
         for param in dir(ldap_config):
-            if param.startswith(settings._prefix):
-                setattr(settings, param[10:], getattr(ldap_config, param))
-        obj.settings = settings
+            if param.startswith(ldap_settings._prefix):
+                setattr(ldap_settings, param[10:], getattr(ldap_config, param))
+        obj.settings = ldap_settings
 
 
         # Optionally disable strict certificate checking
         # Optionally disable strict certificate checking
         if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):
         if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):

+ 98 - 0
netbox/netbox/cli.py

@@ -0,0 +1,98 @@
+"""Console entry point for pip-installed NetBox."""
+
+import argparse
+import os
+import sys
+from importlib.metadata import PackageNotFoundError, version
+
+# Commands handled here must not require Django settings. These names are intentionally
+# reserved by the console wrapper and are never dispatched to Django management commands.
+# 'setup' is not listed: the early-dispatch branch in main() owns it and always returns
+# before this tuple is consulted.
+_RESERVED_COMMANDS = ('secret-key', 'version')
+
+_EPILOG = """Any other command is dispatched to the Django management commands, which
+require a valid NetBox configuration, e.g.:
+  {prog} upgrade
+  {prog} check
+  {prog} createsuperuser
+
+Run "{prog} help" (once configured) for the full management command listing."""
+
+
+def _prog():
+    if sys.argv and sys.argv[0]:
+        name = os.path.basename(sys.argv[0])
+        # `python -m netbox` executes __main__.py; show the user-facing name instead.
+        if name != '__main__.py':
+            return name
+    return 'netbox'
+
+
+def _print_version():
+    try:
+        print(version('netbox'))
+    except PackageNotFoundError:  # pragma: no cover - only in a non-installed checkout
+        print('unknown')
+
+
+def _build_parser(prog):
+    parser = argparse.ArgumentParser(
+        prog=prog,
+        description='NetBox command line interface.',
+        epilog=_EPILOG.format(prog=prog),
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    subparsers = parser.add_subparsers(
+        dest='command',
+        title='pre-configuration commands (no NetBox configuration required)',
+    )
+    subparsers.add_parser(
+        'version', help='Print the installed NetBox package version.',
+        description='Print the installed NetBox package version.')
+    subparsers.add_parser(
+        'setup', add_help=False,
+        help='Create the local configuration files for a pip-installed instance.')
+    subparsers.add_parser(
+        'secret-key', help='Generate a new 50-character SECRET_KEY value.',
+        description='Generate a new 50-character SECRET_KEY value.')
+    return parser
+
+
+def main(argv=None):
+    prog = _prog()
+    args = list(sys.argv[1:] if argv is None else argv)
+
+    # `setup` owns its own parser (netbox.scaffold); dispatch before the wrapper parser.
+    if args and args[0] == 'setup':
+        # Deferred so the command works before Django or a configuration exists.
+        from netbox.scaffold import main as setup_main
+        return setup_main(args[1:], prog=f'{prog} setup')
+
+    if not args:
+        _build_parser(prog).print_help()
+        return 0
+
+    if args[0] in _RESERVED_COMMANDS or args[0] in ('-h', '--help', '--version'):
+        parser = _build_parser(prog)
+        if args[0] == '--version':
+            args = ['version', *args[1:]]
+        try:
+            options = parser.parse_args(args)
+        except SystemExit as e:  # argparse already printed help (0) or an error (2)
+            return int(e.code or 0)
+        if options.command == 'version':
+            _print_version()
+        elif options.command == 'secret-key':
+            # Deferred so the command works before Django or a configuration exists.
+            from utilities.secret_key import generate_secret_key
+            print(generate_secret_key())
+        return 0
+
+    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'netbox.settings')
+
+    # Deferred on purpose: Django must not import until DJANGO_SETTINGS_MODULE is set.
+    from django.core.management import execute_from_command_line
+
+    execute_from_command_line([prog, *args])
+    return 0

+ 3 - 3
netbox/netbox/configuration_example.py

@@ -179,7 +179,7 @@ LOGOUT_REDIRECT_URL = 'home'
 
 
 # The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that
 # The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that
 # the default value of this setting is derived from the installed location.
 # the default value of this setting is derived from the installed location.
-# MEDIA_ROOT = '/opt/netbox/netbox/media'
+# MEDIA_ROOT = '/path/to/netbox/media'
 
 
 # Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
 # Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
 METRICS_ENABLED = False
 METRICS_ENABLED = False
@@ -214,14 +214,14 @@ RELEASE_CHECK_URL = None
 
 
 # The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of
 # The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of
 # this setting is derived from the installed location.
 # this setting is derived from the installed location.
-# REPORTS_ROOT = '/opt/netbox/netbox/reports'
+# REPORTS_ROOT = '/path/to/netbox/reports'
 
 
 # Maximum execution time for background tasks, in seconds.
 # Maximum execution time for background tasks, in seconds.
 RQ_DEFAULT_TIMEOUT = 300
 RQ_DEFAULT_TIMEOUT = 300
 
 
 # The file path where custom scripts will be stored. A trailing slash is not needed. Note that the default value of
 # The file path where custom scripts will be stored. A trailing slash is not needed. Note that the default value of
 # this setting is derived from the installed location.
 # this setting is derived from the installed location.
-# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
+# SCRIPTS_ROOT = '/path/to/netbox/scripts'
 
 
 # The name to use for the session cookie.
 # The name to use for the session cookie.
 SESSION_COOKIE_NAME = 'sessionid'
 SESSION_COOKIE_NAME = 'sessionid'

+ 10 - 1
netbox/netbox/filtersets.py

@@ -108,9 +108,18 @@ class BaseFilterSet(django_filters.FilterSet):
         # Apply any referenced SavedFilters
         # Apply any referenced SavedFilters
         if data and ('filter' in data or 'filter_id' in data):
         if data and ('filter' in data or 'filter_id' in data):
             data = data.copy()  # Get a mutable copy
             data = data.copy()  # Get a mutable copy
+
+            # Coerce filter_id values to integers, ignoring any which are not valid (see #22568)
+            filter_ids = []
+            for f_id in data.pop('filter_id', []):
+                try:
+                    filter_ids.append(int(f_id))
+                except (ValueError, TypeError):
+                    pass
+
             saved_filters = SavedFilter.objects.filter(
             saved_filters = SavedFilter.objects.filter(
                 Q(slug__in=data.pop('filter', [])) |
                 Q(slug__in=data.pop('filter', [])) |
-                Q(pk__in=data.pop('filter_id', []))
+                Q(pk__in=filter_ids)
             )
             )
             for sf in saved_filters:
             for sf in saved_filters:
                 for key, value in sf.parameters.items():
                 for key, value in sf.parameters.items():

+ 88 - 0
netbox/netbox/scaffold.py

@@ -0,0 +1,88 @@
+"""`netbox setup`: create the local configuration files for a pip-installed NetBox instance.
+
+Runs before Django or a local configuration exists (dispatched from the `netbox` console
+script, netbox.cli). Scaffolds conf/__init__.py and conf/configuration.py (copied verbatim
+from the bundled configuration_example.py template) and an empty local_requirements.txt,
+then copies the bundled deployment examples (gunicorn, systemd units, nginx, apache, uwsgi,
+netbox.env) unmodified into <target>/contrib/. Nothing is generated or rewritten; adapting
+and installing the examples (paths, systemd, the web server) remains the administrator's
+job. Existing files are never overwritten.
+"""
+
+import argparse
+import sys
+from importlib.resources import files
+from pathlib import Path
+
+
+def _bundled_data_dir():
+    return files('netbox') / '_data'
+
+
+def _config_template():
+    return files('netbox') / 'configuration_example.py'
+
+
+def _contrib_dir():
+    return files('netbox') / '_data' / 'contrib'
+
+
+def _write(destination, data):
+    if destination.exists():
+        print(f'Skipping existing {destination}')
+        return False
+    destination.parent.mkdir(parents=True, exist_ok=True)
+    destination.write_bytes(data)
+    print(f'Wrote {destination}')
+    return True
+
+
+def scaffold_instance(target):
+    target = Path(target)
+    conf = target / 'conf'
+    items = [
+        (conf / '__init__.py', b''),
+        (conf / 'configuration.py', _config_template().read_bytes()),
+        # An empty local_requirements.txt makes the optional plugin step discoverable and
+        # the documented "pip install -r local_requirements.txt" safe.
+        (target / 'local_requirements.txt', b''),
+        # Bundled deployment examples, copied byte-verbatim; adapting them is the admin's job.
+        # is_file() skips __pycache__: pip's post-install bytecode compilation of gunicorn.py
+        # (the only .py file among the examples) leaves one alongside the real files.
+        *(
+            (target / 'contrib' / example.name, example.read_bytes())
+            for example in sorted(_contrib_dir().iterdir(), key=lambda entry: entry.name)
+            if example.is_file()
+        ),
+    ]
+    return [str(destination) for destination, data in items if _write(destination, data)]
+
+
+def main(argv=None, *, prog='netbox setup'):
+    parser = argparse.ArgumentParser(
+        prog=prog,
+        description=(
+            'Create the local configuration files for a pip-installed NetBox instance '
+            '(conf/configuration.py copied verbatim from the bundled template, plus an empty '
+            'local_requirements.txt), and copy the bundled deployment examples (gunicorn, '
+            'systemd units, nginx, apache, uwsgi, netbox.env) unmodified into '
+            "<target>/contrib/. Nothing is generated or rewritten; adapting and installing "
+            "the examples is the administrator's job. Existing files are never overwritten."
+        ),
+    )
+    parser.add_argument('--target', default='/opt/netbox', help='NetBox instance root (NETBOX_ROOT).')
+    args = parser.parse_args(argv)
+
+    if not _bundled_data_dir().is_dir():
+        print(
+            f'{prog}: this NetBox installation does not include the bundled package data. '
+            'This command is available only from the installed netbox package (pip/wheel); '
+            'for an archive or Git installation, follow the standard installation guide instead.',
+            file=sys.stderr,
+        )
+        return 1
+    if not Path(args.target).is_absolute():
+        parser.error(f"--target must be an absolute path (got '{args.target}')")
+
+    scaffold_instance(args.target)
+    return 0

+ 33 - 20
netbox/netbox/settings.py

@@ -18,6 +18,7 @@ from netbox.config import PARAMS as CONFIG_PARAMS
 from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
 from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
 from netbox.plugins import PluginConfig
 from netbox.plugins import PluginConfig
 from netbox.registry import registry
 from netbox.registry import registry
+from netbox.settings_utils import get_configuration_dir, load_configuration, resolve_install_paths, secret_key_hint
 from utilities.release import load_release_data
 from utilities.release import load_release_data
 from utilities.security import validate_peppers
 from utilities.security import validate_peppers
 from utilities.string import trailing_slash
 from utilities.string import trailing_slash
@@ -30,8 +31,18 @@ from .monkey import get_unique_validators
 
 
 RELEASE = load_release_data()
 RELEASE = load_release_data()
 VERSION = RELEASE.full_version  # Retained for backward compatibility
 VERSION = RELEASE.full_version  # Retained for backward compatibility
-# Set the base directory two levels up
-BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+# Settings package directory (settings.py lives here in both checkout & wheel).
+_SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__))
+
+# All wheel-vs-checkout path branching is centralized in resolve_install_paths(): a wheel
+# bundles package data under netbox/_data and keeps mutable instance files under an external
+# instance root (NETBOX_ROOT, default /opt/netbox); a checkout keeps both roots as the project
+# directory, so archive/git behavior is unchanged.
+_PATHS = resolve_install_paths(_SETTINGS_DIR, os.environ)
+NETBOX_INSTALL_MODE = _PATHS.install_mode
+BASE_DIR = _PATHS.base_dir
+# Instance root for wheel installs (holds conf/, media/, reports/, scripts/, static/, units).
+NETBOX_ROOT = _PATHS.netbox_root
 
 
 # Validate the Python version
 # Validate the Python version
 if sys.version_info < (3, 12):  # noqa: UP036
 if sys.version_info < (3, 12):  # noqa: UP036
@@ -43,17 +54,15 @@ if sys.version_info < (3, 12):  # noqa: UP036
 # Configuration import
 # Configuration import
 #
 #
 
 
-# Import the configuration module
-config_path = os.getenv('NETBOX_CONFIGURATION', 'netbox.configuration')
-try:
-    configuration = importlib.import_module(config_path)
-except ModuleNotFoundError as e:
-    if getattr(e, 'name') == config_path:
-        raise ImproperlyConfigured(
-            f"Specified configuration module ({config_path}) not found. Please define netbox/netbox/configuration.py "
-            f"per the documentation, or specify an alternate module in the NETBOX_CONFIGURATION environment variable."
-        )
-    raise
+# Import the configuration module (wheel mode prefers NETBOX_ROOT/conf/configuration.py).
+configuration = load_configuration(
+    install_mode=NETBOX_INSTALL_MODE,
+    install_root=NETBOX_ROOT,
+    environ=os.environ,
+)
+
+# The directory holding the active configuration.py; ldap_config.py lives beside it.
+CONFIGURATION_DIR = get_configuration_dir(configuration)
 
 
 # Check for missing/conflicting required configuration parameters
 # Check for missing/conflicting required configuration parameters
 for parameter in ('ALLOWED_HOSTS', 'SECRET_KEY', 'REDIS'):
 for parameter in ('ALLOWED_HOSTS', 'SECRET_KEY', 'REDIS'):
@@ -119,7 +128,7 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
     'users.delete_token': ({'user': '$user'},),
     'users.delete_token': ({'user': '$user'},),
 })
 })
 DEVELOPER = getattr(configuration, 'DEVELOPER', False)
 DEVELOPER = getattr(configuration, 'DEVELOPER', False)
-DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
+DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', _PATHS.docs_root)
 EMAIL = getattr(configuration, 'EMAIL', {})
 EMAIL = getattr(configuration, 'EMAIL', {})
 STREAMING_EXPORTS = getattr(configuration, 'STREAMING_EXPORTS', False)
 STREAMING_EXPORTS = getattr(configuration, 'STREAMING_EXPORTS', False)
 EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', [
 EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', [
@@ -150,7 +159,7 @@ LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True)
 LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
 LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
 LOGIN_FORM_HIDDEN = getattr(configuration, 'LOGIN_FORM_HIDDEN', False)
 LOGIN_FORM_HIDDEN = getattr(configuration, 'LOGIN_FORM_HIDDEN', False)
 LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
 LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
-MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
+MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(NETBOX_ROOT, 'media')).rstrip('/')
 METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
 METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
 PLUGINS = getattr(configuration, 'PLUGINS', [])
 PLUGINS = getattr(configuration, 'PLUGINS', [])
 PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
 PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
@@ -175,7 +184,7 @@ REMOTE_AUTH_USER_EMAIL = getattr(configuration, 'REMOTE_AUTH_USER_EMAIL', 'HTTP_
 REMOTE_AUTH_USER_FIRST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_FIRST_NAME', 'HTTP_REMOTE_USER_FIRST_NAME')
 REMOTE_AUTH_USER_FIRST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_FIRST_NAME', 'HTTP_REMOTE_USER_FIRST_NAME')
 REMOTE_AUTH_USER_LAST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_LAST_NAME', 'HTTP_REMOTE_USER_LAST_NAME')
 REMOTE_AUTH_USER_LAST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_LAST_NAME', 'HTTP_REMOTE_USER_LAST_NAME')
 # Required by extras/migrations/0109_script_models.py
 # Required by extras/migrations/0109_script_models.py
-REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
+REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(NETBOX_ROOT, 'reports')).rstrip('/')
 RQ = getattr(configuration, 'RQ', {})
 RQ = getattr(configuration, 'RQ', {})
 if 'WORKER_CLASS' in RQ and RQ['WORKER_CLASS'] != 'utilities.rqworker.NetBoxRQWorker':
 if 'WORKER_CLASS' in RQ and RQ['WORKER_CLASS'] != 'utilities.rqworker.NetBoxRQWorker':
     warnings.warn(
     warnings.warn(
@@ -187,7 +196,7 @@ else:
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
 RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
 RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
 RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
-SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
+SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(NETBOX_ROOT, 'scripts')).rstrip('/')
 SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
 SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
 SECRET_KEY = getattr(configuration, 'SECRET_KEY')  # Required
 SECRET_KEY = getattr(configuration, 'SECRET_KEY')  # Required
 SECURE_HSTS_INCLUDE_SUBDOMAINS = getattr(configuration, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', False)
 SECURE_HSTS_INCLUDE_SUBDOMAINS = getattr(configuration, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', False)
@@ -224,7 +233,7 @@ if type(SECRET_KEY) is not str:
 if len(SECRET_KEY) < 50:
 if len(SECRET_KEY) < 50:
     raise ImproperlyConfigured(
     raise ImproperlyConfigured(
         f"SECRET_KEY must be at least 50 characters in length. To generate a suitable key, run the following command:\n"
         f"SECRET_KEY must be at least 50 characters in length. To generate a suitable key, run the following command:\n"
-        f"  python {BASE_DIR}/generate_secret_key.py"
+        f"  {secret_key_hint(NETBOX_INSTALL_MODE, BASE_DIR)}"
     )
     )
 
 
 # Validate API token peppers
 # Validate API token peppers
@@ -592,13 +601,16 @@ USE_X_FORWARDED_HOST = True
 X_FRAME_OPTIONS = 'SAMEORIGIN'
 X_FRAME_OPTIONS = 'SAMEORIGIN'
 
 
 # Static files (CSS, JavaScript, Images)
 # Static files (CSS, JavaScript, Images)
-STATIC_ROOT = BASE_DIR + '/static'
+# STATIC_ROOT is deliberately not a configuration parameter; static files are collected to <NETBOX_ROOT>/static.
+STATIC_ROOT = os.path.join(NETBOX_ROOT, 'static')
 STATIC_URL = f'/{BASE_PATH}static/'
 STATIC_URL = f'/{BASE_PATH}static/'
 STATICFILES_DIRS = (
 STATICFILES_DIRS = (
     os.path.join(BASE_DIR, 'project-static', 'dist'),
     os.path.join(BASE_DIR, 'project-static', 'dist'),
     os.path.join(BASE_DIR, 'project-static', 'img'),
     os.path.join(BASE_DIR, 'project-static', 'img'),
     os.path.join(BASE_DIR, 'project-static', 'js'),
     os.path.join(BASE_DIR, 'project-static', 'js'),
-    ('docs', os.path.join(BASE_DIR, 'project-static', 'docs')),  # Prefix with /docs
+    # May not exist on a checkout until `manage.py upgrade --build-docs` runs (wheels bundle
+    # the pre-rendered site); collectstatic tolerates that.
+    ('docs', _PATHS.static_docs_root),  # Prefix with /docs
 )
 )
 
 
 # Media URL
 # Media URL
@@ -874,6 +886,7 @@ LANGUAGES = (
     ('fr', _('French')),
     ('fr', _('French')),
     ('it', _('Italian')),
     ('it', _('Italian')),
     ('ja', _('Japanese')),
     ('ja', _('Japanese')),
+    ('ko', _('Korean')),
     ('lv', _('Latvian')),
     ('lv', _('Latvian')),
     ('nl', _('Dutch')),
     ('nl', _('Dutch')),
     ('pl', _('Polish')),
     ('pl', _('Polish')),

+ 204 - 0
netbox/netbox/settings_utils.py

@@ -0,0 +1,204 @@
+"""Startup helpers for settings.py. Import-safe: no Django settings access at import time."""
+
+import importlib
+import importlib.util
+import os
+import sys
+import warnings
+from typing import NamedTuple
+
+from django.core.exceptions import ImproperlyConfigured
+
+__all__ = (
+    'InstallPaths',
+    'get_configuration_dir',
+    'load_configuration',
+    'load_ldap_config',
+    'resolve_install_paths',
+    'secret_key_hint',
+)
+
+
+class InstallPaths(NamedTuple):
+    """Filesystem layout resolved from the install mode (wheel vs. source checkout)."""
+    install_mode: str      # 'wheel' or 'checkout'
+    base_dir: str          # package data root (BASE_DIR)
+    netbox_root: str       # instance root for mutable files (NETBOX_ROOT)
+    docs_root: str         # documentation sources on a checkout, the pre-rendered site in a wheel (DOCS_ROOT default)
+    static_docs_root: str  # built documentation, source of the STATICFILES 'docs' prefix
+
+
+def resolve_install_paths(settings_dir, environ):
+    """Resolve the install mode and filesystem roots for this NetBox installation.
+
+    A wheel bundles package data (including the pre-rendered documentation site)
+    under netbox/_data and keeps mutable instance files under an external instance root
+    (NETBOX_ROOT, default /opt/netbox); a source checkout keeps the historical layout,
+    where both roots are the project directory. All wheel-vs-checkout branching lives
+    here so settings.py stays declarative.
+    """
+    bundled_data = os.path.join(settings_dir, '_data')
+    if os.path.isdir(bundled_data):
+        install_mode = 'wheel'
+        base_dir = bundled_data
+        netbox_root = os.path.abspath(environ.get('NETBOX_ROOT', '/opt/netbox'))
+        docs_root = os.path.join(base_dir, 'docs')
+        # The wheel bundles the pre-rendered documentation site at _data/docs; it serves as
+        # both the DOCS_ROOT default and the STATICFILES 'docs' prefix source.
+        static_docs_root = docs_root
+    else:
+        install_mode = 'checkout'
+        base_dir = os.path.dirname(settings_dir)
+        netbox_root = base_dir
+        docs_root = os.path.join(os.path.dirname(base_dir), 'docs')
+        static_docs_root = os.path.join(base_dir, 'project-static', 'docs')
+    return InstallPaths(
+        install_mode=install_mode,
+        base_dir=base_dir,
+        netbox_root=netbox_root,
+        docs_root=docs_root,
+        static_docs_root=static_docs_root,
+    )
+
+
+def secret_key_hint(install_mode, base_dir):
+    """Return the command to suggest in the SECRET_KEY-too-short error, based on install mode.
+
+    generate_secret_key.py is not packaged in a wheel, so a wheel install points at the
+    `netbox secret-key` console command instead of the (nonexistent) script path.
+    """
+    if install_mode == 'wheel':
+        return 'netbox secret-key'
+    return f'python {base_dir}/generate_secret_key.py'
+
+
+def _import_module(name):
+    """Import a configuration module by dotted path.
+
+    Preserve NetBox's historical behavior: a friendly ImproperlyConfigured when the module
+    itself is absent, but re-raise the original error when the module exists yet imports
+    something else that is missing.
+    """
+    try:
+        return importlib.import_module(name)
+    except ModuleNotFoundError as e:
+        if e.name == name:
+            raise ImproperlyConfigured(
+                f"Specified configuration module ({name}) not found. Please define "
+                f"netbox/netbox/configuration.py per the documentation, or specify an alternate "
+                f"module in the NETBOX_CONFIGURATION environment variable."
+            )
+        raise
+
+
+def _import_from_path(module_name, 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.
+    """
+    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
+
+
+def get_configuration_dir(module):
+    """Return the directory containing a loaded configuration module (None if unknown)."""
+    source = getattr(module, '__file__', None)
+    return os.path.dirname(os.path.abspath(source)) if source else None
+
+
+def load_configuration(*, install_mode, install_root, environ):
+    """Import and return NetBox's configuration module.
+
+    An explicit NETBOX_CONFIGURATION module always wins. In wheel mode, prefer
+    <install_root>/conf/configuration.py, loaded by file path (so a stale source tree at
+    <install_root>/netbox cannot shadow it and no generic 'configuration' module is left in
+    sys.modules), then
+    fall back to the legacy <install_root>/netbox/netbox/configuration.py with a migration
+    warning. In checkout mode, keep the historical default module.
+    """
+    explicit = environ.get('NETBOX_CONFIGURATION')
+    if explicit:
+        return _import_module(explicit)
+
+    if install_mode == 'wheel':
+        conf_dir = os.path.join(install_root, 'conf')
+        preferred = os.path.join(conf_dir, 'configuration.py')
+        legacy = os.path.join(install_root, 'netbox', 'netbox', 'configuration.py')
+        if os.path.isfile(preferred):
+            if os.path.isfile(legacy):
+                warnings.warn(
+                    f"Both {preferred} and the legacy {legacy} exist; using {preferred} and "
+                    f"ignoring the legacy file.",
+                    RuntimeWarning,
+                )
+            return _import_from_path('netbox_local_configuration', preferred)
+        if os.path.isfile(legacy):
+            warnings.warn(
+                f"Loaded NetBox configuration from the legacy source-tree path {legacy}. For a "
+                f"pip-installed NetBox, move it to {preferred}.",
+                RuntimeWarning,
+            )
+            return _import_from_path('netbox_legacy_configuration', legacy)
+        raise ImproperlyConfigured(
+            f"No NetBox configuration found. For a pip-installed NetBox, create {preferred}, "
+            f"or set NETBOX_CONFIGURATION to an importable module."
+        )
+
+    return _import_module('netbox.configuration')
+
+
+def load_ldap_config(config_dir, *, allow_legacy_fallback=False):
+    """Load ldap_config.py from the active configuration directory (settings.CONFIGURATION_DIR).
+
+    One rule for every install method: the active ldap_config.py is the one next to the
+    active configuration.py. Checkout installs may additionally allow a legacy fallback to
+    the historical netbox/netbox/ldap_config.py module, because a custom NETBOX_CONFIGURATION
+    can live outside the source tree while LDAP config stayed inside it; the fallback warns
+    so those installs can migrate to the sibling rule.
+    """
+    path = os.path.join(config_dir, 'ldap_config.py') if config_dir else None
+    if path and os.path.isfile(path):
+        return _import_from_path('netbox.ldap_config', path)
+    if allow_legacy_fallback:
+        try:
+            module = importlib.import_module('netbox.ldap_config')
+        except ModuleNotFoundError as e:
+            if e.name != 'netbox.ldap_config':
+                raise
+        else:
+            warnings.warn(
+                "Loaded LDAP configuration from the legacy netbox/netbox/ldap_config.py module. "
+                "Move ldap_config.py into the directory containing the active configuration.py; "
+                "this fallback may be removed in a future release.",
+                RuntimeWarning,
+            )
+            return module
+    if not config_dir:
+        raise ImproperlyConfigured(
+            "LDAP configuration file not found: unable to determine the directory containing "
+            "configuration.py."
+        )
+    raise ImproperlyConfigured(
+        "LDAP configuration file not found: Check that ldap_config.py has been created "
+        "alongside configuration.py. For a pip-installed NetBox, this is "
+        "NETBOX_ROOT/conf/ldap_config.py."
+    )

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

@@ -6,8 +6,9 @@ from django.urls import reverse
 from rest_framework.exceptions import ValidationError
 from rest_framework.exceptions import ValidationError
 from rest_framework.request import Request
 from rest_framework.request import Request
 
 
+from dcim.api.serializers import RackSerializer
 from netbox.api.exceptions import QuerySetNotOrdered
 from netbox.api.exceptions import QuerySetNotOrdered
-from netbox.api.fields import IntegerRangeSerializer
+from netbox.api.fields import IntegerRangeSerializer, RelatedObjectCountField
 from netbox.api.pagination import NetBoxPagination
 from netbox.api.pagination import NetBoxPagination
 from users.models import Token
 from users.models import Token
 from utilities.testing import APITestCase
 from utilities.testing import APITestCase
@@ -48,6 +49,29 @@ class AppTestCase(APITestCase):
         self.assertEqual(response.data['id'], self.user.pk)
         self.assertEqual(response.data['id'], self.user.pk)
 
 
 
 
+class RelatedObjectCountFieldTestCase(TestCase):
+    """
+    RelatedObjectCountFields are populated by annotations applied to a viewset's queryset, which are only
+    added when serializing an object via its own endpoint (including ?brief=1). They are never annotated when
+    the object is rendered as a nested related object, so they must be omitted from nested representations to
+    keep the generated OpenAPI schema honest. See #22154.
+    """
+    def test_count_field_omitted_when_nested(self):
+        """A nested serializer must drop RelatedObjectCountFields (e.g. RackSerializer.device_count)."""
+        serializer = RackSerializer(nested=True)
+        count_fields = [
+            name for name, field in serializer.fields.items() if isinstance(field, RelatedObjectCountField)
+        ]
+        self.assertEqual(count_fields, [])
+        self.assertNotIn('device_count', serializer.fields)
+
+    def test_count_field_retained_in_brief_mode(self):
+        """?brief=1 (fields=brief_fields, not nested) must retain RelatedObjectCountFields."""
+        serializer = RackSerializer(fields=RackSerializer.Meta.brief_fields)
+        self.assertIn('device_count', serializer.fields)
+        self.assertIsInstance(serializer.fields['device_count'], RelatedObjectCountField)
+
+
 class NetBoxPaginationTestCase(TestCase):
 class NetBoxPaginationTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):

+ 92 - 2
netbox/netbox/tests/test_authentication.py

@@ -1,5 +1,7 @@
 import datetime
 import datetime
-from unittest.mock import MagicMock
+import sys
+from types import ModuleType
+from unittest.mock import MagicMock, patch
 
 
 from django.conf import settings
 from django.conf import settings
 from django.contrib.messages.storage.fallback import FallbackStorage
 from django.contrib.messages.storage.fallback import FallbackStorage
@@ -9,8 +11,11 @@ from django.urls import reverse
 from rest_framework.test import APIClient
 from rest_framework.test import APIClient
 from social_core.exceptions import AuthFailed
 from social_core.exceptions import AuthFailed
 
 
-from core.models import ObjectType
+from core.choices import ManagedFileRootPathChoices
+from core.models import ManagedFile, ObjectType
 from dcim.models import Rack, Site
 from dcim.models import Rack, Site
+from extras.models import ScriptModule
+from netbox.authentication import LDAPBackend
 from netbox.authentication.misc import _mirror_groups
 from netbox.authentication.misc import _mirror_groups
 from netbox.middleware import SocialAuthExceptionMiddleware
 from netbox.middleware import SocialAuthExceptionMiddleware
 from users.constants import TOKEN_PREFIX
 from users.constants import TOKEN_PREFIX
@@ -560,6 +565,43 @@ class LDAPMirrorGroupsTestCase(TestCase):
         )
         )
 
 
 
 
+class LDAPBackendTest(SimpleTestCase):
+    """The LDAP backend reads ldap_config.py from the active configuration directory."""
+
+    def test_backend_loads_ldap_config_from_configuration_dir(self):
+        with override_settings(CONFIGURATION_DIR='/srv/netbox/conf', NETBOX_INSTALL_MODE='checkout'):
+            backend, loader = self._build_backend()
+        loader.assert_called_once_with('/srv/netbox/conf', allow_legacy_fallback=True)
+        self.assertEqual(backend.settings.SERVER_URI, 'ldaps://example')
+
+    def test_backend_disables_legacy_fallback_for_wheel_installs(self):
+        with override_settings(CONFIGURATION_DIR='/opt/netbox/conf', NETBOX_INSTALL_MODE='wheel'):
+            backend, loader = self._build_backend()
+        loader.assert_called_once_with('/opt/netbox/conf', allow_legacy_fallback=False)
+        self.assertEqual(backend.settings.SERVER_URI, 'ldaps://example')
+
+    def _build_backend(self):
+        fake_ldap = ModuleType('ldap')
+        fake_ldap.set_option = MagicMock()
+        backend_module = ModuleType('django_auth_ldap.backend')
+        backend_module.LDAPSettings = type('LDAPSettings', (), {'_prefix': 'AUTH_LDAP_'})
+        package = ModuleType('django_auth_ldap')
+        package.backend = backend_module
+        ldap_config = ModuleType('netbox.ldap_config')
+        ldap_config.AUTH_LDAP_SERVER_URI = 'ldaps://example'
+        with (
+            patch.dict(sys.modules, {
+                'ldap': fake_ldap,
+                'django_auth_ldap': package,
+                'django_auth_ldap.backend': backend_module,
+            }),
+            patch('netbox.authentication.NBLDAPBackend', MagicMock(), create=True),
+            patch('netbox.authentication.load_ldap_config', return_value=ldap_config) as loader,
+        ):
+            backend = LDAPBackend()
+        return backend, loader
+
+
 class ObjectPermissionAPIViewTestCase(TestCase):
 class ObjectPermissionAPIViewTestCase(TestCase):
     client_class = APIClient
     client_class = APIClient
 
 
@@ -745,6 +787,54 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         self.assertEqual(response.status_code, 204)
         self.assertEqual(response.status_code, 204)
 
 
 
 
+class ObjectPermissionProxyModelTestCase(TestCase):
+    """
+    Object-level permission checks against proxy models (e.g. extras.ScriptModule proxying
+    core.ManagedFile) must evaluate the permission rather than raise ValueError.
+    """
+    @classmethod
+    def setUpTestData(cls):
+        cls.managed_file = ManagedFile.objects.create(
+            file_root=ManagedFileRootPathChoices.SCRIPTS,
+            file_path='proxy_permission_test.py'
+        )
+        cls.script_module = ScriptModule.objects.get(pk=cls.managed_file.pk)
+
+    def _grant_scriptmodule_permission(self, constraints=None):
+        obj_perm = ObjectPermission(name='ScriptModule change', actions=['change'], constraints=constraints)
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(ScriptModule, for_concrete_model=False))
+
+    def test_has_perm_cross_app_proxy_model(self):
+        """An unconstrained proxy-model permission grants access to a proxy instance."""
+        self._grant_scriptmodule_permission()
+        self.assertTrue(self.user.has_perm('extras.change_scriptmodule', self.script_module))
+
+    def test_has_perm_cross_app_proxy_model_matching_constraints(self):
+        """A constrained proxy-model permission grants access when the instance matches."""
+        self._grant_scriptmodule_permission(constraints={'file_path': 'proxy_permission_test.py'})
+        self.assertTrue(self.user.has_perm('extras.change_scriptmodule', self.script_module))
+
+    def test_has_perm_cross_app_proxy_model_nonmatching_constraints(self):
+        """A constrained proxy-model permission denies access when the instance does not match."""
+        self._grant_scriptmodule_permission(constraints={'file_path': 'other.py'})
+        self.assertFalse(self.user.has_perm('extras.change_scriptmodule', self.script_module))
+
+    def test_has_perm_invalid_permission_object_pair(self):
+        """A permission checked against an object of an unrelated model denies instead of raising."""
+        site = Site.objects.create(name='Proxy Test Site', slug='proxy-test-site')
+        self._grant_scriptmodule_permission()
+        self.assertFalse(self.user.has_perm('extras.change_scriptmodule', site))
+
+    @override_settings(DEFAULT_PERMISSIONS={'extras.change_nosuchmodel': None})
+    def test_has_perm_unknown_model_permission(self):
+        """A permission naming a nonexistent model denies and logs a warning instead of raising."""
+        self._grant_scriptmodule_permission()
+        with self.assertLogs('netbox.auth.ObjectPermissionBackend', level='WARNING'):
+            self.assertFalse(self.user.has_perm('extras.change_nosuchmodel', self.script_module))
+
+
 class SocialAuthExceptionMiddlewareTestCase(SimpleTestCase):
 class SocialAuthExceptionMiddlewareTestCase(SimpleTestCase):
     """
     """
     Verify that SSO/SAML authentication failures are surfaced as a login-page message rather than
     Verify that SSO/SAML authentication failures are surfaced as a login-page message rather than

+ 131 - 0
netbox/netbox/tests/test_cli.py

@@ -0,0 +1,131 @@
+from contextlib import redirect_stderr, redirect_stdout
+from io import StringIO
+from unittest.mock import patch
+
+from django.test import SimpleTestCase
+
+from netbox import cli
+
+
+class CliDispatchTest(SimpleTestCase):
+    """The console script resolves pre-configuration commands before importing Django."""
+
+    def _main(self, args, argv0='netbox'):
+        # prog derives from sys.argv[0] even when argv is passed explicitly, so pin both.
+        stdout, stderr = StringIO(), StringIO()
+        with (
+            patch.object(cli.sys, 'argv', [argv0, *args]),
+            patch('django.core.management.execute_from_command_line') as execute,
+            redirect_stdout(stdout), redirect_stderr(stderr),
+        ):
+            rc = cli.main(args)
+        return rc, execute, stdout.getvalue(), stderr.getvalue()
+
+    def test_version_flag_prints_version_without_django(self):
+        with patch('netbox.cli.version', return_value='4.7.0b1'):
+            rc, execute, out, _ = self._main(['--version'])
+        execute.assert_not_called()
+        self.assertEqual(out, '4.7.0b1\n')
+        self.assertEqual(rc, 0)
+
+    def test_version_command_prints_version_without_django(self):
+        with patch('netbox.cli.version', return_value='4.7.0b1'):
+            rc, execute, out, _ = self._main(['version'])
+        execute.assert_not_called()
+        self.assertEqual(out, '4.7.0b1\n')
+        self.assertEqual(rc, 0)
+
+    def test_version_help(self):
+        rc, execute, out, _ = self._main(['version', '--help'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 0)
+        self.assertIn('usage: netbox version', out)
+
+    def test_version_rejects_unexpected_arguments(self):
+        rc, execute, _, err = self._main(['version', 'bogus'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 2)
+        self.assertIn('unrecognized arguments: bogus', err)
+
+    def test_no_arguments_prints_help_without_django(self):
+        rc, execute, out, _ = self._main([])
+        execute.assert_not_called()
+        self.assertEqual(rc, 0)
+        self.assertIn('usage: netbox', out)
+        for command in ('version', 'setup', 'secret-key'):
+            self.assertIn(command, out)
+
+    def test_help_flags_print_help_without_django(self):
+        for flag in ('-h', '--help'):
+            with self.subTest(flag=flag):
+                rc, execute, out, _ = self._main([flag])
+                execute.assert_not_called()
+                self.assertEqual(rc, 0)
+                self.assertIn('usage: netbox', out)
+                for command in ('version', 'setup', 'secret-key'):
+                    self.assertIn(command, out)
+
+    def test_secret_key_prints_50_char_key_without_django(self):
+        rc, execute, out, _ = self._main(['secret-key'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 0)
+        self.assertEqual(len(out.strip()), 50)
+
+    def test_secret_key_help(self):
+        rc, execute, out, _ = self._main(['secret-key', '--help'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 0)
+        self.assertIn('usage: netbox secret-key', out)
+
+    def test_secret_key_rejects_unexpected_arguments(self):
+        rc, execute, _, err = self._main(['secret-key', 'bogus'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 2)
+        self.assertIn('unrecognized arguments: bogus', err)
+
+    def test_setup_dispatches_to_scaffold_with_prog(self):
+        with patch('netbox.scaffold.main', return_value=0) as setup_main:
+            rc, execute, _, _ = self._main(['setup', '--target', '/srv/netbox'])
+        execute.assert_not_called()
+        setup_main.assert_called_once_with(['--target', '/srv/netbox'], prog='netbox setup')
+        self.assertEqual(rc, 0)
+
+    def test_setup_return_code_propagates(self):
+        with patch('netbox.scaffold.main', return_value=3):
+            rc, execute, _, _ = self._main(['setup'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 3)
+
+    def test_other_commands_dispatch_to_django(self):
+        with (
+            patch.object(cli.sys, 'argv', ['/opt/netbox/venv/bin/netbox', 'check', '--deploy']),
+            patch.dict(cli.os.environ),
+            patch('django.core.management.execute_from_command_line') as execute,
+        ):
+            cli.os.environ.pop('DJANGO_SETTINGS_MODULE', None)
+            rc = cli.main(['check', '--deploy'])
+            self.assertEqual(cli.os.environ['DJANGO_SETTINGS_MODULE'], 'netbox.settings')
+        execute.assert_called_once_with(['netbox', 'check', '--deploy'])
+        self.assertEqual(rc, 0)
+
+    def test_subcommand_help_falls_through_to_django(self):
+        rc, execute, _, _ = self._main(['migrate', '--help'])
+        execute.assert_called_once_with(['netbox', 'migrate', '--help'])
+        self.assertEqual(rc, 0)
+
+    def test_prog_falls_back_for_python_m_invocation(self):
+        with patch('netbox.scaffold.main', return_value=0) as setup_main:
+            self._main(['setup'], argv0='/x/netbox/__main__.py')
+        setup_main.assert_called_once_with([], prog='netbox setup')
+
+    def test_prog_falls_back_when_argv_is_empty(self):
+        out = StringIO()
+        with (
+            patch.object(cli.sys, 'argv', []),
+            patch('django.core.management.execute_from_command_line') as execute,
+            redirect_stdout(out),
+        ):
+            rc = cli.main([])
+        execute.assert_not_called()
+        self.assertEqual(rc, 0)
+        self.assertIn('usage: netbox', out.getvalue())

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

@@ -3,7 +3,9 @@ import re
 
 
 import strawberry
 import strawberry
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.db import connection
 from django.test import override_settings
 from django.test import override_settings
+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
 from strawberry.extensions import QueryDepthLimiter
 from strawberry.extensions import QueryDepthLimiter
@@ -333,6 +335,63 @@ 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)
 
 
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_graphql_device_list_tags_are_prefetched(self):
+        """
+        Requesting tags on device_list must batch tag lookups (no N+1 per device).
+        """
+        self.add_permissions('dcim.view_device', 'extras.view_tag')
+
+        manufacturer = Manufacturer.objects.create(name='Prefetch Manufacturer', slug='prefetch-manufacturer')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer,
+            model='Prefetch Model',
+            slug='prefetch-model',
+        )
+        device_role = DeviceRole.objects.create(name='Prefetch Role', slug='prefetch-role')
+        site = Site.objects.first()
+        tag_alpha = Tag.objects.create(name='Prefetch Alpha', slug='prefetch-alpha')
+        tag_beta = Tag.objects.create(name='Prefetch Beta', slug='prefetch-beta')
+
+        devices = Device.objects.bulk_create([
+            Device(
+                name=f'Prefetch Device {index}',
+                device_type=device_type,
+                role=device_role,
+                site=site,
+            )
+            for index in range(10)
+        ])
+        for device in devices:
+            device.tags.set([tag_alpha, tag_beta])
+
+        query = """
+        {
+            device_list(filters: {role: {slug: {exact: "prefetch-role"}}}) {
+                name
+                tags {
+                    slug
+                }
+            }
+        }
+        """
+        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']['device_list']), 10)
+
+        tag_queries = sum(1 for query_record in context.captured_queries if 'extras_tag' in query_record['sql'])
+        self.assertLessEqual(
+            tag_queries,
+            2,
+            msg=f'Expected batched tag prefetch, got {tag_queries} tag queries for 10 devices',
+        )
+
     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')

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

@@ -0,0 +1,150 @@
+import contextlib
+import tempfile
+from io import StringIO
+from pathlib import Path
+from unittest.mock import patch
+
+from django.test import SimpleTestCase
+
+from netbox import scaffold
+
+_CONFIG_TEMPLATE_TEXT = (
+    "# example configuration\n"
+    "STORAGE_ROOT = '/opt/netbox/netbox/media'\n"
+    "NETBOX_ROOT = '/opt/netbox'\n"
+)
+_CONTRIB_FILENAMES = (
+    'apache.conf', 'gunicorn.py', 'netbox-rq.service', 'netbox.env', 'netbox.service', 'nginx.conf', 'uwsgi.ini',
+)
+
+
+class ScaffoldInstanceTest(SimpleTestCase):
+    def setUp(self):
+        tmp = tempfile.TemporaryDirectory()
+        self.addCleanup(tmp.cleanup)
+        root = Path(tmp.name)
+
+        bundled = root / '_data'
+        contrib_src = bundled / 'contrib'
+        contrib_src.mkdir(parents=True)
+        for name in _CONTRIB_FILENAMES:
+            (contrib_src / name).write_text(f'# {name} contents\n')
+        # A real install leaves __pycache__ here too: pip's post-install bytecode compilation
+        # runs on gunicorn.py, the only .py file among the examples.
+        (contrib_src / '__pycache__').mkdir()
+        self.contrib_src = contrib_src
+
+        template = root / 'configuration_example.py'
+        template.write_text(_CONFIG_TEMPLATE_TEXT)
+
+        self.target = root / 'opt'
+        self.target.mkdir()
+
+        self.enterContext(patch('netbox.scaffold._bundled_data_dir', return_value=bundled))
+        self.enterContext(patch('netbox.scaffold._config_template', return_value=template))
+        self.enterContext(patch('netbox.scaffold._contrib_dir', return_value=contrib_src))
+
+    def test_scaffolds_configuration_and_contrib_examples(self):
+        """A fresh target gets conf/__init__.py, conf/configuration.py, local_requirements.txt, and contrib/."""
+        written = scaffold.scaffold_instance(self.target)
+        self.assertEqual((self.target / 'conf' / '__init__.py').read_bytes(), b'')
+        self.assertEqual(
+            (self.target / 'conf' / 'configuration.py').read_bytes(),
+            _CONFIG_TEMPLATE_TEXT.encode('utf-8'),
+        )
+        self.assertEqual((self.target / 'local_requirements.txt').read_bytes(), b'')
+        for name in _CONTRIB_FILENAMES:
+            self.assertEqual(
+                (self.target / 'contrib' / name).read_bytes(),
+                (self.contrib_src / name).read_bytes(),
+            )
+        expected = [
+            str(self.target / 'conf' / '__init__.py'),
+            str(self.target / 'conf' / 'configuration.py'),
+            str(self.target / 'local_requirements.txt'),
+            *(str(self.target / 'contrib' / name) for name in _CONTRIB_FILENAMES),
+        ]
+        self.assertEqual(written, expected)
+        self.assertFalse((self.target / 'contrib' / '__pycache__').exists())
+
+    def test_never_clobbers_existing_configuration(self):
+        """An existing conf/configuration.py is left untouched."""
+        (self.target / 'conf').mkdir(parents=True)
+        (self.target / 'conf' / 'configuration.py').write_text('SECRET = 1')
+        written = scaffold.scaffold_instance(self.target)
+        self.assertEqual((self.target / 'conf' / 'configuration.py').read_text(), 'SECRET = 1')
+        self.assertNotIn(str(self.target / 'conf' / 'configuration.py'), written)
+
+    def test_never_clobbers_existing_conf_init(self):
+        """An existing conf/__init__.py is left untouched."""
+        (self.target / 'conf').mkdir(parents=True)
+        (self.target / 'conf' / '__init__.py').write_text('# user-owned\n')
+        written = scaffold.scaffold_instance(self.target)
+        self.assertEqual((self.target / 'conf' / '__init__.py').read_text(), '# user-owned\n')
+        self.assertNotIn(str(self.target / 'conf' / '__init__.py'), written)
+
+    def test_never_clobbers_existing_local_requirements(self):
+        """An existing local_requirements.txt is left untouched."""
+        (self.target / 'local_requirements.txt').write_text('django-auth-ldap\n')
+        written = scaffold.scaffold_instance(self.target)
+        self.assertEqual((self.target / 'local_requirements.txt').read_text(), 'django-auth-ldap\n')
+        self.assertNotIn(str(self.target / 'local_requirements.txt'), written)
+
+    def test_never_clobbers_existing_contrib_file(self):
+        """An existing <target>/contrib/<name> file is left untouched; siblings are still written."""
+        (self.target / 'contrib').mkdir(parents=True)
+        (self.target / 'contrib' / 'gunicorn.py').write_text('# edited\n')
+        written = scaffold.scaffold_instance(self.target)
+        self.assertEqual((self.target / 'contrib' / 'gunicorn.py').read_text(), '# edited\n')
+        self.assertNotIn(str(self.target / 'contrib' / 'gunicorn.py'), written)
+        self.assertIn(str(self.target / 'contrib' / 'nginx.conf'), written)
+
+    def test_main_uses_arguments(self):
+        """main() dispatches --target through to scaffold_instance."""
+        result = scaffold.main(['--target', str(self.target)])
+        self.assertEqual(result, 0)
+        self.assertEqual(
+            (self.target / 'conf' / 'configuration.py').read_bytes(),
+            _CONFIG_TEMPLATE_TEXT.encode('utf-8'),
+        )
+
+    def test_main_rejects_relative_target(self):
+        """A relative --target is rejected with rc 2."""
+        with (
+            contextlib.redirect_stderr(StringIO()),
+            self.assertRaises(SystemExit) as cm,
+        ):
+            scaffold.main(['--target', 'relative/path'])
+        self.assertEqual(cm.exception.code, 2)
+
+    def test_main_help_uses_netbox_setup_prog(self):
+        """--help shows the netbox setup prog name."""
+        out = StringIO()
+        with contextlib.redirect_stdout(out), self.assertRaises(SystemExit) as cm:
+            scaffold.main(['--help'])
+        self.assertEqual(cm.exception.code, 0)
+        self.assertIn('usage: netbox setup', out.getvalue())
+
+    def test_main_fails_friendly_without_bundled_data(self):
+        """Without bundled package data, main() reports rc 1 and a friendly stderr message."""
+        err = StringIO()
+        with (
+            patch('netbox.scaffold._bundled_data_dir', return_value=self.target / 'missing'),
+            contextlib.redirect_stderr(err),
+        ):
+            rc = scaffold.main(['--target', '/tmp/x'])
+        self.assertEqual(rc, 1)
+        self.assertIn('installed netbox package', err.getvalue())
+
+
+class ScaffoldResourceHelpersTest(SimpleTestCase):
+    """The unpatched resource helpers resolve real paths under the installed netbox package."""
+
+    def test_bundled_data_dir_resolves_under_package(self):
+        self.assertTrue(str(scaffold._bundled_data_dir()).endswith('_data'))
+
+    def test_config_template_resolves_under_package(self):
+        self.assertTrue(str(scaffold._config_template()).endswith('configuration_example.py'))
+
+    def test_contrib_dir_resolves_under_bundled_data(self):
+        self.assertTrue(str(scaffold._contrib_dir()).endswith('_data/contrib'))

+ 281 - 0
netbox/netbox/tests/test_settings_utils.py

@@ -0,0 +1,281 @@
+import os
+import sys
+import tempfile
+from types import ModuleType
+from unittest.mock import patch
+
+from django.conf import settings as django_settings
+from django.core.exceptions import ImproperlyConfigured
+from django.test import SimpleTestCase
+
+from netbox import settings_utils
+
+
+class LoadConfigurationTest(SimpleTestCase):
+    def test_explicit_module_wins(self):
+        with patch('netbox.settings_utils.importlib.import_module') as import_module:
+            settings_utils.load_configuration(
+                install_mode='wheel', install_root='/opt/netbox',
+                environ={'NETBOX_CONFIGURATION': 'my.config'},
+            )
+        import_module.assert_called_once_with('my.config')
+
+    def test_checkout_uses_default_module(self):
+        with patch('netbox.settings_utils.importlib.import_module') as import_module:
+            settings_utils.load_configuration(
+                install_mode='checkout', install_root='/repo', environ={},
+            )
+        import_module.assert_called_once_with('netbox.configuration')
+
+    def test_checkout_missing_module_raises_improperly_configured(self):
+        with patch(
+            'netbox.settings_utils.importlib.import_module',
+            side_effect=ModuleNotFoundError("No module named 'netbox.configuration'", name='netbox.configuration'),
+        ):
+            with self.assertRaises(ImproperlyConfigured):
+                settings_utils.load_configuration(
+                    install_mode='checkout', install_root='/repo', environ={},
+                )
+
+    def test_wheel_prefers_conf_dir(self):
+        with tempfile.TemporaryDirectory() as root:
+            conf = os.path.join(root, 'conf')
+            os.mkdir(conf)
+            preferred = os.path.join(conf, 'configuration.py')
+            open(preferred, 'w').close()
+            saved = list(sys.path)
+            try:
+                with patch('netbox.settings_utils._import_from_path') as import_from_path:
+                    settings_utils.load_configuration(
+                        install_mode='wheel', install_root=root, environ={},
+                    )
+                import_from_path.assert_called_once_with('netbox_local_configuration', preferred)
+                self.assertEqual(sys.path, saved)
+            finally:
+                sys.path[:] = saved
+
+    def test_wheel_falls_back_to_legacy_with_warning(self):
+        with tempfile.TemporaryDirectory() as root:
+            legacy_dir = os.path.join(root, 'netbox', 'netbox')
+            os.makedirs(legacy_dir)
+            legacy = os.path.join(legacy_dir, 'configuration.py')
+            open(legacy, 'w').close()
+            with (
+                patch('netbox.settings_utils._import_from_path') as importer,
+                self.assertWarns(RuntimeWarning),
+            ):
+                settings_utils.load_configuration(
+                    install_mode='wheel', install_root=root, environ={},
+                )
+            self.assertEqual(importer.call_args.args[1], legacy)
+
+    def test_wheel_missing_configuration_raises(self):
+        with tempfile.TemporaryDirectory() as root:
+            with self.assertRaisesMessage(ImproperlyConfigured, 'conf/configuration.py'):
+                settings_utils.load_configuration(
+                    install_mode='wheel', install_root=root, environ={},
+                )
+
+    def test_explicit_module_reraises_other_import_error(self):
+        # A missing dependency of the config module must propagate, not become a friendly error.
+        with patch(
+            'netbox.settings_utils.importlib.import_module',
+            side_effect=ModuleNotFoundError("No module named 'missing_dep'", name='missing_dep'),
+        ):
+            with self.assertRaises(ModuleNotFoundError):
+                settings_utils.load_configuration(
+                    install_mode='checkout', install_root='/repo',
+                    environ={'NETBOX_CONFIGURATION': 'my.config'},
+                )
+
+    def test_import_from_path_loads_module_and_restores_sys_path(self):
+        with tempfile.TemporaryDirectory() as root:
+            path = os.path.join(root, 'legacy_cfg.py')
+            with open(path, 'w') as handle:
+                handle.write('ALLOWED_HOSTS = ["example"]\n')
+            self.addCleanup(sys.modules.pop, 'netbox_test_legacy_cfg', None)
+            saved = list(sys.path)
+            module = settings_utils._import_from_path('netbox_test_legacy_cfg', path)
+            self.assertEqual(module.ALLOWED_HOSTS, ['example'])
+            self.assertEqual(sys.path, saved)
+            self.assertIs(sys.modules['netbox_test_legacy_cfg'], module)
+
+    def test_import_from_path_removes_module_on_failure(self):
+        with tempfile.TemporaryDirectory() as root:
+            path = os.path.join(root, 'broken_cfg.py')
+            with open(path, 'w') as handle:
+                handle.write('raise RuntimeError("Simulated configuration error")\n')
+            with self.assertRaisesMessage(RuntimeError, 'Simulated configuration error'):
+                settings_utils._import_from_path('netbox_test_broken_cfg', path)
+            self.assertNotIn('netbox_test_broken_cfg', sys.modules)
+
+    def test_import_from_path_rejects_unloadable_path(self):
+        # A suffix-less file yields no loader; the helper must fail cleanly.
+        with tempfile.TemporaryDirectory() as root:
+            path = os.path.join(root, 'noext')
+            open(path, 'w').close()
+            with self.assertRaisesMessage(ImproperlyConfigured, 'Unable to load'):
+                settings_utils._import_from_path('netbox_test_noext_cfg', path)
+
+    def test_import_from_path_preserves_preexisting_sys_path_entry(self):
+        # Only the index-0 entry this helper inserted is popped; a pre-existing entry survives.
+        with tempfile.TemporaryDirectory() as root:
+            path = os.path.join(root, 'preexisting_cfg.py')
+            with open(path, 'w') as handle:
+                handle.write('ALLOWED_HOSTS = ["example"]\n')
+            self.addCleanup(sys.modules.pop, 'netbox_test_preexisting_cfg', None)
+            saved = list(sys.path)
+            sys.path.append(root)
+            try:
+                settings_utils._import_from_path('netbox_test_preexisting_cfg', path)
+                self.assertEqual(sys.path, saved + [root])
+            finally:
+                sys.path[:] = saved
+
+    def test_wheel_both_configs_present_warns_and_prefers_conf(self):
+        with tempfile.TemporaryDirectory() as root:
+            conf = os.path.join(root, 'conf')
+            os.mkdir(conf)
+            preferred = os.path.join(conf, 'configuration.py')
+            open(preferred, 'w').close()
+            legacy_dir = os.path.join(root, 'netbox', 'netbox')
+            os.makedirs(legacy_dir)
+            open(os.path.join(legacy_dir, 'configuration.py'), 'w').close()
+            saved = list(sys.path)
+            try:
+                with (
+                    patch('netbox.settings_utils._import_from_path') as import_from_path,
+                    self.assertWarns(RuntimeWarning),
+                ):
+                    settings_utils.load_configuration(install_mode='wheel', install_root=root, environ={})
+                import_from_path.assert_called_once_with('netbox_local_configuration', preferred)
+            finally:
+                sys.path[:] = saved
+
+
+class ConfigurationDirTest(SimpleTestCase):
+    def test_returns_directory_of_module_file(self):
+        module = ModuleType('cfg')
+        module.__file__ = '/srv/netbox/conf/configuration.py'
+        self.assertEqual(settings_utils.get_configuration_dir(module), '/srv/netbox/conf')
+
+    def test_returns_none_without_file(self):
+        self.assertIsNone(settings_utils.get_configuration_dir(ModuleType('cfg')))
+
+
+class ResolveInstallPathsTest(SimpleTestCase):
+    """resolve_install_paths() centralizes wheel-vs-checkout filesystem layout decisions."""
+
+    def test_checkout_roots(self):
+        with tempfile.TemporaryDirectory() as root:
+            settings_dir = os.path.join(root, 'netbox', 'netbox')
+            os.makedirs(settings_dir)
+            base_dir = os.path.join(root, 'netbox')
+            paths = settings_utils.resolve_install_paths(settings_dir, {})
+            self.assertEqual(paths.install_mode, 'checkout')
+            self.assertEqual(paths.base_dir, base_dir)
+            self.assertEqual(paths.netbox_root, base_dir)
+            self.assertEqual(paths.docs_root, os.path.join(root, 'docs'))
+            self.assertEqual(paths.static_docs_root, os.path.join(base_dir, 'project-static', 'docs'))
+
+    def test_wheel_roots_default_netbox_root(self):
+        with tempfile.TemporaryDirectory() as root:
+            settings_dir = os.path.join(root, 'site-packages', 'netbox')
+            base_dir = os.path.join(settings_dir, '_data')
+            os.makedirs(base_dir)
+            paths = settings_utils.resolve_install_paths(settings_dir, {})
+            self.assertEqual(paths.install_mode, 'wheel')
+            self.assertEqual(paths.base_dir, base_dir)
+            self.assertEqual(paths.netbox_root, '/opt/netbox')
+            self.assertEqual(paths.docs_root, os.path.join(base_dir, 'docs'))
+            self.assertEqual(paths.static_docs_root, os.path.join(base_dir, 'docs'))
+
+    def test_netbox_root_env_override_is_abspathed(self):
+        with tempfile.TemporaryDirectory() as root:
+            settings_dir = os.path.join(root, 'site-packages', 'netbox')
+            os.makedirs(os.path.join(settings_dir, '_data'))
+            paths = settings_utils.resolve_install_paths(settings_dir, {'NETBOX_ROOT': 'relative/root'})
+            self.assertEqual(paths.netbox_root, os.path.abspath('relative/root'))
+
+
+class SecretKeyHintTest(SimpleTestCase):
+    """secret_key_hint() picks the SECRET_KEY-too-short hint by install mode."""
+
+    def test_wheel_mode_suggests_console_command(self):
+        self.assertEqual(settings_utils.secret_key_hint('wheel', '/opt/netbox/lib/netbox'), 'netbox secret-key')
+
+    def test_checkout_mode_suggests_generate_secret_key_script(self):
+        self.assertEqual(
+            settings_utils.secret_key_hint('checkout', '/repo/netbox'),
+            'python /repo/netbox/generate_secret_key.py',
+        )
+
+
+class LoadLdapConfigTest(SimpleTestCase):
+    def test_loads_sibling_ldap_config(self):
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with open(os.path.join(conf_dir, 'ldap_config.py'), 'w') as handle:
+                handle.write('AUTH_LDAP_SERVER_URI = "ldaps://example"\n')
+            self.addCleanup(sys.modules.pop, 'netbox.ldap_config', None)
+            module = settings_utils.load_ldap_config(conf_dir)
+            self.assertEqual(module.AUTH_LDAP_SERVER_URI, 'ldaps://example')
+            self.assertIs(sys.modules['netbox.ldap_config'], module)
+
+    def test_legacy_fallback_loads_historical_module_with_warning(self):
+        legacy = ModuleType('netbox.ldap_config')
+        legacy.AUTH_LDAP_SERVER_URI = 'ldaps://legacy'
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with patch.dict(sys.modules, {'netbox.ldap_config': legacy}), self.assertWarns(RuntimeWarning):
+                module = settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
+        self.assertIs(module, legacy)
+
+    def test_legacy_fallback_prefers_sibling_file(self):
+        legacy = ModuleType('netbox.ldap_config')
+        legacy.AUTH_LDAP_SERVER_URI = 'ldaps://legacy'
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with open(os.path.join(conf_dir, 'ldap_config.py'), 'w') as handle:
+                handle.write('AUTH_LDAP_SERVER_URI = "ldaps://sibling"\n')
+            with patch.dict(sys.modules, {'netbox.ldap_config': legacy}):
+                module = settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
+            self.assertEqual(module.AUTH_LDAP_SERVER_URI, 'ldaps://sibling')
+
+    def test_legacy_fallback_disabled_raises(self):
+        legacy = ModuleType('netbox.ldap_config')
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with patch.dict(sys.modules, {'netbox.ldap_config': legacy}):
+                with self.assertRaisesMessage(ImproperlyConfigured, 'alongside configuration.py'):
+                    settings_utils.load_ldap_config(conf_dir)
+
+    def test_legacy_fallback_missing_module_raises(self):
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with patch(
+                'netbox.settings_utils.importlib.import_module',
+                side_effect=ModuleNotFoundError("No module named 'netbox.ldap_config'", name='netbox.ldap_config'),
+            ):
+                with self.assertRaisesMessage(ImproperlyConfigured, 'alongside configuration.py'):
+                    settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
+
+    def test_legacy_fallback_reraises_broken_dependency(self):
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with patch(
+                'netbox.settings_utils.importlib.import_module',
+                side_effect=ModuleNotFoundError("No module named 'missing_dep'", name='missing_dep'),
+            ):
+                with self.assertRaises(ModuleNotFoundError):
+                    settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
+
+    def test_none_config_dir_raises(self):
+        with self.assertRaisesMessage(ImproperlyConfigured, 'unable to determine'):
+            settings_utils.load_ldap_config(None)
+
+    def test_missing_file_raises(self):
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with self.assertRaisesMessage(ImproperlyConfigured, 'ldap_config.py'):
+                settings_utils.load_ldap_config(conf_dir)
+
+    def test_configuration_dir_setting_matches_active_configuration(self):
+        from netbox import configuration_testing
+        self.assertEqual(
+            django_settings.CONFIGURATION_DIR,
+            os.path.dirname(os.path.abspath(configuration_testing.__file__)),
+        )

+ 35 - 11
netbox/netbox/views/generic/bulk_views.py

@@ -519,13 +519,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
     def _save_object(self, model_form, request, parent_idx):
     def _save_object(self, model_form, request, parent_idx):
         _action = 'Updated' if model_form.instance.pk else 'Created'
         _action = 'Updated' if model_form.instance.pk else 'Created'
 
 
-        # Save the primary object
+        # Save the primary object. Object-level permissions are enforced in aggregate by
+        # create_and_update_objects() once all records have been processed.
         obj = self.save_object(model_form, request)
         obj = self.save_object(model_form, request)
 
 
-        # Enforce object-level permissions
-        if not self.queryset.filter(pk=obj.pk).first():
-            raise PermissionsViolation()
-
         # Iterate through the related object forms (if any), validating and saving each instance.
         # Iterate through the related object forms (if any), validating and saving each instance.
         for field_name, related_object_form in self.related_object_forms.items():
         for field_name, related_object_form in self.related_object_forms.items():
 
 
@@ -670,9 +667,28 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
             )
             )
             raise ValidationError(error_msg)
             raise ValidationError(error_msg)
 
 
+        # A record which references an existing object by ID performs an update rather than a creation. The bulk
+        # import view is gated only on the 'add' permission, but updating an existing object requires 'change' (as
+        # enforced by the REST API). Require the 'change' permission at the model level before permitting any updates,
+        # and restrict the prefetched objects to those the user is permitted to change (object-level enforcement).
+        update_pks = set(prefetch_ids)
+        if prefetch_ids:
+            change_permission = get_permission_for_model(self.queryset.model, 'change')
+            if not request.user.has_perm(change_permission):
+                raise ValidationError(
+                    _(
+                        "This import includes {count} record(s) that reference an existing object by ID and would "
+                        "update it, which requires the {permission} permission. Remove the ID column to create new "
+                        "objects instead."
+                    ).format(count=len(prefetch_ids), permission=change_permission)
+                )
+            change_queryset = self.queryset.model.objects.restrict(request.user, 'change')
+        else:
+            change_queryset = self.queryset.model.objects
+
         prefetched_objects = {
         prefetched_objects = {
             obj.pk: obj
             obj.pk: obj
-            for obj in self.queryset.model.objects.filter(id__in=prefetch_ids)
+            for obj in change_queryset.filter(id__in=prefetch_ids)
         } if prefetch_ids else {}
         } if prefetch_ids else {}
 
 
         # Delay tree updates until all saves are complete (MPTT plugin models only;
         # Delay tree updates until all saves are complete (MPTT plugin models only;
@@ -680,6 +696,17 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
         with _delay_mptt_updates(self.queryset.model):
         with _delay_mptt_updates(self.queryset.model):
             saved_objects = self._process_import_records(form, request, records, prefetched_objects)
             saved_objects = self._process_import_records(form, request, records, prefetched_objects)
 
 
+        # Enforce object-level permissions in aggregate. Newly created objects are constrained by the 'add'
+        # permission (self.queryset is already restricted to 'add'); updated objects by 'change' (reusing the
+        # queryset built above, so no additional per-record work). This runs inside the caller's atomic
+        # transaction, so any violation rolls back the entire import.
+        created_pks = [obj.pk for obj in saved_objects if obj.pk not in update_pks]
+        if self.queryset.filter(pk__in=created_pks).count() != len(created_pks):
+            raise PermissionsViolation()
+        updated_pks = [obj.pk for obj in saved_objects if obj.pk in update_pks]
+        if updated_pks and change_queryset.filter(pk__in=updated_pks).count() != len(updated_pks):
+            raise PermissionsViolation()
+
         return saved_objects
         return saved_objects
 
 
     #
     #
@@ -721,14 +748,11 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
                     return redirect(redirect_url)
                     return redirect(redirect_url)
 
 
             try:
             try:
-                # Iterate through data and bind each record to a new model form instance.
+                # Iterate through data and bind each record to a new model form instance. Object-level
+                # permissions are enforced within create_and_update_objects().
                 with transaction.atomic(using=router.db_for_write(model)):
                 with transaction.atomic(using=router.db_for_write(model)):
                     new_objects = self.create_and_update_objects(form, request)
                     new_objects = self.create_and_update_objects(form, request)
 
 
-                    # Enforce object-level permissions
-                    if self.queryset.filter(pk__in=[obj.pk for obj in new_objects]).count() != len(new_objects):
-                        raise PermissionsViolation
-
                 msg = _('Imported {count} {object_type}').format(
                 msg = _('Imported {count} {object_type}').format(
                     count=len(new_objects),
                     count=len(new_objects),
                     object_type=model._meta.verbose_name_plural
                     object_type=model._meta.verbose_name_plural

+ 9 - 3
netbox/netbox/views/generic/object_views.py

@@ -603,10 +603,16 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
                         ))
                         ))
 
 
                         # Redirect user on success
                         # Redirect user on success
-                        if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
-                            return redirect(request.get_full_path())
+                        if '_addanother' in request.POST:
+                            redirect_url = request.path
+                            params = prepare_cloned_fields(new_objs[-1])
+                            if 'return_url' in request.GET:
+                                params['return_url'] = request.GET.get('return_url')
+                            if params:
+                                redirect_url += f"?{params.urlencode()}"
+                            if safe_for_redirect(redirect_url):
+                                return redirect(redirect_url)
                         return redirect(self.get_return_url(request))
                         return redirect(self.get_return_url(request))
-
                 except (AbortRequest, PermissionsViolation) as e:
                 except (AbortRequest, PermissionsViolation) as e:
                     logger.debug(e.message)
                     logger.debug(e.message)
                     form.add_error(None, e.message)
                     form.add_error(None, e.message)

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini