Procházet zdrojové kódy

Merge branch 'feature' into 22896-merge-main-into-feature

Resolves all conflicts between main and feature for #22896. Notable
resolutions:

- dcim/signals.py, dcim/tests/test_signals.py: main's cache_presave_scope_fields
  / sync_cached_scope_fields addition is fully superseded by feature's
  PostgreSQL-trigger-based denormalization (confirmed via feature's own
  migration docstrings); dropped in favor of feature's existing approach.
  Both files now match feature's originals exactly.

- netbox/tables/columns.py: combined main's generic get_ordering_annotation()
  protocol with feature's nulls_first-aware order() override. These two
  mechanisms cannot both apply to the same column (django-tables2 negates an
  entire order_by tuple uniformly on direction toggle, so a fixed nulls_first
  placement and multi-column sort composition are mutually exclusive for one
  column) -- preserved nulls_first (existing, wired through forms/API/GraphQL)
  and removed main's two composition-only tests for CustomFieldColumn. See the
  comment on CustomFieldColumn.order() for full reasoning.

- extras/customfields.py, extras/graphql/mixins.py: combined main's
  request-cache optimization and has_key-scoped batch updates with feature's
  resolve_selection_value() (shared select-field label resolution between
  REST and GraphQL).

- extras/events.py, extras/event_rules.py: main's "Honor Script defaults when
  triggered by Event Rules" (#22852) fix was written against the old inline
  action-type dispatch, which feature had already replaced with a pluggable
  action-provider registry (#22770). Re-applied the same two-line fix
  (notifications/job_timeout) inside ScriptAction.enqueue() in event_rules.py
  instead.

- utilities/jinja2.py: fixed a config-attribute name mismatch the raw merge
  would have introduced (main's JINJA2_FILTERS vs feature's renamed
  JINJA_FILTERS) by updating the shared _jinja2_filters() helper.

- ipam/migrations/: renumbered main's 0094_ipaddress_host_index to 0096 and
  added a merge migration, since main and feature had each independently
  added a migration numbered 0094.

- dcim/tests/query_counts.json: regenerated via UPDATE_QUERY_COUNTS=1 against
  the merged codebase rather than hand-merging counts.

Verified: manage.py check clean, full migration graph applies cleanly from
scratch, ruff clean, and full test suites pass for dcim, ipam, netbox, extras,
circuits, vpn, wireless, tenancy, virtualization, core, users, and account
(fresh databases, no state carried over between runs).
Brian Tiemann před 1 týdnem
rodič
revize
fde10cbf22
100 změnil soubory, kde provedl 4629 přidání a 614 odebrání
  1. 3 3
      .claude/skills/add-model/SKILL.md
  2. 54 32
      .github/workflows/release.yml
  3. 8 6
      base_requirements.txt
  4. 2285 48
      contrib/openapi.json
  5. 4 2
      docs/administration/error-reporting.md
  6. 14 0
      docs/configuration/data-validation.md
  7. 0 77
      docs/configuration/error-reporting.md
  8. 3 0
      docs/configuration/index.md
  9. 20 1
      docs/configuration/miscellaneous.md
  10. 3 3
      docs/configuration/required-parameters.md
  11. 1 1
      docs/configuration/security.md
  12. 26 6
      docs/configuration/system.md
  13. 27 1
      docs/customization/custom-fields.md
  14. 5 2
      docs/customization/custom-links.md
  15. 0 4
      docs/development/application-registry.md
  16. 9 7
      docs/development/building-the-package.md
  17. 51 10
      docs/development/release-checklist.md
  18. 11 0
      docs/features/context-data.md
  19. 41 0
      docs/features/cooling.md
  20. 1 1
      docs/features/search.md
  21. 3 1
      docs/getting-started/populating-data.md
  22. 3 4
      docs/installation/1-postgresql.md
  23. 0 3
      docs/installation/2-redis.md
  24. 3 3
      docs/installation/3-netbox.md
  25. 361 0
      docs/installation/3b-python-package.md
  26. 29 10
      docs/installation/6-ldap.md
  27. 8 6
      docs/installation/index.md
  28. 95 11
      docs/installation/upgrading.md
  29. 48 1
      docs/integrations/rest-api.md
  30. 3 10
      docs/integrations/webhooks.md
  31. 1 3
      docs/introduction.md
  32. 4 0
      docs/models/core/job.md
  33. 3 1
      docs/models/dcim/cable.md
  34. 37 0
      docs/models/dcim/coolingfeed.md
  35. 40 0
      docs/models/dcim/coolingintake.md
  36. 3 0
      docs/models/dcim/coolingintaketemplate.md
  37. 38 0
      docs/models/dcim/coolingoutflow.md
  38. 3 0
      docs/models/dcim/coolingoutflowtemplate.md
  39. 36 0
      docs/models/dcim/coolingsource.md
  40. 4 0
      docs/models/dcim/device.md
  41. 8 0
      docs/models/dcim/devicetype.md
  42. 15 2
      docs/models/dcim/interface.md
  43. 14 0
      docs/models/dcim/module.md
  44. 4 0
      docs/models/dcim/modulebay.md
  45. 2 0
      docs/models/dcim/modulebaytemplate.md
  46. 35 0
      docs/models/dcim/modulebaytype.md
  47. 12 0
      docs/models/dcim/moduletype.md
  48. 32 2
      docs/models/dcim/rack.md
  49. 8 0
      docs/models/dcim/racktype.md
  50. 4 0
      docs/models/extras/customfield.md
  51. 5 0
      docs/models/extras/eventrule.md
  52. 14 4
      docs/models/extras/webhook.md
  53. 52 4
      docs/models/ipam/service.md
  54. 4 4
      docs/models/ipam/servicetemplate.md
  55. 115 0
      docs/plugins/development/config-templates.md
  56. 62 0
      docs/plugins/development/event-rule-actions.md
  57. 41 0
      docs/plugins/development/forms.md
  58. 90 2
      docs/plugins/development/graphql-api.md
  59. 4 0
      docs/plugins/development/index.md
  60. 67 0
      docs/plugins/development/ui-components.md
  61. 0 7
      docs/plugins/development/webhooks.md
  62. 57 1
      docs/reference/conditions.md
  63. 12 1
      mkdocs.yml
  64. 9 2
      netbox/account/views.py
  65. 0 12
      netbox/circuits/apps.py
  66. 44 38
      netbox/circuits/choices.py
  67. 15 39
      netbox/circuits/forms/bulk_edit.py
  68. 58 109
      netbox/circuits/forms/model_forms.py
  69. 17 12
      netbox/circuits/graphql/filters.py
  70. 12 12
      netbox/circuits/graphql/types.py
  71. 8 8
      netbox/circuits/migrations/0053_owner.py
  72. 38 0
      netbox/circuits/migrations/0060_denormalization_triggers.py
  73. 12 12
      netbox/circuits/tests/test_filtersets.py
  74. 4 4
      netbox/circuits/tests/test_forms.py
  75. 83 1
      netbox/circuits/tests/test_models.py
  76. 4 4
      netbox/circuits/tests/test_views.py
  77. 2 2
      netbox/circuits/ui/panels.py
  78. 44 0
      netbox/circuits/views.py
  79. 2 2
      netbox/core/api/serializers_/jobs.py
  80. 1 0
      netbox/core/apps.py
  81. 8 9
      netbox/core/checks.py
  82. 26 26
      netbox/core/choices.py
  83. 14 1
      netbox/core/filtersets.py
  84. 3 2
      netbox/core/forms/bulk_edit.py
  85. 13 2
      netbox/core/forms/filtersets.py
  86. 3 2
      netbox/core/forms/model_forms.py
  87. 5 5
      netbox/core/graphql/filters.py
  88. 5 6
      netbox/core/graphql/types.py
  89. 1 1
      netbox/core/migrations/0020_owner.py
  90. 16 0
      netbox/core/migrations/0025_add_job_execution_time.py
  91. 46 0
      netbox/core/migrations/0026_populate_job_execution_time.py
  92. 15 3
      netbox/core/models/change_logging.py
  93. 11 5
      netbox/core/models/config.py
  94. 3 1
      netbox/core/models/data.py
  95. 8 0
      netbox/core/models/jobs.py
  96. 15 2
      netbox/core/tables/jobs.py
  97. 16 0
      netbox/core/tests/test_api.py
  98. 19 7
      netbox/core/tests/test_filtersets.py
  99. 37 0
      netbox/core/tests/test_models.py
  100. 67 1
      netbox/core/tests/test_tables.py

+ 3 - 3
.claude/skills/add-model/SKILL.md

@@ -478,9 +478,9 @@ class MyModelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 **File:** `netbox/<app>/tests/test_filtersets.py`
 
 ```python
-from utilities.testing import ChangeLoggedFilterSetTests
+from utilities.testing import ChangeLoggedFilterSetTestMixin
 
-class MyModelFilterSetTestCase(TestCase, ChangeLoggedFilterSetTests):
+class MyModelFilterSetTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = MyModel.objects.all()
     filterset = MyModelFilterSet
 
@@ -496,7 +496,7 @@ class MyModelFilterSetTestCase(TestCase, ChangeLoggedFilterSetTests):
         # Test FK and FK_id filters
 ```
 
-`ChangeLoggedFilterSetTests` provides standard tests for `id`, `created`, `last_updated`, `q` search, etc. Always mix it in.
+`ChangeLoggedFilterSetTestMixin` provides standard tests for `id`, `created`, `last_updated`, `q` search, etc. Always mix it in.
 
 ## Common Gotchas
 

+ 54 - 32
.github/workflows/release.yml

@@ -44,7 +44,7 @@ jobs:
           cache: pip
 
       - name: Install build tooling
-        run: python -m pip install --upgrade build twine
+        run: python -m pip install --upgrade build twine packaging
 
       - name: Install documentation toolchain
         run: python -m pip install -r requirements.txt
@@ -60,6 +60,20 @@ jobs:
       - name: Check package metadata
         run: twine check dist/*
 
+      - name: Verify the release tag
+        # Both checks run here, in the unprivileged build job, against the wheel that becomes this
+        # run's artifact, so neither publish job has to check out the repository or execute its
+        # scripts while holding id-token: write. A failure here skips every downstream job.
+        if: startsWith(github.ref, 'refs/tags/v')
+        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
+          }
+          python scripts/verify_release_tag.py "$TAG" dist/*.whl
+
       - name: Upload package artifacts
         uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
         with:
@@ -276,15 +290,14 @@ jobs:
     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')
+    # Test PyPI remains an opt-in rehearsal channel: only a manual dispatch from a v* tag publishes
+    # here, so the publish path can be exercised against a real index without touching production.
+    # A branch dispatch still runs the build, verify, and smoke-test jobs as a dry run, with both
+    # publish jobs skipped.
+    # startsWith() is only a coarse route to this job; workflow if: expressions cannot regex-match.
+    # The tag format and the tag-to-wheel version match are enforced in the build job, which fails
+    # the whole run before anything is uploaded.
+    if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v')
     environment:
       name: testpypi
       url: https://test.pypi.org/p/netbox
@@ -293,36 +306,45 @@ jobs:
       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
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
         with:
-          persist-credentials: false
-      - name: Set up Python
-        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+          name: python-package-distributions
+          path: dist/
+
+      - name: Publish package distributions to Test PyPI
+        uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
         with:
-          python-version: '3.12'
-      - name: Install tooling
-        run: python -m pip install --upgrade pip packaging
+          repository-url: https://test.pypi.org/legacy/
+          print-hash: true
 
+  publish-pypi:
+    name: Publish package to PyPI
+    runs-on: ubuntu-latest
+    needs: [smoke-test, cli-smoke-test, verify-dependencies, verify-sdist]
+    # A v* tag push is the production path. Test PyPI is an opt-in rehearsal rather than a promotion
+    # stage, so it is deliberately absent from this job's needs: an outage, a duplicate filename, or
+    # a misconfiguration on a test service must not block a verified production release. The four
+    # verification jobs above already ran against these exact artifacts. The protected pypi
+    # environment supplies the deliberate approval step, and because accepted PyPI filenames cannot
+    # be replaced or reused, a filename the index already holds fails the job instead of being
+    # skipped.
+    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+    environment:
+      name: pypi
+      url: https://pypi.org/p/netbox
+    permissions:
+      contents: read
+      id-token: write
+
+    steps:
       - 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
+      - name: Publish package distributions to PyPI
         uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
         with:
-          repository-url: https://test.pypi.org/legacy/
+          print-hash: true

+ 8 - 6
base_requirements.txt

@@ -27,12 +27,15 @@ django-graphiql-debug-toolbar
 django-htmx
 
 # Modified Preorder Tree Traversal (recursive nesting of objects)
+# Retained primarily for plugin backward compatibility: the deprecated
+# NestedGroupModel base remains MPTT-backed for plugins still using it. Also
+# required by historical migrations that pre-date the switch to PostgreSQL ltree.
+# NetBox core runtime uses netbox.models.ltree.LtreeModel instead.
 django-mptt
 
-# Context managers for PostgreSQL advisory locks
-# https://github.com/Xof/django-pglocks/blob/main/CHANGELOG.md
-# django-pglocks has been merged into django-pgware (see #22571)
-django-pglocks==1.0.4
+# Context managers for PostgreSQL advisory locks (successor to django-pglocks)
+# https://github.com/Xof/django-pgware
+django-pgware
 
 # Prometheus metrics library for Django
 # https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md
@@ -58,8 +61,7 @@ django-storages
 
 # Abstraction models for rendering and paginating HTML tables
 # https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md
-# See #21902 for upgrading to django-tables2 v2.9+
-django-tables2<2.9
+django-tables2
 
 # User-defined tags for objects
 # https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 2285 - 48
contrib/openapi.json


+ 4 - 2
docs/administration/error-reporting.md

@@ -4,11 +4,13 @@
 
 ### Enabling Error Reporting
 
-NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to `True` and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`.
+NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to `True` and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py` via `SENTRY_CONFIG`.
 
 ```python
 SENTRY_ENABLED = True
-SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
+SENTRY_CONFIG = {
+    "dsn": "https://examplePublicKey@o0.ingest.sentry.io/0",
+}
 ```
 
 Setting `SENTRY_ENABLED` to False will disable the Sentry integration.

+ 14 - 0
docs/configuration/data-validation.md

@@ -56,6 +56,20 @@ FIELD_CHOICES = {
 }
 ```
 
+In addition to plain tuples, each choice may be defined as a dictionary, which allows specifying a description (shown as a subtitle beneath the option) alongside the value, label, and color. `value` and `label` are required; `color` and `description` are optional:
+
+```python
+FIELD_CHOICES = {
+    'dcim.Site.status': (
+        {'value': 'foo', 'label': 'Foo', 'color': 'red', 'description': 'The foo status'},
+        {'value': 'bar', 'label': 'Bar', 'color': 'green'},
+    )
+}
+```
+
+!!! info "New in NetBox v4.7"
+    The dictionary-based format for declaring choices was introduced in NetBox v4.7. The tuple-based format remains supported, but will be deprecated in a future release and support for it will eventually be removed.
+
 !!! info "Case-Insensitive Field Identifiers"
     Field identifiers are case-insensitive. Both `dcim.Site.status` and `dcim.site.status` are valid and equivalent.
 

+ 0 - 77
docs/configuration/error-reporting.md

@@ -16,27 +16,6 @@ The default configuration is shown below:
 
 Additionally, `http_proxy` and `https_proxy` are set to the HTTP and HTTPS proxies, respectively, configured for NetBox (if any).
 
-## SENTRY_DSN
-
-!!! warning "This parameter will be removed in NetBox v4.7."
-    Set this using `SENTRY_CONFIG` instead:
-
-    ```
-    SENTRY_CONFIG = {
-        "dsn": "https://examplePublicKey@o0.ingest.sentry.io/0",
-    }
-    ```
-
-Default: `None`
-
-Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be `True` for this parameter to take effect. For example:
-
-```
-SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
-```
-
----
-
 ## SENTRY_ENABLED
 
 Default: `False`
@@ -48,43 +27,6 @@ Set to `True` to enable automatic error reporting via [Sentry](https://sentry.io
 
 ---
 
-## SENTRY_SAMPLE_RATE
-
-!!! warning "This parameter will be removed in NetBox v4.7."
-    Set this using `SENTRY_CONFIG` instead:
-
-    ```
-    SENTRY_CONFIG = {
-        "sample_rate": 0.2,
-    }
-    ```
-
-Default: `1.0` (all)
-
-The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors).
-
----
-
-## SENTRY_SEND_DEFAULT_PII
-
-!!! warning "This parameter will be removed in NetBox v4.7."
-    Set this using `SENTRY_CONFIG` instead:
-
-    ```
-    SENTRY_CONFIG = {
-        "send_default_pii": True,
-    }
-    ```
-
-Default: `False`
-
-Maps to the Sentry SDK's [`send_default_pii`](https://docs.sentry.io/platforms/python/configuration/options/#send-default-pii) parameter. If enabled, certain personally identifiable information (PII) is added.
-
-!!! warning "Sensitive data"
-    If you enable this option, be aware that sensitive data such as cookies and authentication tokens will be logged.
-
----
-
 ## SENTRY_TAGS
 
 An optional dictionary of tag names and values to apply to Sentry error reports.For example:
@@ -99,22 +41,3 @@ SENTRY_TAGS = {
 !!! warning "Reserved tag prefixes"
     Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application.
 
----
-
-## SENTRY_TRACES_SAMPLE_RATE
-
-!!! warning "This parameter will be removed in NetBox v4.7."
-    Set this using `SENTRY_CONFIG` instead:
-
-    ```
-    SENTRY_CONFIG = {
-        "traces_sample_rate": 0.2,
-    }
-    ```
-
-Default: `0` (disabled)
-
-The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions).
-
-!!! warning "Consider performance implications"
-    A high sampling rate for transactions can induce significant performance penalties. If transaction reporting is desired, it is recommended to use a relatively low sample rate of 10% to 20% (0.1 to 0.2).

+ 3 - 0
docs/configuration/index.md

@@ -6,6 +6,9 @@ NetBox's configuration file contains all the important parameters which control
 
 The configuration file is loaded from `$INSTALL_ROOT/netbox/netbox/configuration.py` by default. An example configuration is provided at `configuration_example.py`, which you may copy to use as your default config. Note that a configuration file must be defined; NetBox will not run without one.
 
+!!! note "Python package installations (experimental)"
+    An experimental Python package installation loads `$NETBOX_ROOT/conf/configuration.py` by default. `NETBOX_ROOT` defaults to `/opt/netbox`. Use `netbox setup --target <path>` to scaffold the local configuration, and keep configuration and mutable instance data outside the virtual environment and installed package. The setup target is not persisted; set `NETBOX_ROOT` for all commands and services when using a non-default path.
+
 !!! info "Customizing the Configuration Module"
     A custom configuration module may be specified by setting the `NETBOX_CONFIGURATION` environment variable. This must be a dotted path to the desired Python module. For example, a file named `my_config.py` in the same directory as `settings.py` would be referenced as `netbox.my_config`.
 

+ 20 - 1
docs/configuration/miscellaneous.md

@@ -277,7 +277,10 @@ This is a wrapper for passing global configuration parameters to [Django RQ](htt
 
 Default: `300`
 
-The maximum execution time of a background task (such as running a custom script), in seconds.
+The maximum execution time of a background task (such as running a custom script), in seconds. This may also be expressed as a duration string such as `1h` or `30m`, which NetBox normalizes to seconds when comparing it against webhook timeouts. Set this to `-1` to disable the job timeout entirely.
+
+!!! note
+    A value of zero (or `None`) does not disable the timeout: RQ falls back to its own default of 180 seconds, and NetBox validates webhook timeouts against that value accordingly.
 
 ---
 
@@ -306,3 +309,19 @@ The base unit for disk sizes. Set this to `1024` to use binary prefixes (MiB, Gi
 Default: `1000`
 
 The base unit for RAM sizes. Set this to `1024` to use binary prefixes (MiB, GiB, etc.) instead of decimal prefixes (MB, GB, etc.).
+
+---
+
+## WEBHOOK_DEFAULT_TIMEOUT
+
+Default: `60`
+
+The default maximum time (in seconds) to wait for a response when sending a webhook. This value is used for any webhook which does not define its own timeout. Keeping this below [`RQ_DEFAULT_TIMEOUT`](#rq_default_timeout) gives an unresponsive receiver a chance to be cut off by the request timeout rather than by termination of the background job.
+
+This value must be an integer between 1 and 3600, and must be less than `RQ_DEFAULT_TIMEOUT`; NetBox will refuse to start otherwise. The same upper bound is enforced on the per-webhook [timeout](../models/extras/webhook.md#timeout) field.
+
+!!! warning "Upgrading"
+    If you have lowered `RQ_DEFAULT_TIMEOUT` to 60 seconds or less and have not set `WEBHOOK_DEFAULT_TIMEOUT`, NetBox will not start until you set `WEBHOOK_DEFAULT_TIMEOUT` to a value below your job timeout.
+
+!!! note
+    The timeout is applied separately to establishing the connection and to waiting for data, rather than to the request as a whole. A receiver which responds slowly but continuously can therefore keep a request open for longer than the configured value. `RQ_DEFAULT_TIMEOUT` remains the ultimate upper bound on how long a webhook job can occupy a worker.

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

@@ -39,7 +39,7 @@ API_TOKEN_PEPPERS = {
 !!! warning "Peppers are sensitive"
     Treat pepper values as extremely sensitive. Consider populating peppers from environment variables at initialization time rather than defining them in the configuration file, if feasible. 
 
-Peppers must be at least 50 characters in length and should comprise a random string with a diverse character set. Consider using the Python script at `$INSTALL_ROOT/netbox/generate_secret_key.py` to generate a pepper value.
+Peppers must be at least 50 characters in length and should comprise a random string with a diverse character set. Consider using the Python script at `$INSTALL_ROOT/netbox/generate_secret_key.py` to generate a pepper value. For a Python package installation, run the virtual environment's `netbox secret-key` command instead.
 
 It is recommended to start with a pepper ID of `1`. Additional peppers can be introduced later as needed to begin rotating token hashes.
 
@@ -59,7 +59,7 @@ See the [`DATABASES`](#databases) configuration below for usage.
 
 ## DATABASES
 
-NetBox requires access to a PostgreSQL 14 or later database service to store data. Note that support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7; PostgreSQL 15 or later will be required. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries:
+NetBox requires access to a PostgreSQL 15 or later database service to store data. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries:
 
 ```python
 DATABASES = {
@@ -251,4 +251,4 @@ REDIS = {
 
 This is a secret, pseudorandom string used to assist in the creation new cryptographic hashes for passwords and HTTP cookies. The key defined here should not be shared outside the configuration file. `SECRET_KEY` can be changed at any time without impacting stored data, however be aware that doing so will invalidate all existing user sessions. NetBox deployments comprising multiple nodes must have the same secret key configured on all nodes.
 
-`SECRET_KEY` **must** be at least 50 characters in length, and should contain a mix of letters, digits, and symbols. The script located at `$INSTALL_ROOT/netbox/generate_secret_key.py` may be used to generate a suitable key. Please note that this key is **not** used directly for hashing user passwords or for the encrypted storage of secret data in NetBox.
+`SECRET_KEY` **must** be at least 50 characters in length, and should contain a mix of letters, digits, and symbols. The script located at `$INSTALL_ROOT/netbox/generate_secret_key.py` may be used to generate a suitable key. For a Python package installation, run the virtual environment's `netbox secret-key` command instead. Please note that this key is **not** used directly for hashing user passwords or for the encrypted storage of secret data in NetBox.

+ 1 - 1
docs/configuration/security.md

@@ -6,7 +6,7 @@
 
 Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
 
-A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all the default values as well (excluding those schemes which are not desirable).
+A list of permitted URL schemes referenced when rendering links within NetBox. This list is also enforced when validating the value of URL custom fields. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all the default values as well (excluding those schemes which are not desirable).
 
 ---
 

+ 26 - 6
docs/configuration/system.md

@@ -12,6 +12,20 @@ BASE_PATH = 'netbox/'
 
 ---
 
+## BULK_UPDATE_CHUNK_SIZE
+
+Default: `5000`
+
+The maximum number of rows to affect in a single SQL `UPDATE` statement when NetBox performs a bulk update across many objects (for example, when recalculating cached counters or backfilling custom field data). On very large tables, an unbounded update spanning millions of rows can exceed the database's configured statement timeout; splitting the work into batches of at most this many rows bounds each statement while keeping the overall operation atomic.
+
+Must be a positive integer, or `None` to disable chunking and issue each bulk update as a single unbounded statement.
+
+```python
+BULK_UPDATE_CHUNK_SIZE = 5000
+```
+
+---
+
 ## DATABASE_ROUTERS
 
 Default: `[]` (empty list)
@@ -152,7 +166,7 @@ Set this configuration parameter to `True` for NetBox deployments which do not h
 
 Default: `[]`
 
-A list of system environment variable names which may be referenced from within Jinja2 templates via the built-in [`env`](#jinja2_filters) filter. Patterns may include wildcards (matched using Python's `fnmatch` syntax). Any variable whose name does not match an entry in this list cannot be referenced from a template. For example:
+A list of system environment variable names which may be referenced from within Jinja templates via the built-in [`env`](#jinja_filters) filter. Patterns may include wildcards (matched using Python's `fnmatch` syntax). Any variable whose name does not match an entry in this list cannot be referenced from a template. For example:
 
 ```python
 JINJA_ENVIRONMENT_PARAMS = [
@@ -166,33 +180,39 @@ JINJA_ENVIRONMENT_PARAMS = [
 
 ---
 
-## JINJA2_FILTERS
+## JINJA_FILTERS
+
+!!! info "Renamed in NetBox v4.7"
+    This parameter was formerly named `JINJA2_FILTERS`. The old name is still supported for backward compatibility but is deprecated and will be removed in NetBox v5.0.
 
 Default: `{}`
 
-A dictionary of custom Jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example:
+A dictionary of custom Jinja filters with the key being the filter name and the value being a callable. For more information see the [Jinja documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example:
 
 ```python
 def uppercase(x):
     return str(x).upper()
 
-JINJA2_FILTERS = {
+JINJA_FILTERS = {
     'uppercase': uppercase,
 }
 ```
 
-NetBox also registers the following filters by default. Any entry defined in `JINJA2_FILTERS` with the same name will override the default.
+NetBox also registers the following filters by default. Any entry defined in `JINJA_FILTERS` with the same name will override the default.
 
 | Filter | Description |
 |---|---|
 | `env` | Returns the value of the system environment variable with the given name, provided its name matches an entry in [`JINJA_ENVIRONMENT_PARAMS`](#jinja_environment_params). Returns `None` if the variable is not defined or its name is not whitelisted. |
 
-For example, given `JINJA_ENVIRONMENT_PARAMS = ['WEBHOOK_TOKEN_*']`, a Jinja2 template may reference an environment variable as:
+For example, given `JINJA_ENVIRONMENT_PARAMS = ['WEBHOOK_TOKEN_*']`, a Jinja template may reference an environment variable as:
 
 ```
 Authorization: Bearer {{ 'WEBHOOK_TOKEN_3' | env }}
 ```
 
+!!! tip "Plugin-provided filters"
+    Plugins can also register Jinja filters without requiring instance configuration. See [Jinja Config Templates](../plugins/development/config-templates.md) in the plugin development documentation. Instance-level `JINJA_FILTERS` always takes precedence over plugin-registered filters of the same name.
+
 ---
 
 ## LOGGING

+ 27 - 1
docs/customization/custom-fields.md

@@ -17,7 +17,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net
 * Boolean: True or false
 * Date: A date in ISO 8601 format (YYYY-MM-DD)
 * Date & time: A date and time in ISO 8601 format (YYYY-MM-DD HH:MM:SS)
-* URL: This will be presented as a link in the web UI
+* URL: This will be presented as a link in the web UI. Values are restricted to the schemes permitted by [`ALLOWED_URL_SCHEMES`](../configuration/security.md#allowed_url_schemes). A value entered without a scheme (e.g. `example.com`) is assumed to use `https` and stored as an absolute URL (e.g. `https://example.com`).
 * JSON: Arbitrary data stored in JSON format
 * Selection: A selection of one of several pre-defined custom choices
 * Multiple selection: A selection field which supports the assignment of multiple values
@@ -109,6 +109,28 @@ When retrieving an object via the REST API, all of its custom data will be inclu
     ...
 ```
 
+Selection and multiple selection fields are returned as objects exposing both the stored value and its human-friendly label, following the same convention used by NetBox's built-in choice fields:
+
+```json
+    "custom_fields": {
+        "site_type": {
+            "value": "datacenter",
+            "label": "Data Center"
+        },
+        "regions": [
+            {
+                "value": "us-east",
+                "label": "US East"
+            },
+            {
+                "value": "us-west",
+                "label": "US West"
+            }
+        ]
+    },
+    ...
+```
+
 To set or change these values, simply include nested JSON data. For example:
 
 ```json
@@ -120,3 +142,7 @@ To set or change these values, simply include nested JSON data. For example:
     }
 }
 ```
+
+As with built-in choice fields, selection custom fields are written by passing the raw value (e.g. `"site_type": "datacenter"`), not the `{value, label}` object returned on read.
+
+The GraphQL API's `custom_fields` field resolves selection and multiple selection values to the same `{value, label}` representation.

+ 5 - 2
docs/customization/custom-links.md

@@ -28,10 +28,13 @@ The following context data is available within the template when rendering a cus
 |-----------|-------------------------------------------------------------------------------------------------------------------|
 | `object`  | The NetBox object being displayed                                                                                 |
 | `debug`   | A boolean indicating whether debugging is enabled                                                                 |
-| `request` | The current WSGI request                                                                                          |
-| `user`    | The current user (if authenticated)                                                                               |
+| `request` | A sanitized subset of the current request (see below)                                                             |
+| `user`    | The current user (if authenticated)                                                                   |
 | `perms`   | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user |
 
+!!! note "Changed in NetBox v4.7"
+    For security, `request` no longer exposes the full WSGI request object. Only a safe subset of attributes is available: `request.id`, `request.path`, `request.path_info`, `request.method`, `request.GET` (the query parameters), and `request.user` (the username). Sensitive data such as cookies, headers, and session state is no longer accessible from within a custom link template.
+
 While most of the context variables listed above will have consistent attributes, the object will be an instance of the specific object being viewed when the link is rendered. Different models have different fields and properties, so you may need to some research to determine the attributes available for use within your template for a specific object type.
 
 Checking the REST API representation of an object is generally a convenient way to determine what attributes are available. You can also reference the NetBox source code directly for a comprehensive list.

+ 0 - 4
docs/development/application-registry.md

@@ -16,10 +16,6 @@ A dictionary mapping of models to foreign keys with which cached counter fields
 
 A dictionary mapping data backend types to their respective classes. These are used to interact with [remote data sources](../models/core/datasource.md).
 
-### `denormalized_fields`
-
-Stores registration made using `netbox.denormalized.register()`. For each model, a list of related models and their field mappings is maintained to facilitate automatic updates.
-
 ### `filtersets`
 
 A dictionary mapping each model (identified by its app and label) to its filterset class, if one has been registered for it. Filtersets are registered using the `@register_filterset` decorator.

+ 9 - 7
docs/development/building-the-package.md

@@ -1,8 +1,10 @@
 # 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.
+NetBox package artifacts (a wheel and a source distribution) can be built and verified locally. Installing NetBox from the Python package is experimental in NetBox v4.7 and is not recommended for production use. 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.
+Published 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.
+
+Release tags trigger the production PyPI publishing workflow. Before a release tag is pushed, confirm that the `pypi` GitHub Actions environment has required reviewers configured so the upload waits for approval after the package checks complete. Referencing the environment in the workflow does not create an approval gate by itself. See [Confirm Package Publishing Prerequisites](./release-checklist.md#confirm-package-publishing-prerequisites) and [Publish to PyPI](./release-checklist.md#publish-to-pypi) for the required repository checks and release procedure.
 
 ## Prerequisites
 
@@ -12,7 +14,7 @@ Install the minimum local build tooling (all three are also included in the `dev
 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 also requires a freshly rendered copy of the documentation site (see [Building](#building) below). The documentation toolchain, including `zensical`, `mkdocs`, `mkdocs-material`, `mkdocstrings`, and `mkdocstrings-python`, 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
 
@@ -41,7 +43,7 @@ The package version and the wheel's runtime dependency metadata are both compute
 
 ## 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.
+Always build release artifacts from a clean checkout. The Hatch configuration excludes `netbox/netbox/configuration*.py` and `netbox/netbox/ldap_config*.py`, then force-includes only the two tracked configuration templates, `configuration_example.py` and `configuration_testing.py`. The sdist additionally excludes the checkout-level `netbox/configuration.py` and `netbox/ldap_config.py` symlinks. CI verifies the complete contents of both distributions 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.
 
@@ -87,11 +89,11 @@ 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.
+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` sets the instance root, from which the fixed collected-static path `$NETBOX_ROOT/static` is derived. 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.
+This section is a developer-facing overview of how the package is assembled and how a pip-installed NetBox behaves at runtime. End-user installation steps live in [Install NetBox from the Python Package](../installation/3b-python-package.md).
 
 ### Dynamic metadata
 
@@ -99,7 +101,7 @@ This section is a developer-facing overview of how the package is assembled and
 
 ### 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.
+`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, `netbox/release.yaml`, or `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
 

+ 51 - 10
docs/development/release-checklist.md

@@ -196,6 +196,16 @@ Once CI has completed and a colleague has reviewed the PR, merge it. This effect
 !!! warning
     To ensure a streamlined review process, the pull request for a release **must** be limited to the changes outlined in this document. A release PR must never include functional changes to the application: Any unrelated "cleanup" needs to be captured in a separate PR prior to the release being shipped.
 
+### Confirm Package Publishing Prerequisites
+
+Complete these checks before creating the release tag.
+
+Confirm that the existing PyPI trusted publisher still matches this repository, `.github/workflows/release.yml`, and the `pypi` environment name. If a Test PyPI rehearsal is planned, confirm the corresponding Test PyPI trusted publisher and `testpypi` environment as well. The trusted publisher's environment name must match the publish job's `environment.name`, otherwise the index rejects the upload before any file is transferred.
+
+Confirm that the `pypi` GitHub Actions environment has required reviewers configured so the production upload waits for approval after the package checks complete. Enable **Prevent self-review**, restrict deployments to `v*` tags, and leave administrator bypass disabled unless the maintainers deliberately require it. Referencing an environment from the workflow does not configure these protection rules; if the environment does not exist, GitHub creates it without an approval gate. The `testpypi` environment does not need an approval gate because a rehearsal run is dispatched deliberately.
+
+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. Confirm that the intended tag and `netbox/release.yaml` agree before creating the release. The publishing workflow verifies the match again against the built wheel.
+
 ### Create a New Release
 
 Create a [new release](https://github.com/netbox-community/netbox/releases/new) on GitHub with the following parameters.
@@ -207,23 +217,54 @@ Create a [new release](https://github.com/netbox-community/netbox/releases/new)
 
 Once created, the release will become available for users to install from GitHub.
 
-### Publish to Test PyPI
+### Publish to PyPI
+
+Creating the GitHub release pushes the new tag and starts the Python package publishing workflow. With the prerequisites above in place, the workflow builds and verifies the wheel and source distribution, then holds the production upload until the `pypi` deployment is approved. Approving the deployment publishes the verified artifacts to **PyPI**. Installing NetBox from the Python package is experimental in NetBox v4.7 and is not recommended for production use.
+
+A manual `workflow_dispatch` run from a `v*` release tag publishes to **Test PyPI** instead. This remains available as an optional rehearsal after packaging or publishing changes, but it is not required for every production release. Dispatching from a branch runs the build and verification jobs as a dry run without publishing anywhere.
+
+Dispatch a rehearsal from the release tag with GitHub CLI:
+
+```no-highlight
+gh workflow run release.yml --ref vX.Y.Z
+```
 
-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.
+When a Test PyPI rehearsal is useful for a release, keep the production deployment awaiting approval while you dispatch the workflow from the same tag and validate the rehearsal. The rehearsal is a separate workflow run and rebuilds the distributions, so it validates the packaging and publishing path rather than the exact files waiting for production. Approve the production deployment after the rehearsal completes.
+
+Test PyPI enforces the same filename immutability. Once it has accepted either distribution generated for a release tag, dispatching that tag again is expected to fail because the workflow rebuilds the same wheel and source distribution filenames. A further rehearsal requires a new package version and matching tag.
+
+Official pre-release tags, including beta and release-candidate versions, are published to PyPI as well. This is intentional. Pip does not select pre-release versions by default unless the user explicitly requests one or no compatible stable release is available.
 
 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:
+* Verify that the publish job used the expected trusted-publishing environment: `pypi` for a production release or `testpypi` for a rehearsal.
+* Confirm that the new version is visible on the corresponding package index.
+* Test the published wheel using the [wheel smoke-test procedure](./building-the-package.md#test-installing-the-wheel). For a production release, replace the local wheel installation command in that procedure with:
+
+    ```no-highlight
+    /tmp/netbox-build-test/bin/python -m pip install "netbox==<version>"
+    ```
+
+    For a Test PyPI rehearsal, install NetBox's pinned runtime dependencies from PyPI first and then install the candidate without resolving dependencies from the test index:
 
     ```no-highlight
-    pip install -r requirements.txt
-    pip install --no-deps --index-url https://test.pypi.org/simple/ netbox==<version>
+    /tmp/netbox-build-test/bin/python -m pip install -r requirements.txt
+    /tmp/netbox-build-test/bin/python -m 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.
+    Run `netbox check` with the configuration and environment variables shown in the linked procedure.
+
+!!! warning "Production PyPI uploads are final"
+    Distribution files uploaded to PyPI cannot be replaced. A release may be yanked, and a release or an individual file may be deleted, but an uploaded filename can never be reused. Correcting an accepted distribution file requires publishing a new NetBox version.
+
+    If the publish job fails, check PyPI and the job log to determine whether any distribution file was accepted before deciding how to recover.
+
+    If no file was accepted and the cause can be corrected without changing the built distributions, correct it and re-run only the failed `publish-pypi` job. That reuses the package artifacts already built and verified in the original workflow run. Do not use **Re-run all jobs**, because it rebuilds the distributions.
+
+    If correcting the failure requires changing package contents or metadata, prepare a new NetBox version and release tag instead.
 
-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.
+    If PyPI accepted either distribution file, do not retry the publish job. Production publishing fails on duplicate filenames by design, so the retry fails when it reaches the already accepted file. Yank the incomplete release, record the accepted filenames and hashes, and publish a new NetBox version rather than combining files from separate builds.

+ 11 - 0
docs/features/context-data.md

@@ -90,3 +90,14 @@ Devices and virtual machines may also have a local context data defined. This lo
 A [config context profile](../models/extras/configcontextprofile.md) provides an organizational grouping for related config contexts and may optionally enforce a [JSON schema](https://json-schema.org/) describing the shape of their data. When a profile is assigned to a config context, NetBox validates the context's data against the profile's schema on save and rejects any context that fails validation. This makes it possible to constrain which keys may appear in a context, require certain keys to be present, or limit values to a defined enumeration — guarding against typos and drift as contexts proliferate.
 
 A profile's schema may be authored directly in NetBox or populated from an external [data source](../models/core/datasource.md), enabling teams to maintain schemas alongside the code or configurations that consume them.
+
+## Pre-rendered Caching
+
+!!! info "New in NetBox v4.7"
+
+NetBox pre-renders each device's and virtual machine's merged context data and stores it on the object itself, so most reads can return the result without recomputing the full set of applicable contexts. The cache is initially populated during upgrade (the upgrade script runs the `rebuild_config_context_cache` management command) and is thereafter kept current automatically: whenever an upstream change is detected — a config context being created, modified, or deleted; a device/VM's scope-relevant attribute changing (site, role, tenant, tags, cluster, etc.); or a related object being re-routed in a way that changes which contexts apply — NetBox marks the affected caches invalid and enqueues a non-blocking [background job](./background-jobs.md) to repopulate them.
+
+During the brief window between invalidation and re-render, requests for the affected object's config context fall back to the original on-demand rendering path, so the data returned is always correct — never stale — but may be slightly slower during that window. Once the background job completes, reads are served from the cache.
+
+!!! note
+    The pre-rendered cache supersedes the previous `?exclude=config_context` REST API query parameter. Config context data is now always returned for devices and virtual machines, and the parameter is silently ignored.

+ 41 - 0
docs/features/cooling.md

@@ -0,0 +1,41 @@
+# Cooling
+
+As part of its DCIM feature set, NetBox supports modeling data center cooling infrastructure, from facility plant down to the coolant connections on individual devices. This is used to document liquid- and hybrid-cooled environments (chillers, cooling distribution units, manifolds, rear-door heat exchangers, and cold-plate servers) as a source of truth.
+
+## Model Overview
+
+Cooling infrastructure is modeled as a hierarchy running from facility plant down to individual devices:
+
+**cooling source → cooling feed → device cooling intake / cooling outflow**
+
+A few properties of the model are worth noting up front:
+
+- **Connections are direct references, not cables.** Coolant hoses are not modeled as structured cabling; instead, an intake references the outflow that supplies it directly. Tracing a loop is a walk along these references.
+- **A single feed represents the entire loop.** A cooling feed covers both the supply (cold) and return (warm) paths of a loop, rather than modeling each direction as a separate object.
+- **Intakes and outflows both sit on the supply path.** Both device components describe the cold, coolant-distribution side of the loop: an intake receives coolant and an outflow passes it onward to downstream equipment. The warm return path is not modeled per-component — it is captured by the feed loop.
+
+## Cooling Sources
+
+A [cooling source](../models/dcim/coolingsource.md) is the furthest upstream cooling element modeled in NetBox, representing a chiller, cooling tower, dry cooler, or CRAC/CRAH unit. Each source is associated with a site, and may optionally be associated with a particular location within that site. A cooling source is not a device; it represents external facility plant, and records the coolant (fluid type) and total rated cooling capacity for the loops it originates.
+
+## Cooling Feeds
+
+A [cooling feed](../models/dcim/coolingfeed.md) represents a coolant loop running between a cooling source and a particular rack. Each feed records an operational status, a rated cooling capacity, and a rated (design) flow rate.
+
+## Device Components
+
+Devices participate in cooling through two component types, instantiated from templates defined on the device type:
+
+- A [cooling intake](../models/dcim/coolingintake.md) is a coolant intake on a device, such as a server cold-plate inlet or a CDU facility intake. It records the connector type, diameter, and rated maximum flow, and optionally references the upstream [cooling outflow](../models/dcim/coolingoutflow.md) that supplies it.
+- A [cooling outflow](../models/dcim/coolingoutflow.md) is a coolant supply point on a device, such as a CDU or manifold outlet. It optionally references a parent cooling intake on the same device — the device takes coolant in through its intake and passes it back out through its outflow.
+
+!!! tip "In-rack cooling equipment is modeled as a device"
+    Coolant distribution units (CDUs), manifolds, and rear-door heat exchangers (RDHx) are modeled as ordinary (typically zero-U) [devices](../models/dcim/device.md) installed in the rack — exactly as a PDU is modeled as a device with power ports and outlets. The device's make and model come from its [device type](../models/dcim/devicetype.md), and its cooling connections are represented by cooling intake and outflow components. There is no dedicated CDU or RDHx model.
+
+## Racks and Devices
+
+Racks and devices carry lightweight cooling attributes independent of the feed/component topology:
+
+- A [rack](../models/dcim/rack.md) records a **cooling capability** (air-only, hybrid, or liquid-only) and a **cooling capacity** in kilowatts, typically inherited from its rack type.
+- A [device](../models/dcim/device.md) records a **cooling method** (air, liquid, hybrid, or immersion), inherited from its device type and overridable per device.
+

+ 1 - 1
docs/features/search.md

@@ -2,7 +2,7 @@
 
 ## Global Search
 
-NetBox includes a powerful global search engine, providing a single convenient interface to search across its complex data model. Relevant fields on each model are indexed according to their precedence, so that the most relevant results are returned first. When objects are created or modified, the search index is updated immediately, ensuring real-time accuracy.
+NetBox includes a powerful global search engine, providing a single convenient interface to search across its complex data model. Relevant fields on each model are indexed according to their precedence, so that the most relevant results are returned first. When objects are created, modified, or deleted, the search index is updated by a background task shortly afterward. As a result, a newly created or changed object may not appear in search results for a brief period. (When no background worker is running, the index is updated immediately as part of the request.)
 
 When entering a search query, the user can choose a specific lookup type: exact match, partial match, etc. When a partial match is found, the matching portion of the applicable field value is included with each result so that the user can easily determine its relevance.
 

+ 3 - 1
docs/getting-started/populating-data.md

@@ -26,7 +26,9 @@ When viewing the CSV import form for an object type, you'll notice that the head
 
 <!-- TODO: Screenshot -->
 
-If an "id" field is added the data will be used to update existing records instead of importing new objects.
+If an "id" field is added the data will be used to update existing records instead of importing new objects. When updating, only the columns present in the data are applied; all others are left unchanged. Note that some columns are interdependent: for example, updating a cable's terminations requires that the columns identifying their type and parent object be included as well.
+
+Some columns accept multiple values, separated by commas. Because the comma also serves as the CSV field delimiter, such a value must be enclosed in double quotes, e.g. `"tag1,tag2,tag3"`. (When importing JSON- or YAML-formatted data, these columns accept a native list instead.) An object whose name itself contains a comma cannot be referenced by a multi-value column, as there is no way to distinguish it from a separator.
 
 Note that some models (namely device types and module types) do not support CSV import. Instead, they accept YAML-formatted data to facilitate the import of both the parent object as well as child components.
 

+ 3 - 4
docs/installation/1-postgresql.md

@@ -2,8 +2,8 @@
 
 This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
 
-!!! warning "PostgreSQL 14 or later required"
-    NetBox requires PostgreSQL 14 or later. Please note that MySQL and other relational databases are **not** supported.
+!!! warning "PostgreSQL 15 or later required"
+    NetBox requires PostgreSQL 15 or later. Please note that MySQL and other relational databases are **not** supported.
 
 !!! warning "PostgreSQL 14 deprecation notice"
     Support for PostgreSQL 14 is deprecated as of NetBox v4.6 and will be removed in NetBox v4.7. Please plan to upgrade to PostgreSQL 15 or later.
@@ -15,7 +15,7 @@ sudo apt update
 sudo apt install -y postgresql
 ```
 
-Before continuing, verify that you have installed PostgreSQL 14 or later:
+Before continuing, verify that you have installed PostgreSQL 15 or later:
 
 ```no-highlight
 psql -V
@@ -35,7 +35,6 @@ Within the shell, enter the following commands to create the database and user (
 CREATE DATABASE netbox;
 CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
 ALTER DATABASE netbox OWNER TO netbox;
--- the next two commands are needed on PostgreSQL 15 and later
 \connect netbox;
 GRANT CREATE ON SCHEMA public TO netbox;
 ```

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

@@ -10,9 +10,6 @@ sudo apt install -y redis-server
 
 Before continuing, verify that your installed version of Redis is at least v6.0:
 
-!!! warning "Redis v5.x is deprecated"
-    Support for Redis versions older than 6.0 is deprecated and will be removed in NetBox v4.7.
-
 ```no-highlight
 redis-server -v
 ```

+ 3 - 3
docs/installation/3-netbox.md

@@ -1,6 +1,6 @@
-# NetBox Installation
+# Install NetBox from a Release Archive or Git
 
-This section of the documentation discusses installing and configuring the NetBox application itself.
+This page covers the established release archive and Git installation methods. To install NetBox from the experimental Python package instead, follow the [separate package installation guide](3b-python-package.md).
 
 ## Install System Packages
 
@@ -99,7 +99,7 @@ cd /opt/netbox/netbox/netbox/
 sudo cp configuration_example.py configuration.py
 ```
 
-Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations:
+Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following five are required for new installations:
 
 * `ALLOWED_HOSTS`
 * `API_TOKEN_PEPPERS`

+ 361 - 0
docs/installation/3b-python-package.md

@@ -0,0 +1,361 @@
+# Install NetBox from the Python Package (Experimental)
+
+!!! warning "Experimental in NetBox v4.7"
+    Installing NetBox from the Python package is experimental in NetBox v4.7 and is **not recommended for production use**. Use this workflow to evaluate the packaged installation, test upgrades and rollback procedures, and provide feedback.
+
+    The established [release archive and Git installation methods](3-netbox.md) remain supported and are not replaced by this workflow.
+
+The Python package installs the NetBox application and its Python dependencies into a virtual environment using `pip`. Configuration, uploaded media, custom scripts and reports, collected static files, and deployment configuration remain outside the installed package.
+
+This installation method does **not** configure PostgreSQL, Redis, a WSGI server, an HTTP server, or system services. These remain administrator-managed deployment tasks, just as they are for an archive or Git installation.
+
+## When to Use This Installation Method
+
+Use the Python package for a new test or evaluation deployment when you want `pip` to manage the NetBox application code in a dedicated virtual environment. While this workflow remains experimental, use a [release archive or Git checkout](3-netbox.md) for production deployments.
+
+A package installation is also available as a migration target for an existing deployment, but it is not an in-place conversion. Follow the [migration procedure](#migrate-an-existing-archive-or-git-installation) only after validating the workflow in a separate environment.
+
+## Understand the Installation Layout
+
+A package installation separates the application code from the files that belong to a particular NetBox instance.
+
+| Component | Example Location | Purpose |
+|-----------|------------------|---------|
+| Application code | `<venv>/lib/pythonX.Y/site-packages/` | Installed and replaced by `pip`; do not modify it directly |
+| Python virtual environment | `/opt/netbox/venv/` | Contains NetBox, its dependencies, and any plugins |
+| Instance root | `/opt/netbox/` | Holds local configuration and mutable instance data |
+| Configuration | `/opt/netbox/conf/configuration.py` | Contains settings and credentials for this instance |
+| Mutable data | `/opt/netbox/{media,reports,scripts,static}/` | Persists independently of package upgrades |
+| Deployment examples | `/opt/netbox/contrib/` | Local copies to review and adapt before use |
+
+The instance root defaults to `/opt/netbox` and may be changed with the `NETBOX_ROOT` environment variable. The virtual environment does not need to be located below the instance root; `/opt/netbox/venv` is used throughout this guide only to keep the example straightforward.
+
+!!! note "Custom instance roots"
+    The `--target` option for `netbox setup` selects where the local files are created. It does not permanently set the instance root. When using a location other than `/opt/netbox`, set `NETBOX_ROOT` for all NetBox commands and services.
+
+## Before You Begin
+
+Complete the [PostgreSQL](1-postgresql.md) and [Redis](2-redis.md) installation steps first. Then install the same [required system packages](3-netbox.md#install-system-packages) used by the archive and Git installation methods.
+
+## Create the System User and Instance Root
+
+Create the `netbox` system account and the default instance root:
+
+```no-highlight
+sudo adduser --system --group netbox
+sudo mkdir -p /opt/netbox
+sudo chown root:netbox /opt/netbox
+sudo chmod 755 /opt/netbox
+```
+
+## Create the Virtual Environment
+
+Create a Python virtual environment and update `pip`:
+
+```no-highlight
+sudo python3 -m venv /opt/netbox/venv
+sudo /opt/netbox/venv/bin/python -m pip install --upgrade pip
+```
+
+Install the desired NetBox release. Replace `X.Y.Z` with the exact version to install:
+
+```no-highlight
+sudo /opt/netbox/venv/bin/python -m pip install "netbox==X.Y.Z"
+```
+
+Pinning the version makes the installed release explicit and prevents an unintended upgrade when the command is repeated later.
+
+## Scaffold the Instance Root
+
+Run `netbox setup` to create the local configuration skeleton and copy the bundled deployment examples:
+
+```no-highlight
+sudo /opt/netbox/venv/bin/netbox setup --target /opt/netbox
+```
+
+The command creates the following files when they do not already exist:
+
+```no-highlight
+/opt/netbox/
+├── conf/
+│   ├── __init__.py
+│   └── configuration.py
+├── contrib/
+│   ├── apache.conf
+│   ├── gunicorn.py
+│   ├── netbox-rq.service
+│   ├── netbox.env
+│   ├── netbox.service
+│   ├── nginx.conf
+│   └── uwsgi.ini
+└── local_requirements.txt
+```
+
+`netbox setup` is intentionally non-destructive: existing files are left untouched. It does not install systemd units, configure an HTTP server, rewrite deployment examples for the local paths, or enable plugins.
+
+Create the directories used for mutable instance data and grant the NetBox service account ownership of them:
+
+```no-highlight
+sudo mkdir -p /opt/netbox/{media,reports,scripts,static}
+sudo chown --recursive netbox:netbox \
+    /opt/netbox/media \
+    /opt/netbox/reports \
+    /opt/netbox/scripts \
+    /opt/netbox/static
+```
+
+## Configure NetBox
+
+Open the scaffolded configuration file:
+
+```no-highlight
+sudo ${EDITOR:-vi} /opt/netbox/conf/configuration.py
+```
+
+Define the five [required configuration parameters](../configuration/required-parameters.md):
+
+* `ALLOWED_HOSTS`
+* `API_TOKEN_PEPPERS`
+* `DATABASES`
+* `REDIS`
+* `SECRET_KEY`
+
+Generate a suitable random value for `SECRET_KEY` with the installed command:
+
+```no-highlight
+sudo /opt/netbox/venv/bin/netbox secret-key
+```
+
+Run the command again to generate an independent value for the first entry in `API_TOKEN_PEPPERS`. Treat both values as sensitive and do not reuse the examples from the documentation.
+
+After saving the configuration, restrict access while allowing the NetBox service account to read it:
+
+```no-highlight
+sudo chown --recursive root:netbox /opt/netbox/conf
+sudo chmod 750 /opt/netbox/conf
+sudo chmod 640 /opt/netbox/conf/configuration.py
+```
+
+!!! note "Environment-based configuration"
+    Ensure that any environment variables referenced by `configuration.py` are present when running `netbox upgrade`, `netbox createsuperuser`, and other management commands, and provide the same variables to both NetBox services. The copied `contrib/netbox.env` file is an example only and is not loaded automatically.
+
+## Install Plugins and Optional Python Packages
+
+Plugins and any other local Python requirements must be installed into the **same virtual environment** as NetBox before running the installation or upgrade tasks. Add each package to `/opt/netbox/local_requirements.txt`, then install the file:
+
+```no-highlight
+sudo ${EDITOR:-vi} /opt/netbox/local_requirements.txt
+sudo /opt/netbox/venv/bin/python -m pip install \
+    -r /opt/netbox/local_requirements.txt
+```
+
+Installing a plugin does not enable it. Add the plugin to the `PLUGINS` list in `/opt/netbox/conf/configuration.py` and complete any plugin-specific configuration separately.
+
+NetBox also provides optional package extras for several common integrations. For example, install the LDAP dependencies together with the same pinned NetBox version as follows:
+
+```no-highlight
+sudo /opt/netbox/venv/bin/python -m pip install "netbox[ldap]==X.Y.Z"
+```
+
+Remember which extras are in use and specify them again when upgrading. For LDAP authentication, create `ldap_config.py` beside the active configuration file at `/opt/netbox/conf/ldap_config.py` when following the [LDAP configuration guide](6-ldap.md). Give it the same ownership and permissions as `configuration.py`:
+
+```no-highlight
+sudo chown root:netbox /opt/netbox/conf/ldap_config.py
+sudo chmod 640 /opt/netbox/conf/ldap_config.py
+```
+
+When using uWSGI, install `pyuwsgi` into the same virtual environment and record it as a local requirement:
+
+```no-highlight
+sudo sh -c "echo 'pyuwsgi' >> /opt/netbox/local_requirements.txt"
+sudo /opt/netbox/venv/bin/python -m pip install pyuwsgi
+```
+
+## Run the Installation Tasks
+
+Run the packaged upgrade command to apply database migrations, collect static files, and perform the remaining application installation tasks:
+
+```no-highlight
+sudo -u netbox /opt/netbox/venv/bin/netbox upgrade --no-input
+```
+
+The `netbox upgrade` command is used for both a fresh package installation and future package upgrades. It replaces the source installation's `upgrade.sh` workflow.
+
+For a custom instance root, pass `NETBOX_ROOT` explicitly. The virtual environment may remain elsewhere:
+
+```no-highlight
+sudo -u netbox env NETBOX_ROOT=/srv/netbox \
+    /opt/netbox-venv/bin/netbox upgrade --no-input
+```
+
+## Create a Superuser
+
+Create the first administrative account:
+
+```no-highlight
+sudo -u netbox /opt/netbox/venv/bin/netbox createsuperuser
+```
+
+## Test the Application
+
+Start Django's development server temporarily to confirm that NetBox can load its configuration and connect to its dependencies:
+
+```no-highlight
+sudo -u netbox /opt/netbox/venv/bin/netbox \
+    runserver 0.0.0.0:8000 --insecure
+```
+
+Connect to the server on port 8000 and log in with the superuser account. Type `Ctrl+c` to stop the development server after testing.
+
+!!! danger "Not for production use"
+    The development server is intended only for installation testing. It is neither performant nor secure enough for production use.
+
+## Adapt the Deployment Examples
+
+The files copied to `/opt/netbox/contrib/` are the same deployment examples shipped for archive and Git installations. They are not rewritten for the package layout. Adapt them before following the shared Gunicorn, uWSGI, and HTTP server instructions.
+
+For the default paths used in this guide, the following commands remove the source-tree references:
+
+```no-highlight
+sudo sed -i \
+    's| --pythonpath /opt/netbox/netbox||' \
+    /opt/netbox/contrib/netbox.service
+
+sudo sed -i \
+    's|/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py|/opt/netbox/venv/bin/netbox|' \
+    /opt/netbox/contrib/netbox-rq.service
+
+sudo sed -i \
+    's|chdir = netbox|chdir = /opt/netbox|' \
+    /opt/netbox/contrib/uwsgi.ini
+
+sudo sed -i \
+    's|/opt/netbox/netbox/static|/opt/netbox/static|g' \
+    /opt/netbox/contrib/nginx.conf \
+    /opt/netbox/contrib/apache.conf
+```
+
+These changes have the following effect:
+
+| File | Package Installation Change |
+|------|-----------------------------|
+| `netbox.service` | Imports `netbox.wsgi` from the virtual environment without a source-tree `--pythonpath` |
+| `netbox-rq.service` | Runs the RQ worker through the installed `netbox` command instead of `manage.py` |
+| `uwsgi.ini` | Uses the instance root rather than the absent `/opt/netbox/netbox/` source directory |
+| `nginx.conf` and `apache.conf` | Serve collected static files from `/opt/netbox/static/` |
+
+Review every file before installing it. When using a different instance root or virtual environment, update all `WorkingDirectory`, `ExecStart`, `chdir`, virtual environment, and static-file paths accordingly. Also add the following line to the `[Service]` section of both systemd units, replacing the path as needed:
+
+```ini
+Environment=NETBOX_ROOT=/srv/netbox
+```
+
+When using environment-based configuration, reference an appropriate environment file from both systemd units or define the required variables directly in each unit.
+
+## Continue the Installation
+
+With the deployment examples adapted, continue with either [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md). When using uWSGI and you installed `pyuwsgi` above, skip the **Installation** subsection on the uWSGI page and begin with its configuration steps. Then configure an [HTTP server](5-http-server.md) and, if needed, [LDAP authentication](6-ldap.md).
+
+The shared pages copy files from `/opt/netbox/contrib/`, so make the package-specific changes above **before** copying those files into their final locations.
+
+## Migrate an Existing Archive or Git Installation
+
+!!! warning "Experimental migration path"
+    Migrating an existing deployment to the Python package changes its filesystem and upgrade model. Take a complete backup, document the current configuration, and verify a rollback procedure before proceeding.
+
+Python package releases begin with NetBox v4.7. Before migrating an older deployment, first upgrade the existing archive or Git installation to a version that is available as a Python package.
+
+Migrate the layout separately from a NetBox version upgrade. Install the **same NetBox version** that is currently running, validate the package-based deployment, and only then upgrade to a newer release.
+
+The following example keeps the existing `/opt/netbox` installation in place during migration. It uses `/srv/netbox` as the new instance root and `/opt/netbox-venv` for the new virtual environment.
+
+1. Stop the existing NetBox services after completing a backup:
+
+    ```no-highlight
+    sudo systemctl stop netbox netbox-rq
+    ```
+
+2. Create the new virtual environment and install the same NetBox version as the existing deployment:
+
+    ```no-highlight
+    sudo python3 -m venv /opt/netbox-venv
+    sudo /opt/netbox-venv/bin/python -m pip install --upgrade pip
+    sudo /opt/netbox-venv/bin/python -m pip install "netbox==X.Y.Z"
+    ```
+
+3. Scaffold the new instance root and create its mutable directories:
+
+    ```no-highlight
+    sudo mkdir -p /srv/netbox
+    sudo chown root:netbox /srv/netbox
+    sudo chmod 755 /srv/netbox
+    sudo /opt/netbox-venv/bin/netbox setup --target /srv/netbox
+    sudo mkdir -p /srv/netbox/{media,reports,scripts,static}
+    sudo chown --recursive netbox:netbox \
+        /srv/netbox/media \
+        /srv/netbox/reports \
+        /srv/netbox/scripts \
+        /srv/netbox/static
+    ```
+
+4. Copy the active configuration from the existing installation. If `local_requirements.txt` exists, copy it over the empty file created by `netbox setup`:
+
+    ```no-highlight
+    sudo cp /opt/netbox/netbox/netbox/configuration.py \
+        /srv/netbox/conf/configuration.py
+
+    if [ -f /opt/netbox/local_requirements.txt ]; then
+        sudo cp /opt/netbox/local_requirements.txt \
+            /srv/netbox/local_requirements.txt
+    fi
+    ```
+
+    When the existing deployment uses `NETBOX_CONFIGURATION`, copy the active configuration module instead, together with any sibling modules or local files it imports. Review the copied configuration and update any filesystem paths that still reference the old source tree.
+
+    If LDAP is configured, also copy the active `ldap_config.py` to `/srv/netbox/conf/ldap_config.py`.
+
+5. Copy locally stored media, reports, and scripts. Do not copy collected static files; `netbox upgrade` will create them again.
+
+    ```no-highlight
+    sudo cp -a /opt/netbox/netbox/media/. /srv/netbox/media/
+    sudo cp -a /opt/netbox/netbox/reports/. /srv/netbox/reports/
+    sudo cp -a /opt/netbox/netbox/scripts/. /srv/netbox/scripts/
+    sudo chown --recursive netbox:netbox \
+        /srv/netbox/media \
+        /srv/netbox/reports \
+        /srv/netbox/scripts
+    ```
+
+    Use the paths configured by `MEDIA_ROOT`, `REPORTS_ROOT`, and `SCRIPTS_ROOT` instead when the existing deployment stores these files elsewhere.
+
+6. Install all plugins and local requirements into the new virtual environment **before** running the upgrade tasks:
+
+    ```no-highlight
+    sudo /opt/netbox-venv/bin/python -m pip install \
+        -r /srv/netbox/local_requirements.txt
+    ```
+
+    Repeat any NetBox package extras used by the deployment, and verify that each plugin supports the installed NetBox version.
+
+7. Secure the configuration and run the package installation tasks against the existing database:
+
+    ```no-highlight
+    sudo chown --recursive root:netbox /srv/netbox/conf
+    sudo chmod 750 /srv/netbox/conf
+    sudo chmod 640 /srv/netbox/conf/configuration.py
+
+    sudo -u netbox env NETBOX_ROOT=/srv/netbox \
+        /opt/netbox-venv/bin/netbox upgrade --no-input
+    ```
+
+    If `ldap_config.py` was copied, also run `sudo chmod 640 /srv/netbox/conf/ldap_config.py`.
+
+8. Follow [Adapt the Deployment Examples](#adapt-the-deployment-examples), substituting `/srv/netbox` and `/opt/netbox-venv` for the example paths. Install the updated systemd and HTTP server configuration, switch the services to the package deployment, and ensure that both systemd units define `NETBOX_ROOT=/srv/netbox`.
+
+9. Start the services, test the web interface and background processing, and retain the previous installation until the new deployment has been validated:
+
+    ```no-highlight
+    sudo systemctl start netbox netbox-rq
+    ```
+
+After the migration is complete, use the [Python package upgrade procedure](upgrading.md#upgrade-a-python-package-installation-experimental) for future releases.

+ 29 - 10
docs/installation/6-ldap.md

@@ -12,18 +12,30 @@ sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
 
 ### Install django-auth-ldap
 
-Activate the Python virtual environment and install the `django-auth-ldap` package using pip:
+=== "Release archive or Git"
 
-```no-highlight
-source /opt/netbox/venv/bin/activate
-pip3 install django-auth-ldap
-```
+    Activate the Python virtual environment and install the `django-auth-ldap` package using pip:
 
-Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment:
+    ```no-highlight
+    source /opt/netbox/venv/bin/activate
+    pip3 install django-auth-ldap
+    ```
 
-```no-highlight
-sudo sh -c "echo 'django-auth-ldap' >> /opt/netbox/local_requirements.txt"
-```
+    Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment:
+
+    ```no-highlight
+    sudo sh -c "echo 'django-auth-ldap' >> /opt/netbox/local_requirements.txt"
+    ```
+
+=== "Python package (experimental)"
+
+    Install NetBox's `ldap` optional dependency group, pinned to the installed NetBox version:
+
+    ```no-highlight
+    sudo /opt/netbox/venv/bin/python -m pip install "netbox[ldap]==X.Y.Z"
+    ```
+
+    Specify the `ldap` extra again when upgrading the NetBox package. See the [Python package upgrade procedure](upgrading.md#upgrade-a-python-package-installation-experimental).
 
 ## Configuration
 
@@ -33,7 +45,14 @@ First, enable the LDAP authentication backend in `configuration.py`. (Be sure to
 REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
 ```
 
-Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](https://django-auth-ldap.readthedocs.io/).
+Next, create a file named `ldap_config.py` in the same directory as the active `configuration.py`. This is typically `/opt/netbox/netbox/netbox/` for a release archive or Git installation, or `/opt/netbox/conf/` for a Python package installation. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](https://django-auth-ldap.readthedocs.io/).
+
+For a Python package installation, protect the file while allowing the NetBox service account to read it:
+
+```no-highlight
+sudo chown root:netbox /opt/netbox/conf/ldap_config.py
+sudo chmod 640 /opt/netbox/conf/ldap_config.py
+```
 
 ### General Server Configuration
 

+ 8 - 6
docs/installation/index.md

@@ -18,21 +18,23 @@ The following sections detail how to set up a new instance of NetBox:
 
 1. [PostgreSQL database](1-postgresql.md)
 2. [Redis](2-redis.md)
-3. [NetBox components](3-netbox.md)
+3. Install the NetBox application using either:
+    * a [release archive or Git checkout](3-netbox.md); or
+    * the [Python package](3b-python-package.md) (experimental)
 4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md)
 5. [HTTP server](5-http-server.md)
 6. [LDAP authentication](6-ldap.md) (optional)
 
+!!! warning "Experimental Python package installation"
+    Installing NetBox from the Python package is experimental in NetBox v4.7 and is not recommended for production use. It is intended for evaluation and feedback. The release archive and Git workflows remain supported and are the established installation methods.
+
 ## Requirements
 
 | Dependency | Supported Versions |
 |------------|--------------------|
 | Python     | 3.12, 3.13, 3.14   |
-| PostgreSQL | 14+ [^1]           |
-| Redis      | 5.0+ [^2]          |
-
-[^1]: Support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7. PostgreSQL 15 or later will be required.
-[^2]: Support for Redis versions older than 6.0 is deprecated and will be removed in NetBox v4.7. Redis 6.0 or later will be required.
+| PostgreSQL | 15+                |
+| Redis      | 6.0+               |
 
 Below is a simplified overview of the NetBox application stack for reference:
 

+ 95 - 11
docs/installation/upgrading.md

@@ -22,21 +22,21 @@ block-beta
 !!! warning "Perform a Backup"
     Always be sure to save a backup of your current NetBox deployment prior to starting the upgrade process.
 
-## 1. Review the Release Notes
+## Review the Release Notes
 
 Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect.
 
-## 2. Update Dependencies to Required Versions
+Before proceeding, verify that all installed plugins support the target NetBox release.
+
+## Update Required Dependencies
 
 NetBox requires the following dependencies:
 
 | Dependency | Supported Versions |
 |------------|--------------------|
 | Python     | 3.12, 3.13, 3.14   |
-| PostgreSQL | 14+ [^1]           |
-| Redis      | 5.0+               |
-
-[^1]: Support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7. PostgreSQL 15 or later will be required.
+| PostgreSQL | 15+                |
+| Redis      | 6.0+               |
 
 ### Version History
 
@@ -58,7 +58,11 @@ NetBox requires the following dependencies:
 |      3.1       |    3.7     |    3.9     |       10       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) |
 |      3.0       |    3.7     |    3.9     |      9.6       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) |
 
-## 3. Install the Latest Release
+## Upgrade a Release Archive or Git Installation
+
+The following procedure applies to NetBox installations created from a release archive or Git checkout. Complete the preparation steps above, then use the same installation method that was used for the existing deployment.
+
+### 1. Install the Latest Release
 
 As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by checking out the latest production release from the git repository.
 
@@ -73,7 +77,7 @@ ls -ld /opt/netbox /opt/netbox/.git
 
 If NetBox was installed from a release package, then `/opt/netbox` will be a symlink pointing to the current version, and `/opt/netbox/.git` will not exist.  If it was installed from git, then `/opt/netbox` and `/opt/netbox/.git` will both exist as normal directories.
 
-### Option A: Download a Release
+#### Option A: Download a Release
 
 Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive. Extract it to your desired path. In this example, we'll use `/opt/netbox`.
 
@@ -116,7 +120,7 @@ If you followed the original installation guide to set up gunicorn, be sure to c
 sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
 ```
 
-### Option B: Check Out a Git Release
+#### Option B: Check Out a Git Release
 
 This guide assumes that NetBox is installed in `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command:
 
@@ -135,7 +139,7 @@ sudo git fetch --tags && \
 sudo git checkout v4.5.0
 ```
 
-## 4. Run the Upgrade Script
+### 2. Run the Upgrade Script
 
 Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script:
 
@@ -169,7 +173,7 @@ This script performs the following actions:
     been made to your local codebase and should be investigated. Never attempt to create new migrations unless you are
     intentionally modifying the database schema.
 
-## 5. Restart the NetBox Services
+### 3. Restart the NetBox Services
 
 !!! warning
     If you are upgrading from an installation that does not use a Python virtual environment (any release prior to v2.7.9), you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference.
@@ -179,3 +183,83 @@ Finally, restart the gunicorn and RQ services:
 ```no-highlight
 sudo systemctl restart netbox netbox-rq
 ```
+
+## Upgrade a Python Package Installation (Experimental)
+
+!!! warning "Experimental installation method"
+    Installing NetBox from the Python package is experimental in NetBox v4.7 and is **not recommended for production use**. Test the upgrade and rollback procedures in a non-production environment before relying on them.
+
+This procedure applies only to a deployment created using the [Python package installation method](3b-python-package.md). A package installation does not use `upgrade.sh`; use the installed `netbox upgrade` command instead. For a release archive or Git installation, follow the [procedure above](#upgrade-a-release-archive-or-git-installation).
+
+Complete the preparation steps at the beginning of this page before proceeding.
+
+### 1. Stop the NetBox Services
+
+Stop the web application and background worker services before changing packages in the virtual environment:
+
+```no-highlight
+sudo systemctl stop netbox netbox-rq
+```
+
+### 2. Upgrade NetBox and Local Requirements
+
+Install the target NetBox version into the existing virtual environment. Replace `X.Y.Z` with the exact version being installed:
+
+```no-highlight
+sudo /opt/netbox/venv/bin/python -m pip install --upgrade "netbox==X.Y.Z"
+```
+
+If the deployment uses a package extra, include it in the upgrade command. For example, specify the `ldap` extra again when upgrading a deployment that uses LDAP authentication:
+
+```no-highlight
+sudo /opt/netbox/venv/bin/python -m pip install --upgrade \
+    "netbox[ldap]==X.Y.Z"
+```
+
+Install all plugins and other local Python requirements into the same virtual environment **before** running the NetBox upgrade tasks:
+
+```no-highlight
+sudo /opt/netbox/venv/bin/python -m pip install \
+    -r /opt/netbox/local_requirements.txt
+```
+
+!!! note "Changing the Python version"
+    A virtual environment cannot be moved to a different Python interpreter in place. If the target NetBox release requires another Python version, create a replacement virtual environment, install the target NetBox package and all local requirements into it, and update the service executable paths before restarting NetBox.
+
+### 3. Run the Upgrade Tasks
+
+Run the packaged upgrade command to apply database migrations, collect static files, and perform the remaining application upgrade tasks:
+
+```no-highlight
+sudo -u netbox /opt/netbox/venv/bin/netbox upgrade --no-input
+```
+
+For a non-default instance root or a virtual environment stored elsewhere, use the applicable paths and set `NETBOX_ROOT` explicitly:
+
+```no-highlight
+sudo -u netbox env NETBOX_ROOT=/srv/netbox \
+    /opt/netbox-venv/bin/netbox upgrade --no-input
+```
+
+Ensure that any environment variables referenced by the NetBox configuration are also available when running this command.
+
+### 4. Review the Deployment Configuration
+
+`netbox setup` is not part of a routine upgrade. It leaves existing configuration and deployment examples untouched. To compare the examples bundled with the new package against the local copies without modifying the instance root, scaffold them into a temporary directory:
+
+```no-highlight
+EXAMPLES_DIR=$(mktemp -d)
+/opt/netbox/venv/bin/netbox setup --target "$EXAMPLES_DIR"
+diff --recursive /opt/netbox/contrib "$EXAMPLES_DIR/contrib"
+rm -rf "$EXAMPLES_DIR"
+```
+
+The comparison will also show the package-layout changes made when the deployment examples were first adapted. Distinguish these local changes from updates introduced by the new release, and merge any relevant updates into the administrator-managed systemd, WSGI, and HTTP server configuration.
+
+### 5. Start the NetBox Services
+
+Start the services and verify that both the web application and background workers are operating normally:
+
+```no-highlight
+sudo systemctl start netbox netbox-rq
+```

+ 48 - 1
docs/integrations/rest-api.md

@@ -741,6 +741,53 @@ http://netbox/api/dcim/sites/ \
 !!! note
     The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted.
 
+## Background Processing
+
+!!! info "This feature was introduced in NetBox v4.7."
+
+Bulk write operations (creating, updating, or deleting multiple objects via a model's list endpoint) can optionally be processed as a [background job](../features/background-jobs.md) rather than synchronously. This is useful for large batches that would otherwise hold the connection open long enough to risk a proxy or gateway timeout.
+
+To request background processing, append the `background=true` query parameter to a bulk write request. NetBox enqueues a job and returns an `HTTP 202 Accepted` response containing the job's ID and URL. The actual write is performed later by a worker, running the same logic (and preserving the same all-or-none transaction semantics) as the synchronous path. Note that the request payload is **not** validated before the job is enqueued; validation is deferred to the worker (see below).
+
+```no-highlight
+curl -s -X PATCH \
+-H "Authorization: Token $TOKEN" \
+-H "Content-Type: application/json" \
+http://netbox/api/dcim/sites/?background=true \
+--data '[{"id": 10, "status": "active"}, {"id": 11, "status": "active"}]'
+```
+
+The response identifies the enqueued job:
+
+```json
+{
+    "job": {
+        "id": 42,
+        "url": "http://netbox/api/core/jobs/42/",
+        "status": "pending"
+    }
+}
+```
+
+Poll the job's URL to track its progress. When the job reaches a terminal status, its `data` field holds the result and its `error` field describes any failure. The `data` field mirrors the response the synchronous request would have returned, as an object with the HTTP `status_code` and the response `data`. For example, a completed bulk update records:
+
+```json
+{
+    "status_code": 200,
+    "data": [
+        {"id": 10, "url": "http://netbox/api/dcim/sites/10/", "status": {"value": "active"}, "...": "..."}
+    ]
+}
+```
+
+A failed job records the equivalent error response, for instance `{"status_code": 400, "data": {"slug": ["This field may not be blank."]}}`, with a short summary also placed in the job's `error` field.
+
+A `202` response indicates that the request was accepted and queued, not that it succeeded: validation (including malformed or invalid payloads) and the database write all occur when the job runs. A rejected payload is therefore reported as a failed job rather than a synchronous error response. Always inspect the job's final status to confirm the outcome. Because the result is stored on the job, any user permitted to view jobs (`core.view_job`, subject to object permissions) can read the serialized objects it contains.
+
+Background processing applies only to bulk operations (a JSON list) on a model's list endpoint. For a single-object write the `background` parameter is ignored and the request is processed synchronously. It cannot be combined with an [`If-Match`](#if-match) precondition (which cannot be evaluated reliably once execution is deferred); such a request is rejected with an `HTTP 400` response. If no background worker is running to service the queue, the request is rejected with an `HTTP 503` response rather than enqueuing a job that would never run.
+
+Two behaviors differ from a synchronous request and may change in a future release: field selection via [`fields`/`omit`](#specifying-fields) (and brief mode) is not applied to the stored result, and the authorization captured when the request is accepted is not re-checked if the token is later disabled or expires before the job runs.
+
 ## Changelog Messages
 
 Most objects in NetBox support [change logging](../features/change-logging.md), which generates a detailed record each time an object is created, modified, or deleted. Additionally, users can attach a message to the change record as well. This is accomplished via the REST API by including a `changelog_message` field in the object representation.
@@ -784,7 +831,7 @@ The NetBox REST API primarily employs token-based authentication. For convenienc
 
 ### Tokens
 
-A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically populate a randomly-generated token value.
+A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically generate a random token value. This value is always generated by the server and cannot be specified by the client; any `token` value included in a creation request is ignored.
 
 !!! note "Tokens cannot be retrieved once created"
     Once a token has been created, its plaintext value cannot be retrieved. For this reason, you must take care to securely record the token locally immediately upon its creation. If a token plaintext is lost, it cannot be recovered: A new token must be created.

+ 3 - 10
docs/integrations/webhooks.md

@@ -17,7 +17,7 @@ For example, you might create a NetBox webhook to [trigger a Slack message](http
 * HTTP method: `POST`
 * URL: Slack incoming webhook URL
 * HTTP content type: `application/json`
-* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ username }}!"}`
+* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ request.user }}!"}`
 
 ### Available Context
 
@@ -30,16 +30,11 @@ The following data is available as context for Jinja2 templates:
     * `request.id` - The UUID associated with the request
     * `request.method` - The HTTP method (e.g. `GET` or `POST`)
     * `request.path` - The URL path (ex: `/dcim/sites/123/edit/`)
+    * `request.path_info` - The URL path below the application script prefix
+    * `request.GET` - The query parameters included in the request
     * `request.user` - The name of the authenticated user who made the request (if available)
 * `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
 * `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
-* ⚠️ `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
-* ⚠️ `username` - The name of the user account associated with the change.
-
-!!! warning "Deprecation of legacy keys"
-    The `request_id` and `username` keys in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
-
-    Use `request.user` and `request.id` from the `request` object included in the callback context instead.
 
 ### Sanitizing Header Values
 
@@ -60,8 +55,6 @@ If no body template is specified, the request body will be populated with a JSON
     "event": "created",
     "timestamp": "2026-03-06T15:11:23.503186+00:00",
     "object_type": "dcim.site",
-    "username": "jstretch",
-    "request_id": "17af32f0-852a-46ca-a7d4-33ecd0c13de6",
     "data": {
         "id": 4,
         "url": "/api/dcim/sites/4/",

+ 1 - 3
docs/introduction.md

@@ -79,7 +79,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
 | HTTP service       | nginx or Apache   |
 | WSGI service       | gunicorn or uWSGI |
 | Application        | Django/Python     |
-| Database           | PostgreSQL 14+ [^1] |
+| Database           | PostgreSQL 15+    |
 | Task queuing       | Redis/django-rq   |
-
-[^1]: Support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7. PostgreSQL 15 or later will be required.

+ 4 - 0
docs/models/core/job.md

@@ -28,6 +28,10 @@ The interval (in minutes) at which a scheduled job should re-execute.
 
 The date and time at which the job completed (if complete).
 
+### Execution Time
+
+The amount of time the job spent executing, calculated as the difference between its start and completion times. This is populated only once a started job has completed.
+
 ### User
 
 The user who created the job.

+ 3 - 1
docs/models/dcim/cable.md

@@ -34,7 +34,9 @@ The profile to which the cable conforms. The profile determines the mapping of t
 
 A single-position cable is allowed only one termination point at each end. There is no limit to the number of terminations a multi-position cable may have. Each end of a cable must have the same number of terminations, unless connected to a pass-through port or to a circuit termination.
 
-The assignment of a cable profile is optional. If no profile is assigned, legacy tracing behavior will be preserved.
+The assignment of a cable profile is optional. If no profile is assigned, legacy tracing behavior will be preserved. Note that a cable's profile is what maps each termination to a connector and position: a cable carrying multiple terminations on an end but having no profile assigned is permitted, but NetBox cannot map its positions across the cable. Assign a profile to model a breakout cable whose individual positions must be traced.
+
+When creating cables in bulk, each side accepts a comma-separated list of termination names, along with either a single parent device (or power panel) shared by all of them or one parent per name. Terminations are assigned to connectors in the order given, so the order of these lists determines how the cable is wired.
 
 ### Type
 

+ 37 - 0
docs/models/dcim/coolingfeed.md

@@ -0,0 +1,37 @@
+# Cooling Feed
+
+A cooling feed represents a coolant loop delivered from a [cooling source](./coolingsource.md) to a particular rack or coolant distribution unit (CDU). The [cooling intakes](./coolingintake.md) a feed supplies are derived from the devices installed in the rack it serves, rather than referenced explicitly.
+
+A single feed represents the entire loop, covering both the supply (cold) and return (warm) paths.
+
+!!! tip
+    In-rack cooling equipment — coolant distribution units (CDUs), manifolds, and rear-door heat exchangers (RDHx) — is modeled as an ordinary (typically zero-U) [device](./device.md) installed in the rack. The device's make and model come from its [device type](./devicetype.md), and a [cooling intake](./coolingintake.md) component connects it to cooling. The feed serving such a device is derived from its rack.
+
+## Fields
+
+### Cooling Source
+
+The [cooling source](./coolingsource.md) which supplies this feed.
+
+### Rack
+
+The [rack](./rack.md) which this feed serves (optional).
+
+### Name
+
+The feed's name or identifier. Must be unique to the assigned cooling source.
+
+### Status
+
+The feed's operational status.
+
+!!! tip
+    Additional statuses may be defined by setting `CoolingFeed.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
+
+### Cooling Capacity
+
+The heat-removal capacity of the feed, in kilowatts (kW).
+
+### Maximum Flow
+
+The maximum rate of coolant flow supported by the feed, expressed as a numeric value with a selectable unit (liters per minute, cubic meters per hour, or gallons per minute). Must be a positive, non-zero value in the selected unit, or left blank.

+ 40 - 0
docs/models/dcim/coolingintake.md

@@ -0,0 +1,40 @@
+# Cooling Intakes
+
+A cooling intake is a device component which consumes coolant, such as a server cold-plate inlet or a coolant distribution unit (CDU) intake. It **receives** coolant from the cold, supply side of a loop (see [cooling](../../features/cooling.md) for the overall flow model). A cooling intake optionally references the upstream [cooling outflow](./coolingoutflow.md) which supplies it.
+
+!!! tip
+    Like most device components, cooling intakes are instantiated automatically from [cooling intake templates](./coolingintaketemplate.md) assigned to the selected device type when a device is created.
+
+## Fields
+
+### Device
+
+The device to which this cooling intake belongs.
+
+### Module
+
+The installed module within the assigned device to which this cooling intake belongs (optional).
+
+### Name
+
+The name of the cooling intake. Must be unique to the parent device.
+
+### Label
+
+An alternative physical label identifying the cooling intake.
+
+### Connector Type
+
+The physical coolant connector type (e.g. UQD, UQDB, QDC, camlock, or threaded NPT/BSP).
+
+### Diameter
+
+The connector diameter, expressed as a numeric value with a selectable unit (millimeters, centimeters, or inches). Must be a positive, non-zero value in the selected unit, or left blank.
+
+### Maximum Flow
+
+The maximum coolant flow rate this port supports, expressed as a numeric value with a selectable unit (liters per minute, cubic meters per hour, or gallons per minute). Must be a positive, non-zero value in the selected unit, or left blank.
+
+### Cooling Outflow
+
+The upstream [cooling outflow](./coolingoutflow.md) which supplies this intake (optional).

+ 3 - 0
docs/models/dcim/coolingintaketemplate.md

@@ -0,0 +1,3 @@
+# Cooling Intake Templates
+
+A template for a cooling intake that will be created on all instantiations of the parent device type. See the [cooling intake](./coolingintake.md) documentation for more detail.

+ 38 - 0
docs/models/dcim/coolingoutflow.md

@@ -0,0 +1,38 @@
+# Cooling Outflows
+
+A cooling outflow is a device component which delivers coolant to a downstream [cooling intake](./coolingintake.md), and generally represents an outlet on a coolant distribution unit (CDU) or manifold. A cooling outflow may optionally be associated with an upstream cooling intake on the same device for path tracing.
+
+A cooling outflow is a **supply** point on the cold, coolant-distribution side of a loop: it passes coolant onward to downstream equipment. It does **not** represent the return of warmed coolant back to the cooling source. The return path is not modeled per-component; instead, a single [cooling feed](./coolingfeed.md) represents the entire loop, covering both the supply (cold) and return (warm) paths.
+
+!!! tip
+    Like most device components, cooling outflows are instantiated automatically from [cooling outflow templates](./coolingoutflowtemplate.md) assigned to the selected device type when a device is created.
+
+## Fields
+
+### Device
+
+The device to which this cooling outflow belongs.
+
+### Module
+
+The installed module within the assigned device to which this cooling outflow belongs (optional).
+
+### Name
+
+The name of the cooling outflow. Must be unique to the parent device.
+
+### Label
+
+An alternative physical label identifying the cooling outflow.
+
+### Connector Type
+
+The physical coolant connector type (e.g. UQD, UQDB, QDC, camlock, or threaded NPT/BSP).
+
+### Diameter
+
+The connector diameter, expressed as a numeric value with a selectable unit (millimeters, centimeters, or inches). Must be a positive, non-zero value in the selected unit, or left blank.
+
+### Cooling Intake
+
+The upstream [cooling intake](./coolingintake.md) on the same device which feeds this outlet (optional).

+ 3 - 0
docs/models/dcim/coolingoutflowtemplate.md

@@ -0,0 +1,3 @@
+# Cooling Outflow Templates
+
+A template for a cooling outflow that will be created on all instantiations of the parent device type. See the [cooling outflow](./coolingoutflow.md) documentation for more detail.

+ 36 - 0
docs/models/dcim/coolingsource.md

@@ -0,0 +1,36 @@
+# Cooling Source
+
+A cooling source represents a facility-level source of cooling, such as a chiller, cooling tower, or dry cooler. It serves as the upstream origin for one or more [cooling feeds](./coolingfeed.md) which distribute coolant to racks and devices. A cooling source is not modeled as a device; it represents external facility plant.
+
+## Fields
+
+### Site
+
+The [site](./site.md) at which the cooling source is located.
+
+### Location
+
+The [location](./location.md) within the site where the cooling source resides (optional).
+
+### Name
+
+The cooling source's name or identifier. Must be unique to the assigned site.
+
+### Type
+
+The type of cooling plant (e.g. chiller, cooling tower, dry cooler, CRAC, or CRAH).
+
+### Status
+
+The operational status of the cooling source.
+
+!!! tip
+    Additional statuses may be defined by setting `CoolingSource.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
+
+### Fluid Type
+
+The coolant used by the source (e.g. water, water/glycol, dielectric fluid, or refrigerant).
+
+### Cooling Capacity
+
+The total heat-removal capacity of the source, expressed in kilowatts (kW).

+ 4 - 0
docs/models/dcim/device.md

@@ -30,6 +30,10 @@ The hardware [device type](./devicetype.md) which defines the device's make & mo
 
 The direction in which air circulates through the device chassis for cooling.
 
+### Cooling Method
+
+The cooling method employed by the device (air, liquid, hybrid, or immersion). If not set, this is inherited from the assigned [device type](./devicetype.md) when the device is created.
+
 ### Serial Number
 
 The unique physical serial number assigned to this device by its manufacturer.

+ 8 - 0
docs/models/dcim/devicetype.md

@@ -57,10 +57,18 @@ Indicates whether this is a parent type (capable of housing child devices), a ch
 
 The default direction in which airflow circulates within the device chassis. This may be configured differently for instantiated devices (e.g. because of different fan modules).
 
+### Cooling Method
+
+The default cooling method employed by devices of this type (air, liquid, hybrid, or immersion). Instantiated devices inherit this value unless overridden.
+
 ### Weight
 
 The numeric weight of the device, including a unit designation (e.g. 10 kilograms or 20 pounds).
 
+### End of Life
+
+The date after which this device type is no longer supported by its manufacturer. This can be used to identify devices approaching or past their support horizon to aid in hardware lifecycle planning.
+
 ### Front & Rear Images
 
 Users can upload illustrations of the device's front and rear panels. If present, these will be used to render the device in [rack](./rack.md) elevation diagrams.

+ 15 - 2
docs/models/dcim/interface.md

@@ -28,11 +28,17 @@ An alternative physical label identifying the interface.
 
 ### Type
 
-The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables.
+The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. The generic **channel** type identifies a [channelized subinterface](#channel-id) bound to a parent interface.
 
 !!! note
     The interface type refers to the physical termination or port on the device. Interfaces which employ a removable optic or similar transceiver should be defined to represent the type of transceiver in use, irrespective of the physical termination to that transceiver.
 
+### Channels
+
+For a channelized (breakout) interface, the number of physical channels into which the interface is divided. For example, a 40GE interface broken out into four 10GE channels would have `channels` set to four. Each channel is modeled as a channel-type subinterface bound to this interface via its [channel ID](#channel-id).
+
+A single physical cable terminates to the channelized (parent) interface, occupying one connector shared by all of its channels; NetBox traces a distinct cable path for each channel subinterface. Only one layer of channelization is supported: an interface cannot be both channelized and itself bound to a channel.
+
 ### Speed
 
 The operating speed, in kilobits per second (kbps).
@@ -78,11 +84,18 @@ If selected, this component will be treated as if a cable has been connected.
 
 ### Parent Interface
 
-Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface.
+Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface. Channel-type subinterfaces are likewise bound to their [channelized](#channels) parent interface.
 
 !!! note
     An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned.
 
+### Channel ID
+
+For a channel-type subinterface, the numeric channel on its [channelized](#channels) parent interface to which this subinterface is bound. The channel ID must fall within the range of channels provided by the parent (e.g. one through four for a parent with four channels). A channel subinterface derives its cable connection from the parent's; it cannot be cabled directly.
+
+!!! note "Channel IDs are one-indexed"
+    Channel IDs increment starting at one, even for interfaces with a zero-based identifier. This ensures that each subinterface maps cleanly to the profile of an attached cable.
+
 ### Bridged Interface
 
 Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped.

+ 14 - 0
docs/models/dcim/module.md

@@ -4,6 +4,16 @@ A module is a field-replaceable hardware component installed within a device whi
 
 Similar to devices, modules are instantiated from [module types](./moduletype.md), and any components associated with the module type are automatically instantiated on the new model. Each module must be installed within a [module bay](./modulebay.md) on a [device](./device.md), and each module bay may have only one module installed in it.
 
+## Moving Modules
+
+An installed module can be moved to a different module bay after creation. The destination bay must be enabled and unoccupied. Moving a module relocates its entire subtree: the components installed by the module, the module bays belonging to it, and any child modules installed within those bays.
+
+Component names, labels, and module bay positions derived from the module type's templates (for example, names containing `{module}`) are re-resolved for the destination bay. A component is renamed only when its current name matches exactly one of the module type's templates as resolved for the source bay; components whose names do not match any template resolution (including manually renamed components) are preserved as-is. All resulting names are validated against the destination device before the move is applied. A move is rejected when a template-derived name, label, or position would exceed the destination field's maximum length. A move is also rejected when a component's current value matched a template for the source bay but that template cannot be resolved for the destination bay's nesting depth.
+
+Moving a module to a different device is supported only when the moved components carry no active topology or device-scoped configuration. A cross-device move is rejected while any moved component is cabled or marked as connected, has attached inventory items, or any moved interface has IP addresses, FHRP group assignments, tunnel terminations, L2VPN terminations, virtual circuit terminations, wireless links, wireless LAN assignments, VLANs (untagged, tagged, or Q-in-Q service), a VLAN translation policy, VDC assignments, or a VRF. A parent, bridge, LAG, power outlet to power port, or front/rear port mapping relation crossing the moved module's boundary in either direction also blocks the move. MAC addresses move together with their interfaces.
+
+Via the REST API, a module can be moved by patching only `module_bay`; the device is derived from the target bay. Changing a module's type and moving it must be performed as separate operations.
+
 ## Fields
 
 ### Device
@@ -40,3 +50,7 @@ Controls whether templates module type components are automatically added when c
 ### Adopt Components
 
 Controls whether pre-existing components assigned to the device with the same names as components that would be created automatically will be assigned to the new module.
+
+## Bay Type Compatibility
+
+If the module bay has [bay types](./modulebaytype.md) assigned and the module's type also has bay types assigned, NetBox verifies that the two sets share at least one type in common. An installation that fails this check will be rejected. The `is_bay_compatible` flag is exposed in the REST API to indicate compatibility status without performing a write.

+ 4 - 0
docs/models/dcim/modulebay.md

@@ -30,6 +30,10 @@ An alternative physical label identifying the module bay.
 
 The numeric position in which this module bay is situated. For example, this would be the number assigned to a slot within a chassis-based switch.
 
+### Bay Types
+
+Zero or more [module bay types](./modulebaytype.md) assigned to this bay. When at least one bay type is set, only module types that share a common bay type may be installed. Leave empty to allow any module type.
+
 ### Enabled
 
 Whether this module bay is enabled. Disabled module bays are not available for installation.

+ 2 - 0
docs/models/dcim/modulebaytemplate.md

@@ -1,3 +1,5 @@
 # Module Bay Templates
 
 A template for a module bay that will be created on all instantiations of the parent device type. See the [module bay](./modulebay.md) documentation for more detail.
+
+[Bay types](./modulebaytype.md) assigned to a module bay template are copied to each instantiated module bay, so constraints defined on the device type propagate automatically to all devices of that type.

+ 35 - 0
docs/models/dcim/modulebaytype.md

@@ -0,0 +1,35 @@
+# Module Bay Types
+
+Module bay types are user-defined labels that can be assigned to [module bays](./modulebay.md) and [module types](./moduletype.md) to restrict which modules may be installed into which bays. This is useful for modeling chassis hardware where not every bay accepts every type of line card.
+
+When **both** a module bay and the module type being installed have at least one bay type assigned, NetBox will check for a non-empty intersection. If the two sets share no bay types in common, the installation will be rejected as incompatible.
+
+If either the bay or the module type has **no bay types assigned**, the constraint is not applied and any module type may be installed — this preserves backwards compatibility with existing data.
+
+!!! tip
+    Bay types function as an allow-list: assign the same type to a bay and to the module types that fit it, and leave the type unset on bays or module types where no restriction is needed.
+
+!!! note "GraphQL naming"
+    In the GraphQL API, the type for the `ModuleBay` *component* is named `ModuleBayType` (following the project's `<Model>Type` suffix convention), while the type for the `ModuleBayType` *model* is named `ModuleBayTypeType`. This is an unavoidable consequence of the naming convention colliding with this model's name.
+
+## Fields
+
+### Name
+
+A unique human-readable name for the bay type (e.g. `LC Line Card`, `Power Supply`, `Fan Tray`).
+
+### Slug
+
+A URL-friendly identifier derived from the name.
+
+### Manufacturer
+
+An optional [manufacturer](./manufacturer.md) associated with this bay type. Useful when a vendor uses proprietary slot designations.
+
+### Description
+
+A brief description of the bay type.
+
+### Comments
+
+Free-form Markdown-supported notes.

+ 12 - 0
docs/models/dcim/moduletype.md

@@ -75,10 +75,22 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms
 
 The direction in which air circulates through the device chassis for cooling.
 
+### Cooling Method
+
+The cooling method employed by modules of this type (air, liquid, hybrid, or immersion). This is useful for liquid-cooled modules such as direct-to-chip accelerator (OAM) modules or liquid-cooled line cards.
+
+### End of Life
+
+The date after which this module type is no longer supported by its manufacturer. This can be used to identify modules approaching or past their support horizon to aid in hardware lifecycle planning.
+
 ### Profile
 
 The assigned [profile](./moduletypeprofile.md) for the type of module. Profiles can be used to classify module types by function (e.g. power supply, hard disk, etc.), and they support the addition of user-configurable attributes on module types. The assignment of a module type to a profile is optional.
 
+### Bay Types
+
+Zero or more [module bay types](./modulebaytype.md) that this module type is compatible with. When at least one bay type is set, the module type may only be installed into bays that share a common type. Leave empty to allow installation into any bay.
+
 ### Attributes
 
 Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure.

+ 32 - 2
docs/models/dcim/rack.md

@@ -28,6 +28,9 @@ The rack's name or identifier. Must be unique to the rack's location, if assigne
 
 The [physical type](./racktype.md) of this rack. The rack type defines physical attributes such as height and weight.
 
+!!! warning "Rack type assignment will become mandatory"
+    Beginning in NetBox v5.0, the assignment of a rack type will be required, and several physical attributes will be inferred from it rather than being set directly on the rack. See the note under [Physical Attributes](#physical-attributes) below.
+
 ### Status
 
 Operational status.
@@ -51,5 +54,32 @@ The unique physical serial number assigned to this rack.
 
 A unique, locally-administered label used to identify hardware resources.
 
-!!! note
-    Some additional fields pertaining to physical attributes such as height and weight can also be defined on each rack, but should generally be defined instead on the [rack type](./racktype.md).
+### Cooling Capability
+
+Describes how the rack is able to cool the equipment installed in it, which indicates what kind of equipment it can accommodate:
+
+- **Air-only**: The rack is cooled by airflow only; no coolant is delivered to it. Only air-cooled equipment can be installed.
+- **Hybrid**: Coolant can be delivered to the rack (e.g. via a [cooling feed](./coolingfeed.md)), but it can also house air-cooled equipment. Suitable for mixed or hybrid deployments.
+- **Liquid-only**: The rack is intended exclusively for liquid-cooled equipment (such as direct-to-chip or immersion systems) and does not provide adequate air cooling on its own.
+
+This attribute documents the rack's intended use so that incompatible equipment—such as high-density liquid-cooled hardware in an air-only rack—can be identified. When the rack is assigned a [rack type](./racktype.md), this value is inherited from the rack type.
+
+### Cooling Capacity
+
+The rack's cooling capacity, expressed in kilowatts (kW). When the rack is assigned a [rack type](./racktype.md), this value is inherited from the rack type.
+
+## Physical Attributes
+
+Several physical attributes may be defined on each rack, including its width, height, outer dimensions, mounting depth, and weight. These should generally be defined on the [rack type](./racktype.md) assigned to the rack rather than on the rack itself.
+
+!!! warning "Some rack fields are deprecated"
+    The following fields have been **deprecated** on the rack model and are planned for removal in NetBox v5.0:
+
+    * Form factor
+    * Width
+    * Outer width
+    * Outer height
+    * Outer depth
+    * Outer unit
+
+    In a future release, the values for these attributes will be inferred from the rack's assigned [rack type](./racktype.md), which will become a mandatory assignment. Users are strongly encouraged to define these attributes on a rack type and assign it to each rack. (Note that the U height, starting unit, descending units, and mounting depth fields will be retained on the rack model, as these may legitimately vary among individual racks of the same type.)

+ 8 - 0
docs/models/dcim/racktype.md

@@ -54,6 +54,14 @@ The numeric weight of the rack, including a unit designation (e.g. 10 kilograms
 
 The maximum total weight capacity for all installed devices, inclusive of the rack itself.
 
+### Cooling Capability
+
+The rack design's coolant capability: air-only, hybrid, or liquid-only. Racks of this type inherit this value.
+
+### Cooling Capacity
+
+The rack design's cooling capacity, expressed in kilowatts (kW). Racks of this type inherit this value.
+
 ### Descending Units
 
 If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.)

+ 4 - 0
docs/models/extras/customfield.md

@@ -109,6 +109,10 @@ Choice sets may optionally define colors for individual values. Colored choices
 
 If enabled, values from this field will be automatically pre-populated when cloning existing objects.
 
+### Nulls First
+
+When ordering objects by this custom field, controls whether objects with no value (null) are sorted before or after objects that have a value. This option is enabled by default.
+
 ### Minimum Value
 
 For numeric custom fields only. The minimum valid value (optional).

+ 5 - 0
docs/models/extras/eventrule.md

@@ -47,6 +47,11 @@ The type of action to take when the rule triggers. This must be one of the follo
 * Custom script
 * Notification
 
+!!! tip "Custom Action Types"
+    The above list includes only built-in action types. NetBox plugins can also [register their own custom action types](../../plugins/development/event-rule-actions.md).
+
+    If the plugin providing an event rule's action type is uninstalled or disabled, the event rule is not deleted, but it is marked as unavailable and will not run. It also cannot be saved -- even to edit an unrelated field -- until either the plugin is reinstalled or the action type is changed to a currently-available one.
+
 ### Action Data
 
 An optional dictionary of JSON data to pass when executing the rule. This can be useful to include additional context data, e.g. when transmitting a webhook.

+ 14 - 4
docs/models/extras/webhook.md

@@ -87,6 +87,17 @@ Controls whether validation of the receiver's SSL certificate is enforced when H
 
 The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (if not using the system defaults).
 
+### Timeout
+
+The maximum time (in seconds) to wait for a response from the receiver before the request is considered failed. If left blank, the global [`WEBHOOK_DEFAULT_TIMEOUT`](../../configuration/miscellaneous.md#webhook_default_timeout) configuration value is used.
+
+The timeout must be less than [`RQ_DEFAULT_TIMEOUT`](../../configuration/miscellaneous.md#rq_default_timeout) (300 seconds by default), and NetBox will refuse to save a webhook which violates this. The background job timeout is a hard ceiling on how long a webhook request can run, so a value at or above it leaves no room for the request's own timeout to apply.
+
+!!! note
+    Staying below the job timeout makes it *likely*, but does not guarantee, that the request times out on its own. The timeout is applied separately to establishing the connection and to waiting for data, rather than to the request as a whole, so a receiver which stalls at both stages — or which responds slowly but continuously — can still outlast the job timeout and be terminated by the worker instead.
+
+When a request does time out, the failure is recorded by the `netbox.webhooks` logger and the background job is marked as failed.
+
 ## Context Data
 
 The following context variables are available to the text and link templates.
@@ -96,10 +107,9 @@ The following context variables are available to the text and link templates.
 | `event`       | The event type (`create`, `update`, or `delete`)     |
 | `timestamp`   | The time at which the event occurred                 |
 | `object_type` | The type of object impacted (`app_label.model_name`) |
-| `username`    | The name of the user associated with the change      |
-| `request_id`  | The unique request ID                                |
 | `data`        | A complete serialized representation of the object   |
 | `snapshots`   | Pre- and post-change snapshots of the object         |
+| `request`     | Data about the triggering request (if available)     |
 
-!!! warning "Deprecation of legacy fields"
-    The `request_id` and `username` fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0. Use `request.user` and `request.id` from the `request` object included in the callback context instead. (Note that `request` is populated in the context only when the webhook is associated with a triggering request.)
+!!! note
+    The `request` variable is populated in the context only when the webhook is associated with a triggering request. It exposes `request.id` (the unique request ID) and `request.user` (the name of the user associated with the change), among other attributes.

+ 52 - 4
docs/models/ipam/service.md

@@ -23,14 +23,62 @@ The parent object to which the application service is assigned. This must be one
 
 A service or protocol name.
 
-### Protocol
+### Port Mappings
 
-The wire protocol on which the service runs. Choices include UDP, TCP, and SCTP.
+The protocols and ports on which the service runs. A service may expose the same port on multiple protocols — for example, DNS listening on both `tcp/53` and `udp/53`. In the UI, ports for a given protocol may be entered together using commas and/or hyphens (e.g. `80,8001-8003`).
 
-### Ports
+In the REST and GraphQL APIs, port mappings are represented as a flat list of `protocol/port` strings — matching how they are stored:
 
-One or more numeric ports to which the service is bound. Multiple ports can be expressed using commas and/or hyphens. For example, `80,8001-8003` specifies ports 80, 8001, 8002, and 8003.
+```json
+[
+  "tcp/80",
+  "tcp/443",
+  "udp/53"
+]
+```
+
+!!! note "Changed in NetBox v4.7"
+
+    The single-protocol `protocol` and `ports` fields have been replaced by the unified `port_mappings` field, which supports multiple protocols per service. For backward compatibility, the REST and GraphQL APIs still expose the legacy `protocol` and `ports` fields, and the REST API still accepts them on write as an alternative to `port_mappings`. They are populated for single-protocol services; a service with multiple protocols cannot be represented in the legacy format and returns `null` for both, while a service with no mappings returns `protocol: null` and `ports: []`. In other words, `ports: null` specifically signals "multiple protocols — read `port_mappings` instead." **These legacy fields are deprecated and will be removed in NetBox v5.0; use `port_mappings` instead.**
+
+    On write, `port_mappings` and the legacy `protocol`/`ports` fields may be submitted together only when they agree — as in a full-object round-trip that echoes back a read. A request whose legacy fields contradict `port_mappings` (for example, an edited `port_mappings` sent alongside the stale `protocol`/`ports` from the original read) is rejected as ambiguous; send `port_mappings` alone, or keep the legacy fields consistent with it.
+
+    At the ORM level (custom scripts and plugins), `protocol` and `ports` are now **read-only** properties derived from `port_mappings`. Assign `port_mappings` directly — e.g. `Service(parent=device, name='http', port_mappings=['tcp/80'])` — since passing `protocol=`/`ports=` to the model raises `TypeError` and setting `service.ports = [...]` raises `AttributeError`.
+
+### Filtering by Port Mapping, Protocol, and Port
+
+`port_mappings`, `protocol`, and `port` are all filtered against the `port_mappings` array. Each accepts multiple values (matching any of them), and `port` supports the usual numeric lookups:
+
+| Parameter | Matches services having a mapping… |
+|---|---|
+| `?port_mappings=tcp/80` | that is exactly `tcp/80` |
+| `?port_mappings__n=tcp/80` | *(negated)* that is exactly `tcp/80` |
+| `?protocol=tcp` | whose protocol is TCP |
+| `?protocol__n=tcp` | *(negated)* whose protocol is TCP |
+| `?port=80` | whose port is 80 |
+| `?port__n=80` | *(negated)* whose port is 80 |
+| `?port__gt=` / `?port__gte=` / `?port__lt=` / `?port__lte=` | whose port is above/below the given value |
+
+`port_mappings` is the most direct way to ask "which services expose this exact protocol and port?" — `?port_mappings=tcp/80` will not match a service that exposes only `udp/80`. Protocols may be given in any case, and leading zeros are ignored, so `?port_mappings=TCP/080` finds `tcp/80`. A value naming an unknown protocol or a malformed pair simply matches nothing rather than returning an error.
+
+When `protocol` and one or more `port` lookups are combined, they must all be satisfied by a **single** mapping. So `?protocol=tcp&port__gt=1000` does not match a service whose only TCP mapping is `tcp/80` (even if it also exposes `udp/9999`), and `?port__gte=1000&port__lte=2000` does not match a service exposing only ports 500 and 5000. Each `port_mappings` value already names one complete pair, so it needs no such correlation and is simply combined with the other parameters.
+
+All of these parameters are available as GraphQL filters too, under the same names — `port_mappings`, `protocol`, `port`, `port__gt`, `port__gte`, `port__lt`, `port__lte` — each accepting a list of values. For example, `filters: {port_mappings: ["tcp/80"]}` or `filters: {protocol: [TCP], port__gt: [1000]}`. The single-mapping correlation rule described above applies identically.
+
+!!! warning "GraphQL filter change in NetBox v4.7"
+
+    The GraphQL filters for `Service` and `ServiceTemplate` have changed shape. The former `protocol` lookup and `ports` integer lookup (which nested their comparisons, e.g. `ports: {gt: 1000}`) are replaced by the flat `protocol`, `port`, `port__gt`, `port__gte`, `port__lt`, `port__lte`, and `port_mappings` parameters, each accepting a list of values and spelled the same way as the corresponding REST query parameter. Rewrite `ports: {gt: 1000}` as `port__gt: [1000]`, and `ports: {exact: 80}` as `port: [80]`. The `range` and `i_exact` lookups previously offered by the integer lookup have no direct equivalent; express a range as `port__gte`/`port__lte`, which — unlike the old lookup — requires a single mapping to satisfy both bounds.
+
+    The members of the `ServiceProtocolEnum` used by the `protocol` filter have also been renamed to drop a spurious `ROLE_` prefix: `ROLE_TCP`, `ROLE_UDP`, and `ROLE_SCTP` are now `TCP`, `UDP`, and `SCTP`.
+
+!!! warning "REST filter change in NetBox v4.7"
+
+    Because `protocol` is now filtered against the `port_mappings` array rather than a dedicated model field, the character-based lookup variants previously auto-generated for it — `protocol__ic`, `protocol__nic`, `protocol__isw`, `protocol__empty`, etc. — are no longer available; `protocol` and `protocol__n` remain. The `port__empty` lookup is likewise gone, as a service always has at least one port mapping. As with any unrecognized query parameter, the REST API silently ignores a removed lookup rather than raising an error, so update any saved filters or scripts that relied on them.
 
 ### IP Addresses
 
 The [IP address(es)](./ipaddress.md) to which this service is bound. If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.
+
+## Bulk Import (CSV)
+
+When importing application services or [application service templates](./servicetemplate.md) via CSV, all port mappings for a row are given in a single `port_mappings` column as a comma-separated list of `protocol/port` pairs enclosed in double quotes. For example, `"tcp/80,tcp/443,udp/53"`. Protocols may be specified in any case.

+ 4 - 4
docs/models/ipam/servicetemplate.md

@@ -12,10 +12,10 @@ Application service templates can be used to instantiate [application services](
 
 A service or protocol name.
 
-### Protocol
+### Port Mappings
 
-The wire protocol on which the service runs. Choices include UDP, TCP, and SCTP.
+The protocols and ports on which the service runs. See [Port Mappings](./service.md#port-mappings) on the application service model for details.
 
-### Ports
+## Bulk Import (CSV)
 
-One or more numeric ports to which the service is bound. Multiple ports can be expressed using commas and/or hyphens. For example, `80,8001-8003` specifies ports 80, 8001, 8002, and 8003.
+Application service templates are imported via CSV using the same `port_mappings` column format as application services. See [Bulk Import (CSV)](./service.md#bulk-import-csv) on the application service model for details.

+ 115 - 0
docs/plugins/development/config-templates.md

@@ -0,0 +1,115 @@
+# Jinja Config Templates
+
+NetBox uses [Jinja](https://jinja.palletsprojects.com/) to render [configuration templates](../../features/configuration-rendering.md). Plugins can extend this rendering pipeline in two complementary ways:
+
+1. **Register custom filters** — make new template filters available by name in every config template.
+2. **Inject context variables** — add extra variables that are available inside every config template render.
+
+---
+
+## Registering Jinja Filters
+
+### Via `jinja_env.py` (auto-discovery)
+
+Create a file named `jinja_env.py` in your plugin root and expose a dict called `filters`. NetBox will auto-discover and register it when the plugin loads.
+
+```python title="my_plugin/jinja_env.py"
+def prefix_list(device):
+    """Return all prefixes assigned to a device's interfaces."""
+    return [
+        str(ip.address)
+        for iface in device.interfaces.all()
+        for ip in iface.ip_addresses.all()
+    ]
+
+filters = {
+    'prefix_list': prefix_list,
+}
+```
+
+The filter is then available in any config template:
+
+```jinja2
+{% for prefix in device | prefix_list %}
+  network {{ prefix }}
+{% endfor %}
+```
+
+### Via `register_jinja_filters()`
+
+You can also register filters programmatically inside your plugin's `ready()` method:
+
+```python title="my_plugin/__init__.py"
+from netbox.plugins import PluginConfig
+
+class MyPluginConfig(PluginConfig):
+    name = 'my_plugin'
+    # ...
+
+    def ready(self):
+        super().ready()
+        from netbox.plugins.registration import register_jinja_filters
+        from .jinja_env import filters
+        register_jinja_filters(filters)
+```
+
+`register_jinja_filters()` accepts a `dict` mapping filter names to callables. It raises `TypeError` if passed a non-dict or if any value is not callable.
+
+### Precedence
+
+The full filter precedence from lowest to highest is: **NetBox built-in filters** (e.g. `env`) → **plugin-registered filters** → **instance [`JINJA_FILTERS`](../../configuration/system.md#jinja_filters)**. Instance-level filters always win, so site admins can override anything without touching a plugin.
+
+If two plugins register a filter with the same name, the later-loaded plugin's version wins and NetBox will log a warning.
+
+For example, if `my_plugin` registers a `prefix_list` filter but a site needs different behaviour, the operator can replace it in `configuration.py` without touching the plugin:
+
+```python title="configuration.py"
+def prefix_list(device):
+    # Site-local override: include only loopback prefixes
+    return [
+        str(ip.address)
+        for iface in device.interfaces.filter(type='loopback')
+        for ip in iface.ip_addresses.all()
+    ]
+
+JINJA_FILTERS = {
+    'prefix_list': prefix_list,
+}
+```
+
+---
+
+## Injecting Context Variables
+
+Override `get_jinja_context()` in your `PluginConfig` subclass to inject additional variables into every config template render context.
+
+```python title="my_plugin/__init__.py"
+from netbox.plugins import PluginConfig
+
+class MyPluginConfig(PluginConfig):
+    name = 'my_plugin'
+    # ...
+
+    def get_jinja_context(self):
+        from .utils import MyNamespace
+        return {
+            'my_plugin': MyNamespace(),
+        }
+```
+
+The returned dict is merged into the template context, so `my_plugin` becomes available by name inside every config template:
+
+```jinja2
+{% set records = my_plugin.lookup(device.name) %}
+```
+
+!!! warning "Startup cost"
+    `get_jinja_context()` is called on **every** config template render, not once at startup. Keep it fast. Defer expensive lookups to the object you return rather than performing them in `get_jinja_context()` itself.
+
+!!! note "Conflict avoidance"
+    Choose context variable names that are unlikely to collide with NetBox's built-in template variables (`device`, `queryset`, etc.) or with those contributed by other plugins. Prefixing with your plugin name is strongly recommended.
+
+    In addition, avoid top-level app-label names (`dcim`, `ipam`, `virtualization`, etc.). The auto-populated template context maps each app label to a dict of its public model classes; returning a key like `'dcim'` from `get_jinja_context()` will silently replace that entire namespace.
+
+!!! note "No per-render context"
+    `get_jinja_context()` receives no arguments — it has no access to the object being rendered or the caller-supplied context. It is intended for plugin-global namespaces (e.g. a lazily-evaluated query helper). Per-object logic belongs in the template itself or in a custom filter.

+ 62 - 0
docs/plugins/development/event-rule-actions.md

@@ -0,0 +1,62 @@
+# Event Rule Actions
+
+[Event rules](../../models/extras/eventrule.md) dispatch to an *action* when a matching event occurs, such as sending a webhook request or running a script. Plugins can register their own action types to extend the list of actions an event rule can perform, by subclassing NetBox's `EventRuleAction` class.
+
+```python title="event_rules.py"
+from django.utils.translation import gettext_lazy as _
+from netbox.event_rules import EventRuleAction
+
+from .models import Ticket
+
+class OpenTicketAction(EventRuleAction):
+    slug = 'my_plugin.open_ticket'
+    label = _('Open ticket')
+    description = _('Open a ticket in the external ticketing system')
+    object_model = Ticket
+    object_required = True
+
+    def enqueue(self, *, event_rule, event_context, action_object, action_data):
+        ...
+```
+
+To register one or more event rule actions with NetBox, define a list named `event_rule_actions` at the end of this file:
+
+```python title="event_rules.py"
+event_rule_actions = [OpenTicketAction]
+```
+
+!!! tip
+    The path to the list of event rule actions can be modified by setting `event_rule_actions` in the PluginConfig instance.
+
+A dotted namespace prefix (e.g. `my_plugin.open_ticket`) is strongly recommended for `slug` to avoid collisions with other plugins or with action types added to NetBox core in the future.
+
+`slug` must begin with a lowercase letter, and may contain only letters, digits, underscores, and dot-separated segments thereafter. **Hyphens are not allowed**, even though they're common in plugin/package names -- use an underscore instead, e.g. `my_plugin.open_ticket` as in the example above. `register_event_rule_action()` raises `ImproperlyConfigured` immediately for a slug outside this pattern, rather than allowing it to fail later during GraphQL schema assembly.
+
+`slug`/`label` are only required at registration time, not at class definition, so an intermediate base class shared by several concrete actions may leave them unset.
+
+!!! warning "Actions must be stateless"
+    Registration instantiates the class once, and that single instance serves every event rule, request, and background worker thread for the lifetime of the process. Do not stash per-event data on `self` in `enqueue()` or `validate()` -- concurrent dispatches would race over it. Everything an action needs is passed in as an argument.
+
+## Target Objects
+
+If an action operates against a specific object (e.g. a webhook targets a `Webhook` instance, and a script targets a `Script` instance), set `object_model` to the relevant model class. NetBox uses this to render the object-selection field on the event rule form and to validate the selected object's type. `object_required` defaults to `False` (matching `object_model`'s default of `None`); set it to `True` alongside `object_model` if the target object must always be selected. (Setting `object_required` *without* an `object_model` raises `ImproperlyConfigured` at registration, as it could never be satisfied.) Override `get_object_queryset()` to customize which objects are eligible for selection (e.g. to filter or further restrict the queryset).
+
+The object-selection field is labeled with `object_model`'s verbose name; set `object_label` to override it.
+
+If an action leaves `object_model` as `None`, event rules using it must not specify a target object: supplying one is rejected as a validation error rather than being silently stored.
+
+## Bulk Import
+
+To support resolving a target object from a CSV value during bulk import of event rules, override `resolve_import_object()`. Raise `django.core.exceptions.ObjectDoesNotExist` (or a subclass) if the supplied value doesn't resolve to an object. If this method is not overridden, event rules using this action type cannot be targeted at an object via bulk import.
+
+## Unregistered Actions
+
+An event rule's `action_type` is stored as a plain string, and is not validated against the set of currently-registered actions at the database level. This means an event rule can reference an action type provided by a plugin that is later uninstalled or disabled, without the row being deleted or corrupted. While its action type is unavailable:
+
+* The event rule is skipped during event processing (it does not raise an error, and does not prevent other event rules from being processed).
+* It is displayed with an "unavailable" indicator in the UI. `action_is_available` is exposed as a read-only field via the REST API, and as a filter (`?action_is_available=false`), so affected event rules can be found in bulk.
+* It cannot be saved via the UI or REST API -- even to edit an unrelated field -- until its `action_type` is changed to a currently-registered value.
+
+Reinstalling the plugin (and thereby re-registering the action type) automatically restores the event rule to working order, with no need to re-save it.
+
+::: netbox.event_rules.EventRuleAction

+ 41 - 0
docs/plugins/development/forms.md

@@ -210,6 +210,35 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
     options:
       members: false
 
+## Static Choice Fields
+
+These fields render a standard HTML `<select>` element (as opposed to the API-backed widgets used by the dynamic object fields below). They extend Django's built-in choice fields to optionally display a short **description** beneath each option's label.
+
+For choice set-backed fields, descriptions are defined per choice using a `Choice` object in the `ChoiceSet` and are rendered automatically. Pass `show_descriptions=False` to suppress them for a particular field.
+
+```python
+from utilities.choices import Choice, ChoiceSet
+from utilities.forms.fields import ChoiceField
+
+class StatusChoices(ChoiceSet):
+    ACTIVE = 'active'
+    RETIRED = 'retired'
+    CHOICES = (
+        Choice(ACTIVE, 'Active', description='Currently in service'),
+        Choice(RETIRED, 'Retired', description='No longer in service'),
+    )
+
+status = ChoiceField(choices=StatusChoices)
+```
+
+::: utilities.forms.fields.ChoiceField
+    options:
+      members: false
+
+::: utilities.forms.fields.MultipleChoiceField
+    options:
+      members: false
+
 ## Dynamic Object Fields
 
 ::: utilities.forms.fields.DynamicModelChoiceField
@@ -230,6 +259,18 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
     options:
       members: false
 
+## Generic Object Fields
+
+`GenericObjectChoiceField` represents a generic foreign key (a `content_type` plus `object_id` pair) as a single, REST API-backed form field. Pair it with `GenericObjectFormMixin` on the form to seed the field's initial value from the model's GFK descriptor and assign the selected object back to it automatically.
+
+::: utilities.forms.fields.GenericObjectChoiceField
+    options:
+      members: false
+
+::: utilities.forms.mixins.GenericObjectFormMixin
+    options:
+      members: false
+
 ## CSV Import Fields
 
 ::: utilities.forms.fields.CSVChoiceField

+ 90 - 2
docs/plugins/development/graphql-api.md

@@ -25,9 +25,9 @@ class MyModelType:
 @strawberry.type
 class MyQuery:
     @strawberry.field
-    def dummymodel(self, id: int) -> DummyModelType:
+    def mymodel(self, id: int) -> MyModelType:
         return None
-    dummymodel_list: list[DummyModelType] = strawberry_django.field()
+    mymodel_list: list[MyModelType] = strawberry_django.field()
 
 
 schema = [
@@ -35,6 +35,94 @@ schema = [
 ]
 ```
 
+## Extending Core Types & Filters
+
+!!! info "This feature was introduced in NetBox v4.6."
+
+In addition to registering its own top-level query fields, a plugin can inject fields and filters onto NetBox's **existing** core GraphQL types (e.g. `DeviceType`). This allows a plugin's related data to be traversed within a single query rooted at a core object, rather than requiring a separate top-level query. This mirrors the `PluginTemplateExtension` mechanism used to extend core object views in the UI.
+
+An extension is a mixin class declaring a `models` attribute: a list of the lowercased `app_label.model` labels of the core types it extends. Output-type extensions are collected from `graphql.type_extensions` and filter extensions from `graphql.filter_extensions` by default; these paths can be overridden via the `graphql_type_extensions` and `graphql_filter_extensions` attributes on the PluginConfig.
+
+Each declared path must resolve to a list named `type_extensions` (or `filter_extensions`) - for example, defined in `graphql.py` alongside the schema, or re-exported from the plugin's `graphql` package.
+
+!!! warning
+    Do not import core GraphQL modules (e.g. `dcim.graphql.types`) from a plugin's `ready()`. Doing so assembles the affected core types before other plugins have registered their extensions, which are then silently dropped. A warning is logged under `netbox.graphql` if this occurs.
+
+### Type Extensions
+
+An output-type extension is a `@strawberry.type` class whose fields and resolvers are spliced into the target type:
+
+```python
+# graphql.py (or graphql/type_extensions.py)
+from typing import Annotated
+
+import strawberry
+import strawberry_django
+
+from utilities.querysets import RestrictedPrefetch
+from my_plugin.models import Widget
+
+
+@strawberry.type
+class DeviceTypeExtension:
+    models = ['dcim.device']
+
+    @strawberry_django.field(
+        prefetch_related=lambda info: RestrictedPrefetch(
+            'widgets', info.context.request.user, 'view', queryset=Widget.objects.all()
+        ),
+    )
+    def widgets(self) -> list[Annotated['WidgetType', strawberry.lazy('my_plugin.graphql.types')]]:
+        return self.widgets.all()
+
+
+type_extensions = [
+    DeviceTypeExtension,
+]
+```
+
+!!! note
+    Scope any related-object resolver with `RestrictedPrefetch(..., info.context.request.user, 'view', ...)`, as shown above. Object permissions are only applied to the top-level queryset, so a plain `prefetch_related='widgets'` returns related objects the requesting user may not be permitted to see.
+
+### Filter Extensions
+
+A filter extension is a `@strawberry.type` class declaring additional filters - either as annotated filter fields or as custom filter methods - which are spliced into the target filter:
+
+```python
+# graphql.py (or graphql/filter_extensions.py)
+import strawberry
+import strawberry_django
+from django.db.models import Q
+
+
+@strawberry.type
+class DeviceFilterExtension:
+    models = ['dcim.device']
+
+    @strawberry_django.filter_field()
+    def has_widgets(self, value: bool, prefix) -> Q:
+        return Q(**{f'{prefix}widgets__isnull': not value})
+
+
+filter_extensions = [
+    DeviceFilterExtension,
+]
+```
+
+With both registered, a client can fetch a device and its plugin-provided data in a single query:
+
+```graphql
+query {
+  device_list(filters: { has_widgets: true }) {
+    name
+    widgets { id name }
+  }
+}
+```
+
+!!! note
+    Extensions are strictly additive: they can only add new fields, never replace existing ones. If an extension declares a name the core type already provides, the core definition always takes precedence and the extension's version is ignored. If two extensions on the same type declare the same new name, the one whose plugin is loaded first (earlier in `PLUGINS`) wins. Both cases are logged as warnings under the `netbox.graphql` logger.
+
 ## GraphQL Objects
 
 NetBox provides two object type classes for use by plugins.

+ 4 - 0
docs/plugins/development/index.md

@@ -118,10 +118,14 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
 | `events_pipeline`     | A list of handlers to add to [`EVENTS_PIPELINE`](../../configuration/miscellaneous.md#events_pipeline), identified by dotted paths |
 | `search_indexes`      | The dotted path to the list of search index classes (default: `search.indexes`)                                                    |
 | `data_backends`       | The dotted path to the list of data source backend classes (default: `data_backends.backends`)                                     |
+| `event_rule_actions`  | The dotted path to the list of event rule action classes (default: `event_rules.event_rule_actions`)                               |
 | `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`)                        |
+| `jinja_filters`       | The dotted path to a dict of custom Jinja filter functions for use in config templates (default: `jinja_env.filters`)              |
 | `menu`                | The dotted path to a top-level navigation menu provided by the plugin (default: `navigation.menu`)                                 |
 | `menu_items`          | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`)                                |
 | `graphql_schema`      | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`)                                           |
+| `graphql_type_extensions` | The dotted path to the list of GraphQL output-type extension classes, if any (default: `graphql.type_extensions`)              |
+| `graphql_filter_extensions` | The dotted path to the list of GraphQL filter extension classes, if any (default: `graphql.filter_extensions`)              |
 | `user_preferences`    | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`)           |
 
 All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.

+ 67 - 0
docs/plugins/development/ui-components.md

@@ -53,6 +53,73 @@ class MyView(generic.ObjectView):
 
 ::: netbox.ui.layout.Column
 
+## Breadcrumbs
+
+Breadcrumbs are rendered at the top of an object's page to convey its position within a hierarchy and to provide quick navigation to related objects. By default, a single breadcrumb linking to the object's list view is shown. To add object-specific breadcrumbs, pass a list of `Breadcrumb` instances to your layout, just as you would its panels.
+
+A `Breadcrumb` typically references an _accessor_ (rather than a static value), which is resolved against the object being viewed when the page is rendered. The accessor may be a dotted attribute path or a callable. (A breadcrumb may instead define a static `label`; see below.)
+
+```python
+from netbox.ui import layout
+from netbox.ui.breadcrumbs import Breadcrumb
+from netbox.views import generic
+
+class MyView(generic.ObjectView):
+    layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('site'),
+            Breadcrumb('location'),
+            Breadcrumb('rack'),
+        ],
+        left_panels=[...],
+        right_panels=[...],
+    )
+```
+
+Each breadcrumb renders as a label (the string representation of the resolved object) and an optional link. If no explicit `url` is provided, the object's `get_absolute_url()` is used when available. A breadcrumb whose accessor resolves to `None` (or an empty iterable) renders as an empty string and is omitted, which simplifies conditional breadcrumbs (e.g. where a device may or may not be assigned to a rack).
+
+To link a breadcrumb somewhere other than the related object's own page (for example, to a filtered list view), pass a `url`. A callable `url` receives the resolved object:
+
+```python
+from django.urls import reverse
+
+Breadcrumb('rir', url=lambda rir: f"{reverse('ipam:asn_list')}?rir_id={rir.pk}")
+```
+
+A callable accessor which returns an iterable renders one breadcrumb per object, which is useful for representing a hierarchy of ancestors:
+
+```python
+Breadcrumb(lambda obj: obj.get_ancestors())
+```
+
+To render a breadcrumb that isn't tied to a related object, omit the accessor and pass a `label`. This is useful for linking to a parent view that isn't reachable via an attribute on the object (e.g. a user's personal token list):
+
+```python
+from django.urls import reverse_lazy
+
+Breadcrumb(label=_('My API Tokens'), url=reverse_lazy('account:usertoken_list'))
+```
+
+The `label` may also be a callable, which receives the relevant object (the resolved related object when an accessor is given, otherwise the viewed instance). This is useful for an unlinked descriptive crumb derived from the object:
+
+```python
+Breadcrumb(label=lambda obj: f"{_('Units')} {obj.unit_list}")
+```
+
+The default root breadcrumb (linking to the object's list view) is prepended to the trail automatically. Where that list view isn't an appropriate root—for example, the global token list is admin-only, so a user's personal token page links to their own token list instead—pass `root_breadcrumb=False` to the layout and supply a replacement as the first breadcrumb:
+
+```python
+SimpleLayout(
+    root_breadcrumb=False,
+    breadcrumbs=[
+        Breadcrumb(label=_('My API Tokens'), url=reverse_lazy('account:usertoken_list')),
+    ],
+    ...
+)
+```
+
+::: netbox.ui.breadcrumbs.Breadcrumb
+
 ## Panels
 
 Within each column, related blocks of content are arranged into panels. Each panel has a title and may have a set of associated actions, but the content within is otherwise arbitrary.

+ 0 - 7
docs/plugins/development/webhooks.md

@@ -29,8 +29,6 @@ The resulting webhook payload will look like the following:
     "event": "updated",
     "timestamp": "2025-08-07T14:24:30.627321+00:00",
     "object_type": "dcim.site",
-    "username": "admin",
-    "request_id": "49e3e39e-7333-4b9c-a9af-19f0dc1e7dc9",
     "data": {
         "id": 2,
         "url": "/api/dcim/sites/2/",
@@ -44,11 +42,6 @@ The resulting webhook payload will look like the following:
 }
 ```
 
-!!! warning "Deprecation of legacy keys"
-    The `request_id` and `username` keys in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
-
-    Use `request.user` and `request.id` from the `request` object included in the callback context instead.
-
 !!! note "Consider namespacing webhook data"
     The data returned from all webhook callbacks will be compiled into a single `context` dictionary. Any existing keys within this dictionary will be overwritten by subsequent callbacks which include those keys. To avoid collisions with webhook data provided by other plugins, consider namespacing your plugin's data within a nested dictionary as such:
     

+ 57 - 1
docs/reference/conditions.md

@@ -9,7 +9,7 @@ A condition is expressed as a JSON object with the following keys:
 | Key name | Required | Default | Description |
 |----------|----------|---------|-------------|
 | attr     | Yes      | -       | Name of the key within the data being evaluated |
-| value    | Yes      | -       | The reference value to which the given data will be compared |
+| value    | See note | -       | The reference value to which the given data will be compared. Not used by snapshot operators (`changed`, `unchanged`). |
 | op       | No       | `eq`    | The logical operation to be performed |
 | negate   | No       | False   | Negate (invert) the result of the condition's evaluation |
 
@@ -22,6 +22,9 @@ A condition is expressed as a JSON object with the following keys:
 * `lte`: Less than or equal to
 * `in`: Is present within a list of values
 * `contains`: Contains the specified value
+* `regex`: Matches a regular expression
+* `changed`: The attribute's value differs between the pre-change and post-change snapshots (no `value` required)
+* `unchanged`: The attribute's value is the same in both snapshots (no `value` required)
 
 ### Accessing Nested Keys
 
@@ -91,6 +94,59 @@ The following condition will evaluate as true:
 !!! note "Evaluating static choice fields"
     Pay close attention when evaluating static choice fields, such as the `status` field above. These fields typically render as a dictionary specifying both the field's raw value (`value`) and its human-friendly label (`label`). Be sure to specify on which of these you want to match.
 
+## Snapshot Conditions (Event Rules)
+
+When used in an [event rule](../features/event-rules.md), conditions can also inspect the **pre-change and post-change snapshots** captured at the time of the event. This allows rules to fire only when a specific field actually changes value, rather than whenever it has a particular value.
+
+### Snapshot Operators
+
+The `changed` and `unchanged` operators compare an attribute's value across the two snapshots. They do not accept a `value` key.
+
+Fire only when `status` changes (to any value):
+
+```json
+{
+  "attr": "status",
+  "op": "changed"
+}
+```
+
+### Combining with Standard Conditions
+
+The canonical use case — fire only when `status` changes **to** `active` — combines a standard value check with the `changed` operator:
+
+```json
+{
+  "and": [
+    {
+      "attr": "status.value",
+      "value": "active"
+    },
+    {
+      "attr": "status",
+      "op": "changed"
+    }
+  ]
+}
+```
+
+### Direct Snapshot Path Access
+
+You can also read pre- or post-change values directly using the `snapshots.prechange.<attr>` and `snapshots.postchange.<attr>` dot-path syntax with any standard operator:
+
+```json
+{
+  "attr": "snapshots.prechange.status",
+  "value": "planned"
+}
+```
+
+!!! warning "Snapshot serialization format"
+    Snapshot data uses the **model serializer format**, not the REST API format. Choice fields such as `status` are stored as raw strings (e.g. `"active"`) rather than nested objects (e.g. `{"value": "active", "label": "Active"}`). Use `attr: "snapshots.prechange.status"` — not `"snapshots.prechange.status.value"` — when referencing snapshot attributes. The `changed`/`unchanged` operators compare the same format on both sides, so they are not affected by this distinction.
+
+!!! note "Snapshot availability"
+    Snapshots are only populated for update and delete events. For create events, `prechange` is `null` — conditions using the `changed` operator on a create event evaluate to `true` (the field transitioned from non-existent to its initial value), while conditions using `snapshots.prechange.*` paths evaluate to `false`. For delete events, `postchange` is `null` — the `changed` operator evaluates to `true` for any attribute present in the prechange snapshot, and `unchanged` evaluates to `false`.
+
 ## Condition Sets
 
 Multiple conditions can be combined into nested sets using AND or OR logic. This is done by declaring a JSON object with a single key (`and` or `or`) containing a list of condition objects and/or child condition sets.

+ 12 - 1
mkdocs.yml

@@ -71,6 +71,7 @@ nav:
         - Facilities: 'features/facilities.md'
         - Devices & Cabling: 'features/devices-cabling.md'
         - Power Tracking: 'features/power-tracking.md'
+        - Cooling: 'features/cooling.md'
         - IPAM: 'features/ipam.md'
         - VLAN Management: 'features/vlan-management.md'
         - L2VPN & Overlay: 'features/l2vpn-overlay.md'
@@ -98,7 +99,8 @@ nav:
         - Installing NetBox: 'installation/index.md'
         - 1. PostgreSQL: 'installation/1-postgresql.md'
         - 2. Redis: 'installation/2-redis.md'
-        - 3. NetBox: 'installation/3-netbox.md'
+        - 3a. Release Archive or Git: 'installation/3-netbox.md'
+        - 3b. Python Package (Experimental): 'installation/3b-python-package.md'
         - 4a. Gunicorn: 'installation/4a-gunicorn.md'
         - 4b. uWSGI: 'installation/4b-uwsgi.md'
         - 5. HTTP Server: 'installation/5-http-server.md'
@@ -147,11 +149,13 @@ nav:
             - UI Components: 'plugins/development/ui-components.md'
             - Navigation: 'plugins/development/navigation.md'
             - Templates: 'plugins/development/templates.md'
+            - Config Templates: 'plugins/development/config-templates.md'
             - Tables: 'plugins/development/tables.md'
             - Forms: 'plugins/development/forms.md'
             - Filters & Filter Sets: 'plugins/development/filtersets.md'
             - Search: 'plugins/development/search.md'
             - Event Types: 'plugins/development/event-types.md'
+            - Event Rule Actions: 'plugins/development/event-rule-actions.md'
             - Permissions: 'plugins/development/permissions.md'
             - Data Backends: 'plugins/development/data-backends.md'
             - Webhooks: 'plugins/development/webhooks.md'
@@ -199,6 +203,12 @@ nav:
             - ConsolePortTemplate: 'models/dcim/consoleporttemplate.md'
             - ConsoleServerPort: 'models/dcim/consoleserverport.md'
             - ConsoleServerPortTemplate: 'models/dcim/consoleserverporttemplate.md'
+            - CoolingFeed: 'models/dcim/coolingfeed.md'
+            - CoolingIntake: 'models/dcim/coolingintake.md'
+            - CoolingIntakeTemplate: 'models/dcim/coolingintaketemplate.md'
+            - CoolingOutflow: 'models/dcim/coolingoutflow.md'
+            - CoolingOutflowTemplate: 'models/dcim/coolingoutflowtemplate.md'
+            - CoolingSource: 'models/dcim/coolingsource.md'
             - Device: 'models/dcim/device.md'
             - DeviceBay: 'models/dcim/devicebay.md'
             - DeviceBayTemplate: 'models/dcim/devicebaytemplate.md'
@@ -217,6 +227,7 @@ nav:
             - Module: 'models/dcim/module.md'
             - ModuleBay: 'models/dcim/modulebay.md'
             - ModuleBayTemplate: 'models/dcim/modulebaytemplate.md'
+            - ModuleBayType: 'models/dcim/modulebaytype.md'
             - ModuleType: 'models/dcim/moduletype.md'
             - ModuleTypeProfile: 'models/dcim/moduletypeprofile.md'
             - Platform: 'models/dcim/platform.md'

+ 9 - 2
netbox/account/views.py

@@ -11,7 +11,7 @@ from django.contrib.auth.models import update_last_login
 from django.contrib.auth.signals import user_logged_in
 from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render, resolve_url
-from django.urls import reverse
+from django.urls import reverse, reverse_lazy
 from django.utils.decorators import method_decorator
 from django.utils.http import urlencode
 from django.utils.translation import gettext_lazy as _
@@ -27,6 +27,7 @@ from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable
 from netbox.authentication import get_auth_backend_display, get_saml_idps
 from netbox.config import get_config
 from netbox.ui import layout
+from netbox.ui.breadcrumbs import Breadcrumb
 from netbox.views import generic
 from users import forms
 from users.models import UserConfig
@@ -345,6 +346,12 @@ class UserTokenListView(LoginRequiredMixin, View):
 @register_model_view(UserToken)
 class UserTokenView(LoginRequiredMixin, View):
     layout = layout.SimpleLayout(
+        # The global UserToken list view is admin-only, so substitute the user's personal token list
+        # for the default root breadcrumb.
+        root_breadcrumb=False,
+        breadcrumbs=[
+            Breadcrumb(label=_('My API Tokens'), url=reverse_lazy('account:usertoken_list')),
+        ],
         left_panels=[
             TokenPanel(),
         ],
@@ -362,7 +369,7 @@ class UserTokenView(LoginRequiredMixin, View):
         plaintext = request.session.pop(f'_token_plaintext_{token.pk}', None)
         token_auth_string = f'{token.get_auth_header_prefix()}{plaintext}' if plaintext else None
 
-        return render(request, 'account/token.html', {
+        return render(request, 'users/token.html', {
             'object': token,
             'layout': self.layout,
             'token_auth_string': token_auth_string,

+ 0 - 12
netbox/circuits/apps.py

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

+ 44 - 38
netbox/circuits/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 #
 # Circuits
@@ -18,12 +18,18 @@ class CircuitStatusChoices(ChoiceSet):
     STATUS_DECOMMISSIONED = 'decommissioned'
 
     CHOICES = [
-        (STATUS_PLANNED, _('Planned'), 'cyan'),
-        (STATUS_PROVISIONING, _('Provisioning'), 'blue'),
-        (STATUS_ACTIVE, _('Active'), 'green'),
-        (STATUS_OFFLINE, _('Offline'), 'red'),
-        (STATUS_DEPROVISIONING, _('Deprovisioning'), 'yellow'),
-        (STATUS_DECOMMISSIONED, _('Decommissioned'), 'gray'),
+        Choice(
+            STATUS_PLANNED, _('Planned'), color='cyan',
+            description=_('Designated for future use but not yet installed')
+        ),
+        Choice(STATUS_PROVISIONING, _('Provisioning'), color='blue', description=_('Being configured for service')),
+        Choice(STATUS_ACTIVE, _('Active'), color='green', description=_('Fully operational and in service')),
+        Choice(STATUS_OFFLINE, _('Offline'), color='red', description=_('Installed but not currently in service')),
+        Choice(STATUS_DEPROVISIONING, _('Deprovisioning'), color='yellow', description=_('Being removed from service')),
+        Choice(
+            STATUS_DECOMMISSIONED, _('Decommissioned'), color='gray',
+            description=_('Retired and no longer in service')
+        ),
     ]
 
 
@@ -31,17 +37,17 @@ class CircuitCommitRateChoices(ChoiceSet):
     key = 'Circuit.commit_rate'
 
     CHOICES = [
-        (10000, '10 Mbps'),
-        (100000, '100 Mbps'),
-        (1000000, '1 Gbps'),
-        (10000000, '10 Gbps'),
-        (25000000, '25 Gbps'),
-        (40000000, '40 Gbps'),
-        (100000000, '100 Gbps'),
-        (200000000, '200 Gbps'),
-        (400000000, '400 Gbps'),
-        (1544, 'T1 (1.544 Mbps)'),
-        (2048, 'E1 (2.048 Mbps)'),
+        Choice(10000, '10 Mbps'),
+        Choice(100000, '100 Mbps'),
+        Choice(1000000, '1 Gbps'),
+        Choice(10000000, '10 Gbps'),
+        Choice(25000000, '25 Gbps'),
+        Choice(40000000, '40 Gbps'),
+        Choice(100000000, '100 Gbps'),
+        Choice(200000000, '200 Gbps'),
+        Choice(400000000, '400 Gbps'),
+        Choice(1544, 'T1 (1.544 Mbps)'),
+        Choice(2048, 'E1 (2.048 Mbps)'),
     ]
 
 
@@ -55,8 +61,8 @@ class CircuitTerminationSideChoices(ChoiceSet):
     SIDE_Z = 'Z'
 
     CHOICES = (
-        (SIDE_A, 'A'),
-        (SIDE_Z, 'Z')
+        Choice(SIDE_A, 'A'),
+        Choice(SIDE_Z, 'Z')
     )
 
 
@@ -64,17 +70,17 @@ class CircuitTerminationPortSpeedChoices(ChoiceSet):
     key = 'CircuitTermination.port_speed'
 
     CHOICES = [
-        (10000, '10 Mbps'),
-        (100000, '100 Mbps'),
-        (1000000, '1 Gbps'),
-        (10000000, '10 Gbps'),
-        (25000000, '25 Gbps'),
-        (40000000, '40 Gbps'),
-        (100000000, '100 Gbps'),
-        (200000000, '200 Gbps'),
-        (400000000, '400 Gbps'),
-        (1544, 'T1 (1.544 Mbps)'),
-        (2048, 'E1 (2.048 Mbps)'),
+        Choice(10000, '10 Mbps'),
+        Choice(100000, '100 Mbps'),
+        Choice(1000000, '1 Gbps'),
+        Choice(10000000, '10 Gbps'),
+        Choice(25000000, '25 Gbps'),
+        Choice(40000000, '40 Gbps'),
+        Choice(100000000, '100 Gbps'),
+        Choice(200000000, '200 Gbps'),
+        Choice(400000000, '400 Gbps'),
+        Choice(1544, 'T1 (1.544 Mbps)'),
+        Choice(2048, 'E1 (2.048 Mbps)'),
     ]
 
 
@@ -87,10 +93,10 @@ class CircuitPriorityChoices(ChoiceSet):
     PRIORITY_INACTIVE = 'inactive'
 
     CHOICES = [
-        (PRIORITY_PRIMARY, _('Primary')),
-        (PRIORITY_SECONDARY, _('Secondary')),
-        (PRIORITY_TERTIARY, _('Tertiary')),
-        (PRIORITY_INACTIVE, _('Inactive')),
+        Choice(PRIORITY_PRIMARY, _('Primary')),
+        Choice(PRIORITY_SECONDARY, _('Secondary')),
+        Choice(PRIORITY_TERTIARY, _('Tertiary')),
+        Choice(PRIORITY_INACTIVE, _('Inactive')),
     ]
 
 
@@ -104,7 +110,7 @@ class VirtualCircuitTerminationRoleChoices(ChoiceSet):
     ROLE_SPOKE = 'spoke'
 
     CHOICES = [
-        (ROLE_PEER, _('Peer'), 'green'),
-        (ROLE_HUB, _('Hub'), 'blue'),
-        (ROLE_SPOKE, _('Spoke'), 'orange'),
+        Choice(ROLE_PEER, _('Peer'), color='green', description=_('Connects to other peers as an equal endpoint')),
+        Choice(ROLE_HUB, _('Hub'), color='blue', description=_('Central endpoint to which spokes connect')),
+        Choice(ROLE_SPOKE, _('Spoke'), color='orange', description=_('Remote endpoint connecting to a hub')),
     ]

+ 15 - 39
netbox/circuits/forms/bulk_edit.py

@@ -1,6 +1,5 @@
 from django import forms
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist
 from django.utils.translation import gettext_lazy as _
 
 from circuits.choices import (
@@ -11,21 +10,20 @@ from circuits.choices import (
 )
 from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
 from circuits.models import *
-from dcim.models import Site
 from ipam.models import ASN
 from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm
 from tenancy.models import Tenant
-from utilities.forms import add_blank_choice, get_field_value
+from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms.fields import (
+    ChoiceField,
     ColorField,
-    ContentTypeChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
+    GenericObjectChoiceField,
 )
 from utilities.forms.rendering import FieldSet
-from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, HTMXSelect, NumberWithOptions
-from utilities.templatetags.builtins.filters import bettertitle
+from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions
 
 __all__ = (
     'CircuitBulkEditForm',
@@ -127,7 +125,7 @@ class CircuitBulkEditForm(PrimaryModelBulkEditForm):
             'provider': '$provider'
         }
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(CircuitStatusChoices),
         required=False,
@@ -160,7 +158,7 @@ class CircuitBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         required=False
     )
-    distance_unit = forms.ChoiceField(
+    distance_unit = ChoiceField(
         label=_('Distance unit'),
         choices=add_blank_choice(DistanceUnitChoices),
         required=False,
@@ -179,24 +177,18 @@ class CircuitBulkEditForm(PrimaryModelBulkEditForm):
     )
 
 
-class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
+class CircuitTerminationBulkEditForm(GenericObjectFormMixin, NetBoxModelBulkEditForm):
     description = forms.CharField(
         label=_('Description'),
         max_length=200,
         required=False
     )
-    termination_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
-        widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
-        required=False,
-        label=_('Termination type')
-    )
-    termination = DynamicModelChoiceField(
+    termination = GenericObjectChoiceField(
         label=_('Termination'),
-        queryset=Site.objects.none(),  # Initial queryset
+        content_type_queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
         required=False,
-        disabled=True,
-        selector=True
+        selector=True,
+        hx_method='post',
     )
     port_speed = forms.IntegerField(
         required=False,
@@ -215,28 +207,12 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
     model = CircuitTermination
     fieldsets = (
         FieldSet(
-            'description',
-            'termination_type', 'termination',
-            'mark_connected', name=_('Circuit Termination')
+            'description', 'termination', 'mark_connected', name=_('Circuit Termination')
         ),
         FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')),
     )
     nullable_fields = ('description', 'termination')
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        if termination_type_id := get_field_value(self, 'termination_type'):
-            try:
-                termination_type = ContentType.objects.get(pk=termination_type_id)
-                model = termination_type.model_class()
-                self.fields['termination'].queryset = model.objects.all()
-                self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
-                self.fields['termination'].disabled = False
-                self.fields['termination'].label = _(bettertitle(model._meta.verbose_name))
-            except ObjectDoesNotExist:
-                pass
-
 
 class CircuitGroupBulkEditForm(OrganizationalModelBulkEditForm):
     tenant = DynamicModelChoiceField(
@@ -257,7 +233,7 @@ class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
         queryset=Circuit.objects.all(),
         required=False
     )
-    priority = forms.ChoiceField(
+    priority = ChoiceField(
         label=_('Priority'),
         choices=add_blank_choice(CircuitPriorityChoices),
         required=False
@@ -299,7 +275,7 @@ class VirtualCircuitBulkEditForm(PrimaryModelBulkEditForm):
         queryset=VirtualCircuitType.objects.all(),
         required=False
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(CircuitStatusChoices),
         required=False,
@@ -322,7 +298,7 @@ class VirtualCircuitBulkEditForm(PrimaryModelBulkEditForm):
 
 
 class VirtualCircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
-    role = forms.ChoiceField(
+    role = ChoiceField(
         label=_('Role'),
         choices=add_blank_choice(VirtualCircuitTerminationRoleChoices),
         required=False,

+ 58 - 109
netbox/circuits/forms/model_forms.py

@@ -1,30 +1,33 @@
-from django import forms
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.utils.translation import gettext_lazy as _
 
 from circuits.choices import (
     CircuitCommitRateChoices,
+    CircuitPriorityChoices,
+    CircuitStatusChoices,
     CircuitTerminationPortSpeedChoices,
+    CircuitTerminationSideChoices,
     VirtualCircuitTerminationRoleChoices,
 )
 from circuits.constants import *
 from circuits.models import *
-from dcim.models import Interface, Site
+from dcim.models import Interface
 from ipam.models import ASN
+from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 from tenancy.forms import TenancyForm
-from utilities.forms import get_field_value
+from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms.fields import (
-    ContentTypeChoiceField,
+    ChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
+    GenericObjectChoiceField,
     SlugField,
+    TypedChoiceField,
 )
 from utilities.forms.mixins import DistanceValidationMixin
 from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields
-from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
-from utilities.string import title
+from utilities.forms.widgets import DatePicker, NumberWithOptions
 
 __all__ = (
     'CircuitForm',
@@ -136,6 +139,16 @@ class CircuitTypeForm(OrganizationalModelForm):
 
 
 class CircuitForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=CircuitStatusChoices,
+        initial=CircuitStatusChoices.STATUS_ACTIVE,
+    )
+    distance_unit = TypedChoiceField(
+        label=_('Distance unit'),
+        choices=add_blank_choice(DistanceUnitChoices),
+        required=False,
+    )
     provider = DynamicModelChoiceField(
         label=_('Provider'),
         queryset=Provider.objects.all(),
@@ -186,29 +199,28 @@ class CircuitForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm):
         }
 
 
-class CircuitTerminationForm(NetBoxModelForm):
+class CircuitTerminationForm(GenericObjectFormMixin, NetBoxModelForm):
+    term_side = ChoiceField(
+        label=_('Termination side'),
+        choices=CircuitTerminationSideChoices,
+    )
     circuit = DynamicModelChoiceField(
         label=_('Circuit'),
         queryset=Circuit.objects.all(),
         selector=True
     )
-    termination_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
-        widget=HTMXSelect(),
-        label=_('Termination type')
-    )
-    termination = DynamicModelChoiceField(
+    termination = GenericObjectChoiceField(
         label=_('Termination'),
-        queryset=Site.objects.none(),  # Initial queryset
-        disabled=True,
-        selector=True
+        content_type_queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
+        required=True,
+        selector=True,
+        hx_target_id='circuit-termination',
     )
 
     fieldsets = (
         FieldSet(
-            'circuit', 'term_side', 'description', 'tags',
-            'termination_type', 'termination',
-            'mark_connected', name=_('Circuit Termination')
+            'circuit', 'term_side', 'description', 'tags', 'termination', 'mark_connected',
+            name=_('Circuit Termination'), html_id='circuit-termination',
         ),
         FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')),
     )
@@ -216,7 +228,7 @@ class CircuitTerminationForm(NetBoxModelForm):
     class Meta:
         model = CircuitTermination
         fields = [
-            'circuit', 'term_side', 'termination_type', 'mark_connected', 'port_speed', 'upstream_speed',
+            'circuit', 'term_side', 'mark_connected', 'port_speed', 'upstream_speed',
             'xconnect_id', 'pp_info', 'description', 'tags',
         ]
         widgets = {
@@ -228,48 +240,6 @@ class CircuitTerminationForm(NetBoxModelForm):
             ),
         }
 
-    def __init__(self, *args, **kwargs):
-        instance = kwargs.get('instance')
-        initial = kwargs.get('initial', {})
-
-        if instance is not None and instance.termination:
-            initial['termination'] = instance.termination
-            kwargs['initial'] = initial
-
-        super().__init__(*args, **kwargs)
-
-        if termination_type_id := get_field_value(self, 'termination_type'):
-            try:
-                termination_type = ContentType.objects.get(pk=termination_type_id)
-                model = termination_type.model_class()
-                self.fields['termination'].queryset = model.objects.all()
-                self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
-                self.fields['termination'].disabled = False
-                self.fields['termination'].label = _(title(model._meta.verbose_name))
-            except ObjectDoesNotExist:
-                pass
-
-            if self.instance and termination_type_id != self.instance.termination_type_id:
-                self.initial['termination'] = None
-        else:
-            # Clear the initial termination value if termination_type is not set
-            self.initial['termination'] = None
-
-    def clean(self):
-        super().clean()
-
-        termination = self.cleaned_data.get('termination')
-        termination_type = self.cleaned_data.get('termination_type')
-        if termination_type and not termination:
-            raise ValidationError({
-                'termination': _('Please select a {termination_type}.').format(
-                    termination_type=_(title(termination_type.model_class()._meta.verbose_name))
-                )
-            })
-
-        # Assign the selected termination (if any)
-        self.instance.termination = self.cleaned_data.get('termination')
-
 
 class CircuitGroupForm(TenancyForm, OrganizationalModelForm):
     fieldsets = (
@@ -284,64 +254,37 @@ class CircuitGroupForm(TenancyForm, OrganizationalModelForm):
         ]
 
 
-class CircuitGroupAssignmentForm(NetBoxModelForm):
+class CircuitGroupAssignmentForm(GenericObjectFormMixin, NetBoxModelForm):
+    priority = TypedChoiceField(
+        label=_('Priority'),
+        choices=add_blank_choice(CircuitPriorityChoices),
+        required=False,
+    )
     group = DynamicModelChoiceField(
         label=_('Group'),
         queryset=CircuitGroup.objects.all(),
     )
-    member_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS),
-        widget=HTMXSelect(),
+    member = GenericObjectChoiceField(
+        label=_('Member'),
+        content_type_queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS),
         required=False,
-        label=_('Circuit type')
-    )
-    member = DynamicModelChoiceField(
-        label=_('Circuit'),
-        queryset=Circuit.objects.none(),  # Initial queryset
-        required=False,
-        disabled=True,
-        selector=True
+        selector=True,
+        hx_target_id='circuit-group-assignment',
     )
 
     fieldsets = (
-        FieldSet('group', 'member_type', 'member', 'priority', 'tags', name=_('Group Assignment')),
+        FieldSet(
+            'group', 'member', 'priority', 'tags',
+            name=_('Group Assignment'), html_id='circuit-group-assignment',
+        ),
     )
 
     class Meta:
         model = CircuitGroupAssignment
         fields = [
-            'group', 'member_type', 'priority', 'tags',
+            'group', 'priority', 'tags',
         ]
 
-    def __init__(self, *args, **kwargs):
-        instance = kwargs.get('instance')
-        initial = kwargs.get('initial', {})
-
-        if instance is not None and instance.member:
-            initial['member'] = instance.member
-            kwargs['initial'] = initial
-
-        super().__init__(*args, **kwargs)
-
-        if member_type_id := get_field_value(self, 'member_type'):
-            try:
-                model = ContentType.objects.get(pk=member_type_id).model_class()
-                self.fields['member'].queryset = model.objects.all()
-                self.fields['member'].widget.attrs['selector'] = model._meta.label_lower
-                self.fields['member'].disabled = False
-                self.fields['member'].label = _(title(model._meta.verbose_name))
-            except ObjectDoesNotExist:
-                pass
-
-            if self.instance.pk and member_type_id != self.instance.member_type_id:
-                self.initial['member'] = None
-
-    def clean(self):
-        super().clean()
-
-        # Assign the selected circuit (if any)
-        self.instance.member = self.cleaned_data.get('member')
-
 
 class VirtualCircuitTypeForm(OrganizationalModelForm):
     fieldsets = (
@@ -356,6 +299,11 @@ class VirtualCircuitTypeForm(OrganizationalModelForm):
 
 
 class VirtualCircuitForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=CircuitStatusChoices,
+        initial=CircuitStatusChoices.STATUS_ACTIVE,
+    )
     provider_network = DynamicModelChoiceField(
         label=_('Provider network'),
         queryset=ProviderNetwork.objects.all(),
@@ -393,9 +341,8 @@ class VirtualCircuitTerminationForm(NetBoxModelForm):
         queryset=VirtualCircuit.objects.all(),
         selector=True
     )
-    role = forms.ChoiceField(
+    role = ChoiceField(
         choices=VirtualCircuitTerminationRoleChoices,
-        widget=HTMXSelect(),
         label=_('Role')
     )
     interface = DynamicModelChoiceField(
@@ -412,7 +359,9 @@ class VirtualCircuitTerminationForm(NetBoxModelForm):
     )
 
     fieldsets = (
-        FieldSet('virtual_circuit', 'role', 'interface', 'description', 'tags'),
+        FieldSet(
+            'virtual_circuit', 'role', 'interface', 'description', 'tags',
+        ),
     )
 
     class Meta:

+ 17 - 12
netbox/circuits/graphql/filters.py

@@ -10,7 +10,12 @@ from circuits.graphql.filter_mixins import CircuitTypeFilterMixin
 from dcim.graphql.filter_mixins import CabledObjectModelFilterMixin
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
 from netbox.graphql.filter_mixins import DistanceFilterMixin, ImageAttachmentFilterMixin
-from netbox.graphql.filters import ChangeLoggedModelFilter, OrganizationalModelFilter, PrimaryModelFilter
+from netbox.graphql.filters import (
+    ChangeLoggedModelFilter,
+    OrganizationalModelFilter,
+    PrimaryModelFilter,
+    register_filter,
+)
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 
 if TYPE_CHECKING:
@@ -36,7 +41,7 @@ __all__ = (
 )
 
 
-@strawberry_django.filter_type(models.CircuitTermination, lookups=True)
+@register_filter(models.CircuitTermination, lookups=True)
 class CircuitTerminationFilter(
     CustomFieldsFilterMixin,
     TagsFilterMixin,
@@ -83,7 +88,7 @@ class CircuitTerminationFilter(
     )
 
 
-@strawberry_django.filter_type(models.Circuit, lookups=True)
+@register_filter(models.Circuit, lookups=True)
 class CircuitFilter(
     ContactFilterMixin,
     ImageAttachmentFilterMixin,
@@ -117,17 +122,17 @@ class CircuitFilter(
     )
 
 
-@strawberry_django.filter_type(models.CircuitType, lookups=True)
+@register_filter(models.CircuitType, lookups=True)
 class CircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter):
     pass
 
 
-@strawberry_django.filter_type(models.CircuitGroup, lookups=True)
+@register_filter(models.CircuitGroup, lookups=True)
 class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilter):
     pass
 
 
-@strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True)
+@register_filter(models.CircuitGroupAssignment, lookups=True)
 class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     member_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -142,7 +147,7 @@ class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, Cha
     )
 
 
-@strawberry_django.filter_type(models.Provider, lookups=True)
+@register_filter(models.Provider, lookups=True)
 class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     slug: StrFilterLookup | None = strawberry_django.filter_field()
@@ -152,7 +157,7 @@ class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.ProviderAccount, lookups=True)
+@register_filter(models.ProviderAccount, lookups=True)
 class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -162,7 +167,7 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
+@register_filter(models.ProviderNetwork, lookups=True)
 class ProviderNetworkFilter(PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -172,12 +177,12 @@ class ProviderNetworkFilter(PrimaryModelFilter):
     service_id: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
+@register_filter(models.VirtualCircuitType, lookups=True)
 class VirtualCircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter):
     pass
 
 
-@strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
+@register_filter(models.VirtualCircuit, lookups=True)
 class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
     cid: StrFilterLookup | None = strawberry_django.filter_field()
     provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -200,7 +205,7 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.VirtualCircuitTermination, lookups=True)
+@register_filter(models.VirtualCircuitTermination, lookups=True)
 class VirtualCircuitTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     virtual_circuit: Annotated['VirtualCircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()

+ 12 - 12
netbox/circuits/graphql/types.py

@@ -8,7 +8,7 @@ from dcim.graphql.mixins import CabledObjectMixin
 from dcim.models import Location, Region, Site, SiteGroup
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
 from netbox.graphql.optimization import build_gfk_prefetch
-from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType
+from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType, register_type
 from tenancy.graphql.types import TenantType
 
 from .filters import *
@@ -32,7 +32,7 @@ __all__ = (
 )
 
 
-@strawberry_django.type(
+@register_type(
     models.Provider,
     fields='__all__',
     filters=ProviderFilter,
@@ -45,7 +45,7 @@ class ProviderType(ContactsMixin, PrimaryObjectType):
     accounts: list[Annotated["ProviderAccountType", strawberry.lazy('circuits.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ProviderAccount,
     fields='__all__',
     filters=ProviderAccountFilter,
@@ -56,7 +56,7 @@ class ProviderAccountType(ContactsMixin, PrimaryObjectType):
     circuits: list[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ProviderNetwork,
     fields='__all__',
     filters=ProviderNetworkFilter,
@@ -67,7 +67,7 @@ class ProviderNetworkType(PrimaryObjectType):
     circuit_terminations: list[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.CircuitTermination,
     exclude=['termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'],
     filters=CircuitTerminationFilter,
@@ -100,7 +100,7 @@ class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, Ob
         return self.termination
 
 
-@strawberry_django.type(
+@register_type(
     models.CircuitType,
     fields='__all__',
     filters=CircuitTypeFilter,
@@ -112,7 +112,7 @@ class CircuitTypeType(OrganizationalObjectType):
     circuits: list[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.Circuit,
     fields='__all__',
     filters=CircuitFilter,
@@ -128,7 +128,7 @@ class CircuitType(PrimaryObjectType, ContactsMixin):
     terminations: list[CircuitTerminationType]
 
 
-@strawberry_django.type(
+@register_type(
     models.CircuitGroup,
     fields='__all__',
     filters=CircuitGroupFilter,
@@ -138,7 +138,7 @@ class CircuitGroupType(OrganizationalObjectType):
     tenant: TenantType | None
 
 
-@strawberry_django.type(
+@register_type(
     models.CircuitGroupAssignment,
     exclude=['member_type', 'member_id'],
     filters=CircuitGroupAssignmentFilter,
@@ -165,7 +165,7 @@ class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
         return self.member
 
 
-@strawberry_django.type(
+@register_type(
     models.VirtualCircuitType,
     fields='__all__',
     filters=VirtualCircuitTypeFilter,
@@ -177,7 +177,7 @@ class VirtualCircuitTypeType(OrganizationalObjectType):
     virtual_circuits: list[Annotated["VirtualCircuitType", strawberry.lazy('circuits.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.VirtualCircuitTermination,
     fields='__all__',
     filters=VirtualCircuitTerminationFilter,
@@ -194,7 +194,7 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
     ] = strawberry_django.field(select_related=["interface"])
 
 
-@strawberry_django.type(
+@register_type(
     models.VirtualCircuit,
     fields='__all__',
     filters=VirtualCircuitFilter,

+ 8 - 8
netbox/circuits/migrations/0053_owner.py

@@ -13,56 +13,56 @@ class Migration(migrations.Migration):
             model_name='circuit',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='circuitgroup',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='circuittype',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='provider',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='provideraccount',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='providernetwork',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='virtualcircuit',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='virtualcircuittype',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
     ]

+ 38 - 0
netbox/circuits/migrations/0060_denormalization_triggers.py

@@ -0,0 +1,38 @@
+"""
+Maintain CircuitTermination's denormalized site/region/site-group columns via PostgreSQL triggers instead
+of the Python `post_save` handler formerly registered in netbox.denormalized.
+"""
+from django.db import migrations
+
+from utilities.migration import InstallDenormalizationTrigger
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0059_nullify_empty_cable_end'),
+        # Source tables (dcim_site, dcim_location) must already exist.
+        ('dcim', '0242_ltree_paths'),
+    ]
+
+    operations = [
+        InstallDenormalizationTrigger(
+            dependent_table='circuits_circuittermination',
+            source_table='dcim_site',
+            fk_column='_site_id',
+            mappings={'_region_id': 'region_id', '_site_group_id': 'group_id'},
+        ),
+        InstallDenormalizationTrigger(
+            dependent_table='circuits_circuittermination',
+            source_table='dcim_location',
+            fk_column='_location_id',
+            mappings={'_site_id': 'site_id'},
+            related_mappings=(
+                {
+                    'table': 'dcim_site',
+                    'source_fk': 'site_id',
+                    'mappings': {'_region_id': 'region_id', '_site_group_id': 'group_id'},
+                },
+            ),
+        ),
+    ]

+ 12 - 12
netbox/circuits/tests/test_filtersets.py

@@ -20,10 +20,10 @@ from dcim.models import (
 from ipam.models import ASN, RIR
 from netbox.choices import DistanceUnitChoices
 from tenancy.models import Tenant, TenantGroup
-from utilities.testing import ChangeLoggedFilterSetTests
+from utilities.testing import ChangeLoggedFilterSetTestMixin
 
 
-class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
+class ProviderTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = Provider.objects.all()
     filterset = ProviderFilterSet
 
@@ -135,7 +135,7 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
+class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = CircuitType.objects.all()
     filterset = CircuitTypeFilterSet
 
@@ -165,7 +165,7 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
+class CircuitTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = Circuit.objects.all()
     filterset = CircuitFilterSet
 
@@ -440,7 +440,7 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
-class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
+class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = CircuitTermination.objects.all()
     filterset = CircuitTerminationFilterSet
     ignore_fields = ('cable', 'cable_positions')
@@ -609,7 +609,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
 
 
-class CircuitGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
+class CircuitGroupTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = CircuitGroup.objects.all()
     filterset = CircuitGroupFilterSet
 
@@ -667,7 +667,7 @@ class CircuitGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
-class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
+class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = CircuitGroupAssignment.objects.all()
     filterset = CircuitGroupAssignmentFilterSet
 
@@ -812,7 +812,7 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
-class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
+class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = ProviderNetwork.objects.all()
     filterset = ProviderNetworkFilterSet
 
@@ -853,7 +853,7 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class ProviderAccountTestCase(TestCase, ChangeLoggedFilterSetTests):
+class ProviderAccountTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = ProviderAccount.objects.all()
     filterset = ProviderAccountFilterSet
 
@@ -898,7 +898,7 @@ class ProviderAccountTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class VirtualCircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
+class VirtualCircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = VirtualCircuitType.objects.all()
     filterset = VirtualCircuitTypeFilterSet
 
@@ -928,7 +928,7 @@ class VirtualCircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
+class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = VirtualCircuit.objects.all()
     filterset = VirtualCircuitFilterSet
 
@@ -1064,7 +1064,7 @@ class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class VirtualCircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
+class VirtualCircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = VirtualCircuitTermination.objects.all()
     filterset = VirtualCircuitTerminationFilterSet
 

+ 4 - 4
netbox/circuits/tests/test_forms.py

@@ -32,12 +32,12 @@ class CircuitTerminationFormTestCase(TestCase):
             data={
                 'circuit': self.circuit.pk,
                 'term_side': 'A',
-                'termination_type': provider_network_type.pk,
-                'termination': '',
+                'termination_content_type': provider_network_type.pk,
+                'termination_object_id': '',
             }
         )
 
         self.assertFalse(form.is_valid())
         self.assertIn('termination', form.errors)
-        self.assertIn('Please select a Provider Network.', form.errors['termination'])
-        self.assertNotIn('termination_id', form.errors)
+        self.assertIn('Please select a provider network.', form.errors['termination'])
+        self.assertNotIn('termination_object_id', form.errors)

+ 83 - 1
netbox/circuits/tests/test_models.py

@@ -3,7 +3,7 @@ from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
 from django.test import TestCase
 
 from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
-from dcim.models import Site
+from dcim.models import Location, Region, Site, SiteGroup
 
 
 class CircuitTerminationTestCase(TestCase):
@@ -166,3 +166,85 @@ class CircuitTerminationTestCase(TestCase):
         self.assertIn(NON_FIELD_ERRORS, errors)
         self.assertIn('Please select a Provider Network.', errors[NON_FIELD_ERRORS])
         self.assertNotIn('termination_id', errors)
+
+
+class CircuitTerminationDenormalizationTriggerTestCase(TestCase):
+    """
+    Verify the PostgreSQL triggers (installed by circuits migration 0058) that keep a
+    CircuitTermination's denormalized scope columns in sync with its Site/Location.
+
+    These replace the former Python `post_save` handler in netbox.denormalized. Unlike that
+    handler, the triggers also fire for bulk QuerySet.update() writes (exercised below).
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
+        cls.circuit = Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type)
+
+    def test_site_region_group_change_propagates_to_termination(self):
+        region_a = Region.objects.create(name='Region A', slug='region-a')
+        region_b = Region.objects.create(name='Region B', slug='region-b')
+        group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
+        group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
+        site = Site.objects.create(name='Site', slug='site', region=region_a, group=group_a)
+
+        termination = CircuitTermination.objects.create(
+            circuit=self.circuit, term_side='A', termination=site,
+        )
+        self.assertEqual(termination._region, region_a)
+        self.assertEqual(termination._site_group, group_a)
+
+        # Reassign the Site's region/group; the trigger should update the termination.
+        site.region = region_b
+        site.group = group_b
+        site.save()
+
+        termination.refresh_from_db()
+        self.assertEqual(termination._region, region_b)
+        self.assertEqual(termination._site_group, group_b)
+
+    def test_location_site_change_propagates_to_termination(self):
+        region_a = Region.objects.create(name='Region A', slug='region-a')
+        region_b = Region.objects.create(name='Region B', slug='region-b')
+        group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
+        group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
+        site_a = Site.objects.create(name='Site A', slug='site-a', region=region_a, group=group_a)
+        site_b = Site.objects.create(name='Site B', slug='site-b', region=region_b, group=group_b)
+        location = Location.objects.create(name='Loc', slug='loc', site=site_a)
+
+        termination = CircuitTermination.objects.create(
+            circuit=self.circuit, term_side='A', termination=location,
+        )
+        self.assertEqual(termination._site, site_a)
+        self.assertEqual(termination._location, location)
+
+        # Move the Location to a different Site; the trigger updates _site and pulls the new
+        # site's region/group through in the same statement.
+        location.site = site_b
+        location.save()
+
+        termination.refresh_from_db()
+        self.assertEqual(termination._site, site_b)
+        self.assertEqual(termination._region, region_b)
+        self.assertEqual(termination._site_group, group_b)
+
+    def test_bulk_update_of_site_propagates_to_termination(self):
+        """
+        A QuerySet.update() bypasses post_save (the old handler never fired for it); the
+        DB trigger fires regardless, which is the behavior this change introduces.
+        """
+        region_a = Region.objects.create(name='Region A', slug='region-a')
+        region_b = Region.objects.create(name='Region B', slug='region-b')
+        site = Site.objects.create(name='Site', slug='site', region=region_a)
+
+        termination = CircuitTermination.objects.create(
+            circuit=self.circuit, term_side='A', termination=site,
+        )
+        self.assertEqual(termination._region, region_a)
+
+        Site.objects.filter(pk=site.pk).update(region=region_b)
+
+        termination.refresh_from_db()
+        self.assertEqual(termination._region, region_b)

+ 4 - 4
netbox/circuits/tests/test_views.py

@@ -400,8 +400,8 @@ class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         cls.form_data = {
             'circuit': circuits[2].pk,
             'term_side': 'A',
-            'termination_type': ContentType.objects.get_for_model(Site).pk,
-            'termination': sites[2].pk,
+            'termination_content_type': ContentType.objects.get_for_model(Site).pk,
+            'termination_object_id': sites[2].pk,
             'description': 'New description',
         }
 
@@ -541,8 +541,8 @@ class CircuitGroupAssignmentTestCase(
 
         cls.form_data = {
             'group': circuit_groups[3].pk,
-            'member_type': ContentType.objects.get_for_model(Circuit).pk,
-            'member': circuits[3].pk,
+            'member_content_type': ContentType.objects.get_for_model(Circuit).pk,
+            'member_object_id': circuits[3].pk,
             'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
             'tags': [t.pk for t in tags],
         }

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

@@ -62,8 +62,8 @@ class CircuitGroupAssignmentsPanel(panels.ObjectsTablePanel):
         actions.AddObject(
             'circuits.CircuitGroupAssignment',
             url_params={
-                'member_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
-                'member': lambda ctx: ctx['object'].pk,
+                'member_content_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
+                'member_object_id': lambda ctx: ctx['object'].pk,
                 'return_url': lambda ctx: ctx['object'].get_absolute_url(),
             },
             label=_('Assign Group'),

+ 44 - 0
netbox/circuits/views.py

@@ -5,6 +5,7 @@ from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
 from ipam.models import ASN
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
 from netbox.ui import actions, layout
+from netbox.ui.breadcrumbs import Breadcrumb, filtered_list_url
 from netbox.ui.panels import (
     CommentsPanel,
     ObjectsTablePanel,
@@ -147,8 +148,12 @@ class ProviderAccountListView(generic.ObjectListView):
 
 @register_model_view(ProviderAccount)
 class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = ProviderAccount.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('provider', url=filtered_list_url('circuits:provideraccount_list', 'provider_id')),
+        ],
         left_panels=[
             panels.ProviderAccountPanel(),
             TagsPanel(),
@@ -240,8 +245,12 @@ class ProviderNetworkListView(generic.ObjectListView):
 
 @register_model_view(ProviderNetwork)
 class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = ProviderNetwork.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('provider', url=filtered_list_url('circuits:providernetwork_list', 'provider_id')),
+        ],
         left_panels=[
             panels.ProviderNetworkPanel(),
             TagsPanel(),
@@ -422,8 +431,12 @@ class CircuitListView(generic.ObjectListView):
 
 @register_model_view(Circuit)
 class CircuitView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = Circuit.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('provider', url=filtered_list_url('circuits:circuit_list', 'provider_id')),
+        ],
         left_panels=[
             panels.CircuitPanel(),
             panels.CircuitGroupAssignmentsPanel(),
@@ -508,8 +521,12 @@ class CircuitTerminationListView(generic.ObjectListView):
 
 @register_model_view(CircuitTermination)
 class CircuitTerminationView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = CircuitTermination.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('circuit.provider', url=filtered_list_url('circuits:circuit_list', 'provider_id')),
+        ],
         left_panels=[
             panels.CircuitTerminationPanel(),
         ],
@@ -646,8 +663,12 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
 
 @register_model_view(CircuitGroupAssignment)
 class CircuitGroupAssignmentView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = CircuitGroupAssignment.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('group', url=filtered_list_url('circuits:circuitgroupassignment_list', 'group_id')),
+        ],
         left_panels=[
             panels.CircuitGroupAssignmentPanel(),
             TagsPanel(),
@@ -785,8 +806,16 @@ class VirtualCircuitListView(generic.ObjectListView):
 
 @register_model_view(VirtualCircuit)
 class VirtualCircuitView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = VirtualCircuit.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('provider', url=filtered_list_url('circuits:virtualcircuit_list', 'provider_id')),
+            Breadcrumb(
+                'provider_network',
+                url=filtered_list_url('circuits:virtualcircuit_list', 'provider_network_id'),
+            ),
+        ],
         left_panels=[
             panels.VirtualCircuitPanel(),
             TagsPanel(),
@@ -881,8 +910,23 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
 
 @register_model_view(VirtualCircuitTermination)
 class VirtualCircuitTerminationView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = VirtualCircuitTermination.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb(
+                'virtual_circuit.provider',
+                url=filtered_list_url('circuits:virtualcircuit_list', 'provider_id'),
+            ),
+            Breadcrumb(
+                'virtual_circuit.provider_network',
+                url=filtered_list_url('circuits:virtualcircuit_list', 'provider_network_id'),
+            ),
+            Breadcrumb(
+                'virtual_circuit',
+                url=filtered_list_url('circuits:virtualcircuittermination_list', 'virtual_circuit_id'),
+            ),
+        ],
         left_panels=[
             panels.VirtualCircuitTerminationPanel(),
             TagsPanel(),

+ 2 - 2
netbox/core/api/serializers_/jobs.py

@@ -32,8 +32,8 @@ class JobSerializer(BaseModelSerializer):
         model = Job
         fields = [
             'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
-            'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'queue_name',
-            'notifications', 'log_entries',
+            'scheduled', 'interval', 'started', 'completed', 'execution_time', 'user', 'data', 'error', 'job_id',
+            'queue_name', 'notifications', 'log_entries',
         ]
         brief_fields = ('url', 'created', 'completed', 'user', 'status')
 

+ 1 - 0
netbox/core/apps.py

@@ -32,6 +32,7 @@ class CoreConfig(AppConfig):
         from core.checks import check_duplicate_indexes, check_postgresql_version, check_redis_version  # noqa: F401
         from netbox import context_managers  # noqa: F401
         from netbox.models.features import register_models
+        from netbox.search import signals as search_signals  # noqa: F401
 
         from . import data_backends, events, search  # noqa: F401
 

+ 8 - 9
netbox/core/checks.py

@@ -74,22 +74,21 @@ def check_postgresql_version(app_configs, **kwargs):
 @register(Tags.caches)
 def check_redis_version(app_configs, **kwargs):
     """
-    Warn if the Redis version is less than 6.0, as support for Redis older than 6.0
-    will be removed in NetBox v4.7.
+    Report an error if the Redis version is less than 6.0.
     """
-    warnings = []
+    errors = []
     try:
         client = cache.client.get_client()
         redis_version = tuple(int(x) for x in client.info()['redis_version'].split('.'))
         if redis_version < (6, 0):
-            warnings.append(
-                Warning(
-                    f'Support for Redis {".".join(str(x) for x in redis_version)} is deprecated and will be '
-                    f'removed in NetBox v4.7.',
+            errors.append(
+                Error(
+                    f'Redis {".".join(str(x) for x in redis_version)} is not supported. NetBox requires Redis 6.0 '
+                    f'or later.',
                     hint='Please upgrade to Redis 6.0 or later.',
-                    id='netbox.W002',
+                    id='netbox.E002',
                 )
             )
     except Exception:
         pass
-    return warnings
+    return errors

+ 26 - 26
netbox/core/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 #
 # Data sources
@@ -15,11 +15,11 @@ class DataSourceStatusChoices(ChoiceSet):
     FAILED = 'failed'
 
     CHOICES = (
-        (NEW, _('New'), 'blue'),
-        (QUEUED, _('Queued'), 'orange'),
-        (SYNCING, _('Syncing'), 'cyan'),
-        (COMPLETED, _('Completed'), 'green'),
-        (FAILED, _('Failed'), 'red'),
+        Choice(NEW, _('New'), color='blue', description=_('Newly created and not yet synchronized')),
+        Choice(QUEUED, _('Queued'), color='orange', description=_('Queued for synchronization')),
+        Choice(SYNCING, _('Syncing'), color='cyan', description=_('Synchronization in progress')),
+        Choice(COMPLETED, _('Completed'), color='green', description=_('Most recent synchronization succeeded')),
+        Choice(FAILED, _('Failed'), color='red', description=_('Most recent synchronization failed')),
     )
 
 
@@ -32,8 +32,8 @@ class ManagedFileRootPathChoices(ChoiceSet):
     REPORTS = 'reports'  # settings.REPORTS_ROOT
 
     CHOICES = (
-        (SCRIPTS, _('Scripts')),
-        (REPORTS, _('Reports')),
+        Choice(SCRIPTS, _('Scripts')),
+        Choice(REPORTS, _('Reports')),
     )
 
 
@@ -51,12 +51,12 @@ class JobStatusChoices(ChoiceSet):
     STATUS_FAILED = 'failed'
 
     CHOICES = (
-        (STATUS_PENDING, _('Pending'), 'cyan'),
-        (STATUS_SCHEDULED, _('Scheduled'), 'gray'),
-        (STATUS_RUNNING, _('Running'), 'blue'),
-        (STATUS_COMPLETED, _('Completed'), 'green'),
-        (STATUS_ERRORED, _('Errored'), 'red'),
-        (STATUS_FAILED, _('Failed'), 'red'),
+        Choice(STATUS_PENDING, _('Pending'), color='cyan', description=_('Awaiting execution')),
+        Choice(STATUS_SCHEDULED, _('Scheduled'), color='gray', description=_('Scheduled to run at a future time')),
+        Choice(STATUS_RUNNING, _('Running'), color='blue', description=_('Currently executing')),
+        Choice(STATUS_COMPLETED, _('Completed'), color='green', description=_('Finished successfully')),
+        Choice(STATUS_ERRORED, _('Errored'), color='red', description=_('Terminated due to an unhandled error')),
+        Choice(STATUS_FAILED, _('Failed'), color='red', description=_('Failed to complete')),
     )
 
     ENQUEUED_STATE_CHOICES = (
@@ -78,9 +78,9 @@ class JobNotificationChoices(ChoiceSet):
     NOTIFICATION_NEVER = 'never'
 
     CHOICES = (
-        (NOTIFICATION_ALWAYS, _('Always')),
-        (NOTIFICATION_ON_FAILURE, _('On failure')),
-        (NOTIFICATION_NEVER, _('Never')),
+        Choice(NOTIFICATION_ALWAYS, _('Always'), description=_('Notify after every job execution')),
+        Choice(NOTIFICATION_ON_FAILURE, _('On failure'), description=_('Notify only when a job fails')),
+        Choice(NOTIFICATION_NEVER, _('Never'), description=_('Never send job notifications')),
     )
 
 
@@ -91,12 +91,12 @@ class JobIntervalChoices(ChoiceSet):
     INTERVAL_WEEKLY = 60 * 24 * 7
 
     CHOICES = (
-        (INTERVAL_MINUTELY, _('Minutely')),
-        (INTERVAL_HOURLY, _('Hourly')),
-        (INTERVAL_HOURLY * 12, _('12 hours')),
-        (INTERVAL_DAILY, _('Daily')),
-        (INTERVAL_WEEKLY, _('Weekly')),
-        (INTERVAL_DAILY * 30, _('30 days')),
+        Choice(INTERVAL_MINUTELY, _('Minutely')),
+        Choice(INTERVAL_HOURLY, _('Hourly')),
+        Choice(INTERVAL_HOURLY * 12, _('12 hours')),
+        Choice(INTERVAL_DAILY, _('Daily')),
+        Choice(INTERVAL_WEEKLY, _('Weekly')),
+        Choice(INTERVAL_DAILY * 30, _('30 days')),
     )
 
 
@@ -111,7 +111,7 @@ class ObjectChangeActionChoices(ChoiceSet):
     ACTION_DELETE = 'delete'
 
     CHOICES = (
-        (ACTION_CREATE, _('Created'), 'green'),
-        (ACTION_UPDATE, _('Updated'), 'blue'),
-        (ACTION_DELETE, _('Deleted'), 'red'),
+        Choice(ACTION_CREATE, _('Created'), color='green'),
+        Choice(ACTION_UPDATE, _('Updated'), color='blue'),
+        Choice(ACTION_DELETE, _('Deleted'), color='red'),
     )

+ 14 - 1
netbox/core/filtersets.py

@@ -132,6 +132,19 @@ class JobFilterSet(BaseFilterSet):
         field_name='completed',
         lookup_expr='gte'
     )
+    execution_time = django_filters.DurationFilter(
+        label=_('Execution time')
+    )
+    execution_time__gte = django_filters.DurationFilter(
+        field_name='execution_time',
+        lookup_expr='gte',
+        label=_('Execution time (minimum)')
+    )
+    execution_time__lte = django_filters.DurationFilter(
+        field_name='execution_time',
+        lookup_expr='lte',
+        label=_('Execution time (maximum)')
+    )
     status = django_filters.MultipleChoiceFilter(
         choices=JobStatusChoices,
         distinct=False,
@@ -160,7 +173,7 @@ class JobFilterSet(BaseFilterSet):
         model = Job
         fields = (
             'id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id',
-            'queue_name',
+            'queue_name', 'execution_time',
         )
 
     def search(self, queryset, name, value):

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

@@ -5,6 +5,7 @@ from core.choices import JobIntervalChoices
 from core.models import *
 from netbox.forms import PrimaryModelBulkEditForm
 from netbox.utils import get_data_backend_choices
+from utilities.forms.fields import ChoiceField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 
@@ -14,7 +15,7 @@ __all__ = (
 
 
 class DataSourceBulkEditForm(PrimaryModelBulkEditForm):
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         choices=get_data_backend_choices,
         required=False
@@ -24,7 +25,7 @@ class DataSourceBulkEditForm(PrimaryModelBulkEditForm):
         widget=BulkEditNullBooleanSelect(),
         label=_('Enabled')
     )
-    sync_interval = forms.ChoiceField(
+    sync_interval = ChoiceField(
         choices=JobIntervalChoices,
         required=False,
         label=_('Sync interval')

+ 13 - 2
netbox/core/forms/filtersets.py

@@ -75,11 +75,12 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
     model = Job
     fieldsets = (
         FieldSet('q', 'filter_id'),
-        FieldSet('object_type_id', 'status', 'queue_name', name=_('Attributes')),
+        FieldSet('object_type_id', 'status', 'queue_name', 'user', name=_('Attributes')),
         FieldSet(
             'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
-            'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
+            'started__after', 'completed__before', 'completed__after', name=_('Scheduling')
         ),
+        FieldSet('execution_time__gte', 'execution_time__lte', name=_('Execution')),
     )
     object_type_id = ContentTypeChoiceField(
         label=_('Object Type'),
@@ -140,6 +141,16 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
         required=False,
         label=_('User')
     )
+    execution_time__gte = forms.DurationField(
+        label=_('Execution time (minimum)'),
+        required=False,
+        help_text=_('Seconds, or HH:MM:SS')
+    )
+    execution_time__lte = forms.DurationField(
+        label=_('Execution time (maximum)'),
+        required=False,
+        help_text=_('Seconds, or HH:MM:SS')
+    )
 
 
 class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):

+ 3 - 2
netbox/core/forms/model_forms.py

@@ -13,7 +13,7 @@ from netbox.forms import NetBoxModelForm, PrimaryModelForm
 from netbox.registry import registry
 from netbox.utils import get_data_backend_choices
 from utilities.forms import get_field_value
-from utilities.forms.fields import JSONField
+from utilities.forms.fields import ChoiceField, JSONField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import HTMXSelect
 
@@ -27,8 +27,9 @@ EMPTY_VALUES = ('', None, [], ())
 
 
 class DataSourceForm(PrimaryModelForm):
-    type = forms.ChoiceField(
+    type = ChoiceField(
         choices=get_data_backend_choices,
+        # No hx_target_id: changing type adds/removes the Backend Parameters fieldset entirely.
         widget=HTMXSelect()
     )
 

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

@@ -7,7 +7,7 @@ from strawberry.scalars import ID
 from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup, StrFilterLookup
 
 from core import models
-from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter
+from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter, register_filter
 
 from .enums import *
 
@@ -23,7 +23,7 @@ __all__ = (
 )
 
 
-@strawberry_django.filter_type(models.DataFile, lookups=True)
+@register_filter(models.DataFile, lookups=True)
 class DataFileFilter(BaseModelFilter):
     created: DatetimeFilterLookup | None = strawberry_django.filter_field()
     last_updated: DatetimeFilterLookup | None = strawberry_django.filter_field()
@@ -38,7 +38,7 @@ class DataFileFilter(BaseModelFilter):
     hash: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.DataSource, lookups=True)
+@register_filter(models.DataSource, lookups=True)
 class DataSourceFilter(PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     type: StrFilterLookup | None = strawberry_django.filter_field()
@@ -57,7 +57,7 @@ class DataSourceFilter(PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.ObjectChange, lookups=True)
+@register_filter(models.ObjectChange, lookups=True)
 class ObjectChangeFilter(BaseModelFilter):
     time: DatetimeFilterLookup | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@@ -84,7 +84,7 @@ class ObjectChangeFilter(BaseModelFilter):
     )
 
 
-@strawberry_django.filter_type(DjangoContentType, lookups=True)
+@register_filter(DjangoContentType, lookups=True)
 class ContentTypeFilter(BaseModelFilter):
     app_label: StrFilterLookup | None = strawberry_django.filter_field()
     model: StrFilterLookup | None = strawberry_django.filter_field()

+ 5 - 6
netbox/core/graphql/types.py

@@ -1,11 +1,10 @@
 from typing import Annotated
 
 import strawberry
-import strawberry_django
 from django.contrib.contenttypes.models import ContentType as DjangoContentType
 
 from core import models
-from netbox.graphql.types import BaseObjectType, PrimaryObjectType
+from netbox.graphql.types import BaseObjectType, PrimaryObjectType, register_type
 
 from .filters import *
 
@@ -17,7 +16,7 @@ __all__ = (
 )
 
 
-@strawberry_django.type(
+@register_type(
     models.DataFile,
     exclude=['data',],
     filters=DataFileFilter,
@@ -27,7 +26,7 @@ class DataFileType(BaseObjectType):
     source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')]
 
 
-@strawberry_django.type(
+@register_type(
     models.DataSource,
     fields='__all__',
     filters=DataSourceFilter,
@@ -37,7 +36,7 @@ class DataSourceType(PrimaryObjectType):
     datafiles: list[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ObjectChange,
     fields='__all__',
     filters=ObjectChangeFilter,
@@ -47,7 +46,7 @@ class ObjectChangeType(BaseObjectType):
     pass
 
 
-@strawberry_django.type(
+@register_type(
     DjangoContentType,
     fields='__all__',
     pagination=True

+ 1 - 1
netbox/core/migrations/0020_owner.py

@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
             model_name='datasource',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
     ]

+ 16 - 0
netbox/core/migrations/0025_add_job_execution_time.py

@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("core", "0024_job_notifications"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="job",
+            name="execution_time",
+            field=models.DurationField(blank=True, editable=False, null=True),
+        ),
+    ]

+ 46 - 0
netbox/core/migrations/0026_populate_job_execution_time.py

@@ -0,0 +1,46 @@
+from django.db import migrations
+from django.db.models import DurationField, ExpressionWrapper, F
+
+BATCH_SIZE = 5000
+
+
+def populate_execution_time(apps, schema_editor):
+    """
+    Populate execution_time for existing jobs which have both a start and completion time recorded.
+    Updates are performed in batches, as installations which retain job history indefinitely can
+    accumulate a very large number of rows. Rows which already have a value are skipped, so that an
+    interrupted run can simply be resumed.
+    """
+    Job = apps.get_model("core", "Job")
+    queryset = Job.objects.filter(
+        started__isnull=False, completed__isnull=False, execution_time__isnull=True
+    )
+    execution_time = ExpressionWrapper(F("completed") - F("started"), output_field=DurationField())
+
+    last_pk = 0
+    while True:
+        pks = list(
+            queryset.filter(pk__gt=last_pk).order_by("pk").values_list("pk", flat=True)[:BATCH_SIZE]
+        )
+        if not pks:
+            break
+        Job.objects.filter(pk__in=pks).update(execution_time=execution_time)
+        last_pk = pks[-1]
+
+
+class Migration(migrations.Migration):
+    # The backfill is deliberately kept out of the migration which adds the column, so that the
+    # ACCESS EXCLUSIVE lock taken by ALTER TABLE is not held for its duration. Running without a
+    # wrapping transaction is what allows the batching above to bound the work actually held open.
+    atomic = False
+
+    dependencies = [
+        ("core", "0025_add_job_execution_time"),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=populate_execution_time,
+            reverse_code=migrations.RunPython.noop,
+        ),
+    ]

+ 15 - 3
netbox/core/models/change_logging.py

@@ -6,7 +6,6 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel
 
 from core.choices import ObjectChangeActionChoices
 from core.querysets import ObjectChangeQuerySet
@@ -160,13 +159,26 @@ class ObjectChange(models.Model):
         model = self.changed_object_type.model_class()
         attrs = set()
 
+        # model_class() returns None when the model's app is no longer installed
+        # (e.g. a removed plugin); there are no fields to exclude, and the
+        # issubclass() checks below would raise TypeError on None.
+        if model is None:
+            return attrs
+
         # Exclude auto-populated change tracking fields
         if issubclass(model, ChangeLoggingMixin):
             attrs.update({'created', 'last_updated'})
 
-        # Exclude MPTT-internal fields
+        # Exclude trigger-maintained ltree columns (path and the optional sort_path)
+        from netbox.models.ltree import LtreeModel
+        if issubclass(model, LtreeModel):
+            attrs.update({'path', 'sort_path'})
+
+        # Exclude MPTT bookkeeping columns for the deprecated MPTT-backed
+        # NestedGroupModel still shipped for plugin compatibility.
+        from mptt.models import MPTTModel
         if issubclass(model, MPTTModel):
-            attrs.update({'level', 'lft', 'rght', 'tree_id'})
+            attrs.update({'lft', 'rght', 'tree_id', 'level'})
 
         return attrs
 

+ 11 - 5
netbox/core/models/config.py

@@ -1,10 +1,10 @@
 from django.core.cache import cache
-from django.db import models
+from django.db import models, router, transaction
 from django.urls import reverse
 from django.utils.translation import gettext
 from django.utils.translation import gettext_lazy as _
 
-from utilities.querysets import RestrictedQuerySet
+from utilities.querysets import RestrictedQuerySet, chunked_update
 
 __all__ = (
     'ConfigRevision',
@@ -78,9 +78,15 @@ class ConfigRevision(models.Model):
         cache.set('config_version', self.pk, None)
 
         if update_db:
-            # Set all instances of ConfigRevision to false and set this instance to true
-            ConfigRevision.objects.all().update(active=False)
-            ConfigRevision.objects.filter(pk=self.pk).update(active=True)
+            # Set all instances of ConfigRevision to false and set this instance to true. Wrap both
+            # statements in a transaction so the "exactly one active revision" invariant is preserved
+            # even when the deactivation is chunked into multiple statements. Resolve the write alias
+            # once and pin the transaction and both querysets to it, so the transaction genuinely
+            # covers the (potentially router-directed) writes performed by chunked_update().
+            using = router.db_for_write(ConfigRevision)
+            with transaction.atomic(using=using):
+                chunked_update(ConfigRevision.objects.using(using).all(), active=False)
+                ConfigRevision.objects.using(using).filter(pk=self.pk).update(active=True)
 
     activate.alters_data = True
 

+ 3 - 1
netbox/core/models/data.py

@@ -220,7 +220,9 @@ class DataSource(JobsMixin, PrimaryModel):
                     continue
 
             # Bulk update modified files
-            updated_count = DataFile.objects.bulk_update(updated_files, ('last_updated', 'size', 'hash', 'data'))
+            updated_count = DataFile.objects.bulk_update(
+                updated_files, ('last_updated', 'size', 'hash', 'data'), batch_size=settings.BULK_UPDATE_CHUNK_SIZE
+            )
             logger.debug(f"Updated {updated_count} files")
 
             # Bulk delete deleted files

+ 8 - 0
netbox/core/models/jobs.py

@@ -84,6 +84,12 @@ class Job(models.Model):
         null=True,
         blank=True
     )
+    execution_time = models.DurationField(
+        verbose_name=_('execution time'),
+        null=True,
+        blank=True,
+        editable=False
+    )
     user = models.ForeignKey(
         to=settings.AUTH_USER_MODEL,
         on_delete=models.SET_NULL,
@@ -241,6 +247,8 @@ class Job(models.Model):
         if error:
             self.error = error
         self.completed = timezone.now()
+        if self.started:
+            self.execution_time = self.completed - self.started
         self.save()
 
         # Notify the user (if any) of completion

+ 15 - 2
netbox/core/tables/jobs.py

@@ -7,6 +7,7 @@ from core.constants import JOB_LOG_ENTRY_LEVELS
 from core.models import Job
 from core.tables.columns import BadgeColumn
 from netbox.tables import BaseTable, NetBoxTable, columns
+from utilities.string import humanize_duration
 
 
 class JobTable(NetBoxTable):
@@ -44,6 +45,9 @@ class JobTable(NetBoxTable):
     completed = columns.DateTimeColumn(
         verbose_name=_('Completed'),
     )
+    execution_time = tables.Column(
+        verbose_name=_('Execution Time'),
+    )
     queue_name = tables.Column(
         verbose_name=_('Queue'),
     )
@@ -58,15 +62,24 @@ class JobTable(NetBoxTable):
         model = Job
         fields = (
             'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
-            'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id',
+            'completed', 'execution_time', 'user', 'queue_name', 'log_entries', 'error', 'job_id',
         )
         default_columns = (
-            'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
+            'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'execution_time',
+            'user',
         )
 
     def render_log_entries(self, value):
         return len(value)
 
+    def render_execution_time(self, value):
+        return humanize_duration(value)
+
+    def value_execution_time(self, value):
+        # Export the recorded execution time as a raw number of seconds, rather than the humanized
+        # string, as an export is intended for analysis
+        return round(value.total_seconds(), 3)
+
 
 class JobLogEntryTable(BaseTable):
     timestamp = columns.DateTimeColumn(

+ 16 - 0
netbox/core/tests/test_api.py

@@ -206,10 +206,26 @@ class JobTestCase(
                     status='completed',
                     queue_name='default',
                     job_id=uuid.uuid4(),
+                    execution_time=timezone.timedelta(seconds=90),
                 ),
             ]
         )
 
+    def test_list_objects_by_execution_time(self):
+        """The Job list endpoint supports filtering and ordering by execution_time."""
+        self.add_permissions('core.view_job')
+        url = reverse('core-api:job-list')
+
+        # Filter: only the completed job has a (90s) execution_time
+        response = self.client.get(f'{url}?execution_time__gte=60', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['count'], 1)
+
+        # Ordering by execution_time should be accepted (NULLs sort to one end)
+        response = self.client.get(f'{url}?ordering=execution_time', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['count'], 3)
+
 
 class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
     user_permissions = ()

+ 19 - 7
netbox/core/tests/test_filtersets.py

@@ -1,5 +1,5 @@
 import uuid
-from datetime import UTC, datetime
+from datetime import UTC, datetime, timedelta
 
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
@@ -7,14 +7,14 @@ from django.test import TestCase
 from dcim.models import Site
 from ipam.models import IPAddress
 from users.models import User
-from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests
+from utilities.testing import BaseFilterSetTestMixin, ChangeLoggedFilterSetTestMixin
 
 from ..choices import *
 from ..filtersets import *
 from ..models import *
 
 
-class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
+class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = DataSource.objects.all()
     filterset = DataSourceFilterSet
     ignore_fields = ('ignore_rules', 'parameters')
@@ -82,7 +82,7 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
+class DataFileTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = DataFile.objects.all()
     filterset = DataFileFilterSet
     ignore_fields = ('data',)
@@ -148,7 +148,7 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
+class ObjectChangeTestCase(TestCase, BaseFilterSetTestMixin):
     queryset = ObjectChange.objects.all()
     filterset = ObjectChangeFilterSet
     ignore_fields = ('message', 'prechange_data', 'postchange_data')
@@ -244,7 +244,7 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
-class JobTestCase(TestCase, BaseFilterSetTests):
+class JobTestCase(TestCase, BaseFilterSetTestMixin):
     queryset = Job.objects.all()
     filterset = JobFilterSet
     ignore_fields = ('data', 'error', 'log_entries')
@@ -262,14 +262,17 @@ class JobTestCase(TestCase, BaseFilterSetTests):
             Job(
                 name='Job 1', job_id=uuid.uuid4(), user=users[0],
                 notifications=JobNotificationChoices.NOTIFICATION_ALWAYS,
+                execution_time=timedelta(seconds=30),
             ),
             Job(
                 name='Job 2', job_id=uuid.uuid4(), user=users[0],
                 notifications=JobNotificationChoices.NOTIFICATION_ALWAYS,
+                execution_time=timedelta(seconds=60),
             ),
             Job(
                 name='Job 3', job_id=uuid.uuid4(), user=users[1],
                 notifications=JobNotificationChoices.NOTIFICATION_ON_FAILURE,
+                execution_time=timedelta(seconds=120),
             ),
             Job(
                 name='Job 4', job_id=uuid.uuid4(), user=users[2],
@@ -293,8 +296,17 @@ class JobTestCase(TestCase, BaseFilterSetTests):
         ]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
+    def test_execution_time(self):
+        """Filter Jobs by execution time (exact value and gte/lte range)."""
+        params = {'execution_time': timedelta(seconds=60)}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'execution_time__gte': timedelta(seconds=60)}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'execution_time__lte': timedelta(seconds=60)}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
-class ObjectTypeTestCase(TestCase, BaseFilterSetTests):
+class ObjectTypeTestCase(TestCase, BaseFilterSetTestMixin):
     queryset = ObjectType.objects.all()
     filterset = ObjectTypeFilterSet
     ignore_fields = (

+ 37 - 0
netbox/core/tests/test_models.py

@@ -1,9 +1,11 @@
 import uuid
+from datetime import timedelta
 from unittest.mock import MagicMock, patch
 
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.test import TestCase
+from django.utils import timezone
 
 from core.choices import JobNotificationChoices, JobStatusChoices, ObjectChangeActionChoices
 from core.models import DataSource, Job, ObjectType
@@ -344,3 +346,38 @@ class JobTestCase(TestCase):
                     0,
                     msg=f"Expected no notification for status={status} with notifications=never",
                 )
+
+    @patch('core.models.jobs.job_end')
+    def test_execution_time_set_on_terminate(self, mock_job_end):
+        """
+        terminate() should set execution_time to (completed - started) for a started job.
+        """
+        job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
+        job.started = timezone.now() - timedelta(seconds=90)
+        job.save()
+
+        job.terminate(status=JobStatusChoices.STATUS_COMPLETED)
+
+        self.assertIsNotNone(job.execution_time)
+        self.assertEqual(job.execution_time, job.completed - job.started)
+
+    @patch('core.models.jobs.job_end')
+    def test_execution_time_none_before_completion(self, mock_job_end):
+        """
+        A job which has not completed should have a null execution_time.
+        """
+        job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
+        self.assertIsNone(job.execution_time)
+
+    @patch('core.models.jobs.job_end')
+    def test_execution_time_none_when_never_started(self, mock_job_end):
+        """
+        Terminating a job which was never started (no started timestamp) should leave
+        execution_time null rather than computing a value from a missing start time.
+        """
+        job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
+        self.assertIsNone(job.started)
+
+        job.terminate(status=JobStatusChoices.STATUS_COMPLETED)
+
+        self.assertIsNone(job.execution_time)

+ 67 - 1
netbox/core/tests/test_tables.py

@@ -1,4 +1,11 @@
-from core.models import ObjectChange
+import uuid
+from datetime import timedelta
+
+from django.test import TestCase
+from django.utils import timezone
+
+from core.choices import JobStatusChoices
+from core.models import Job, ObjectChange
 from core.tables import *
 from utilities.testing import TableTestCases
 
@@ -15,6 +22,65 @@ class JobTableTestCase(TableTestCases.StandardTableTestCase):
     table = JobTable
 
 
+class JobExecutionTimeColumnTestCase(TestCase):
+    """
+    Test the rendering and export behavior of JobTable's execution_time column.
+    """
+    @classmethod
+    def setUpTestData(cls):
+        now = timezone.now()
+        Job.objects.bulk_create((
+            Job(
+                name='completed-90s', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_COMPLETED,
+                started=now - timedelta(seconds=90), completed=now, execution_time=timedelta(seconds=90),
+            ),
+            Job(
+                name='completed-subsecond', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_COMPLETED,
+                started=now - timedelta(milliseconds=430), completed=now,
+                execution_time=timedelta(milliseconds=430),
+            ),
+            Job(
+                name='completed-long', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_COMPLETED,
+                started=now - timedelta(days=2, hours=3), completed=now,
+                execution_time=timedelta(days=2, hours=3),
+            ),
+            Job(name='pending', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_PENDING),
+        ))
+
+    def _table(self, name):
+        table = JobTable(Job.objects.filter(name=name))
+        table.columns.show('execution_time')
+        return table
+
+    def _render(self, name):
+        table = self._table(name)
+        return str(next(iter(table.rows)).get_cell('execution_time'))
+
+    def test_render_completed_job(self):
+        self.assertEqual(self._render('completed-90s'), '1m 30s')
+        self.assertEqual(self._render('completed-long'), '2d 3h')
+
+    def test_render_subsecond_job(self):
+        # Sub-second jobs report millisecond precision rather than reading as zero
+        self.assertEqual(self._render('completed-subsecond'), '0.43s')
+
+    def test_render_job_without_execution_time(self):
+        table = self._table('pending')
+        self.assertEqual(str(next(iter(table.rows)).get_cell('execution_time')), table.default)
+
+    def _export_value(self, name):
+        rows = list(self._table(name).as_values())
+        return rows[1][rows[0].index('Execution Time')]
+
+    def test_export_value_is_raw_seconds(self):
+        # Exports carry the recorded duration in seconds, not the humanized string
+        self.assertEqual(self._export_value('completed-90s'), 90.0)
+        self.assertEqual(self._export_value('completed-subsecond'), 0.43)
+
+    def test_export_value_of_job_without_execution_time(self):
+        self.assertIsNone(self._export_value('pending'))
+
+
 class ObjectChangeTableTestCase(TableTestCases.StandardTableTestCase):
     table = ObjectChangeTable
     queryset_sources = [

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů