Просмотр исходного кода

Merge pull request #23103 from netbox-community/feature

Merge `feature` into `main`
Jeremy Stretch 21 часов назад
Родитель
Сommit
8cc4548e1f
100 измененных файлов с 3493 добавлено и 684 удалено
  1. 3 3
      .claude/skills/add-model/SKILL.md
  2. 55 31
      .github/workflows/release.yml
  3. 11 10
      base_requirements.txt
  4. 1 0
      contrib/generated_schema.json
  5. 733 47
      contrib/openapi.json
  6. 4 2
      docs/administration/error-reporting.md
  7. 13 2
      docs/administration/management-commands.md
  8. 14 0
      docs/configuration/data-validation.md
  9. 0 77
      docs/configuration/error-reporting.md
  10. 0 4
      docs/configuration/graphql-api.md
  11. 3 0
      docs/configuration/index.md
  12. 20 3
      docs/configuration/miscellaneous.md
  13. 3 5
      docs/configuration/required-parameters.md
  14. 1 4
      docs/configuration/security.md
  15. 37 14
      docs/configuration/system.md
  16. 55 2
      docs/customization/custom-fields.md
  17. 5 2
      docs/customization/custom-links.md
  18. 5 0
      docs/customization/custom-scripts.md
  19. 2 0
      docs/customization/reports.md
  20. 0 4
      docs/development/application-registry.md
  21. 9 7
      docs/development/building-the-package.md
  22. 51 10
      docs/development/release-checklist.md
  23. 11 0
      docs/features/context-data.md
  24. 43 0
      docs/features/cooling.md
  25. 3 0
      docs/features/customization.md
  26. 0 2
      docs/features/resource-ownership.md
  27. 1 1
      docs/features/search.md
  28. 3 1
      docs/getting-started/populating-data.md
  29. 3 7
      docs/installation/1-postgresql.md
  30. 0 3
      docs/installation/2-redis.md
  31. 3 3
      docs/installation/3-netbox.md
  32. 361 0
      docs/installation/3b-python-package.md
  33. 29 10
      docs/installation/6-ldap.md
  34. 8 6
      docs/installation/index.md
  35. 121 11
      docs/installation/upgrading.md
  36. 0 5
      docs/integrations/graphql-api.md
  37. 71 11
      docs/integrations/rest-api.md
  38. 3 10
      docs/integrations/webhooks.md
  39. 1 3
      docs/introduction.md
  40. 4 0
      docs/models/core/job.md
  41. 3 3
      docs/models/dcim/cable.md
  42. 37 0
      docs/models/dcim/coolingfeed.md
  43. 40 0
      docs/models/dcim/coolingintake.md
  44. 3 0
      docs/models/dcim/coolingintaketemplate.md
  45. 38 0
      docs/models/dcim/coolingoutflow.md
  46. 3 0
      docs/models/dcim/coolingoutflowtemplate.md
  47. 36 0
      docs/models/dcim/coolingsource.md
  48. 4 0
      docs/models/dcim/device.md
  49. 8 0
      docs/models/dcim/devicetype.md
  50. 19 4
      docs/models/dcim/interface.md
  51. 18 0
      docs/models/dcim/module.md
  52. 4 0
      docs/models/dcim/modulebay.md
  53. 4 0
      docs/models/dcim/modulebaytemplate.md
  54. 37 0
      docs/models/dcim/modulebaytype.md
  55. 14 0
      docs/models/dcim/moduletype.md
  56. 0 2
      docs/models/dcim/platform.md
  57. 32 2
      docs/models/dcim/rack.md
  58. 8 0
      docs/models/dcim/racktype.md
  59. 8 0
      docs/models/extras/customfield.md
  60. 5 0
      docs/models/extras/eventrule.md
  61. 14 4
      docs/models/extras/webhook.md
  62. 0 2
      docs/models/ipam/iprange.md
  63. 54 11
      docs/models/ipam/service.md
  64. 6 6
      docs/models/ipam/servicetemplate.md
  65. 0 3
      docs/models/virtualization/virtualmachine.md
  66. 1 1
      docs/models/virtualization/vminterface.md
  67. 0 2
      docs/plugins/development/background-jobs.md
  68. 117 0
      docs/plugins/development/config-templates.md
  69. 64 0
      docs/plugins/development/event-rule-actions.md
  70. 1 2
      docs/plugins/development/filtersets.md
  71. 45 0
      docs/plugins/development/forms.md
  72. 98 2
      docs/plugins/development/graphql-api.md
  73. 4 0
      docs/plugins/development/index.md
  74. 0 3
      docs/plugins/development/navigation.md
  75. 69 3
      docs/plugins/development/ui-components.md
  76. 0 6
      docs/plugins/development/views.md
  77. 0 7
      docs/plugins/development/webhooks.md
  78. 91 1
      docs/reference/conditions.md
  79. 273 0
      docs/release-notes/version-4.7.md
  80. 13 1
      mkdocs.yml
  81. 15 5
      netbox/account/views.py
  82. 0 12
      netbox/circuits/apps.py
  83. 44 38
      netbox/circuits/choices.py
  84. 15 39
      netbox/circuits/forms/bulk_edit.py
  85. 58 109
      netbox/circuits/forms/model_forms.py
  86. 17 12
      netbox/circuits/graphql/filters.py
  87. 12 12
      netbox/circuits/graphql/types.py
  88. 8 8
      netbox/circuits/migrations/0053_owner.py
  89. 38 0
      netbox/circuits/migrations/0060_denormalization_triggers.py
  90. 12 12
      netbox/circuits/tests/test_filtersets.py
  91. 4 4
      netbox/circuits/tests/test_forms.py
  92. 83 1
      netbox/circuits/tests/test_models.py
  93. 4 4
      netbox/circuits/tests/test_views.py
  94. 2 2
      netbox/circuits/ui/panels.py
  95. 44 0
      netbox/circuits/views.py
  96. 150 3
      netbox/core/api/schema.py
  97. 2 2
      netbox/core/api/serializers_/jobs.py
  98. 6 1
      netbox/core/apps.py
  99. 92 29
      netbox/core/checks.py
  100. 26 26
      netbox/core/choices.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
 

+ 55 - 31
.github/workflows/release.yml

@@ -93,6 +93,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:
@@ -309,15 +323,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
@@ -326,38 +339,49 @@ 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
+        # Bundles twine 7.0.0 and packaging 26.2 (requirements/runtime.txt).
+        # Keep EXPECTED_TWINE_VERSION and EXPECTED_PACKAGING_VERSION aligned when updating this action.
+        uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
         with:
-          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
         # Bundles twine 7.0.0 and packaging 26.2 (requirements/runtime.txt).
         # Keep EXPECTED_TWINE_VERSION and EXPECTED_PACKAGING_VERSION aligned when updating this action.
         uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
         with:
-          repository-url: https://test.pypi.org/legacy/
+          print-hash: true

+ 11 - 10
base_requirements.txt

@@ -4,7 +4,7 @@ colorama
 
 # The Python web framework on which NetBox is built
 # https://docs.djangoproject.com/en/stable/releases/
-Django==6.0.*
+Django==6.1.*
 
 # Django middleware which permits cross-domain API requests
 # https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
@@ -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
@@ -72,7 +74,7 @@ django-timezone-field
 # A REST API framework for Django projects
 # https://www.django-rest-framework.org/community/release-notes/
 # TODO: Re-evaluate the monkey-patch of get_unique_validators() before upgrading
-djangorestframework==3.17.1
+djangorestframework==3.18.0
 
 # Sane and flexible OpenAPI 3 schema generation for Django REST framework.
 # https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst
@@ -158,8 +160,7 @@ social-auth-app-django
 
 # Social authentication framework
 # https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
-# Need to verify that v4.9.0 does not introduce breaking changes (see #22095)
-social-auth-core==4.8.*
+social-auth-core
 
 # Image thumbnail generation
 # https://github.com/jazzband/sorl-thumbnail/blob/master/CHANGES.rst

+ 1 - 0
contrib/generated_schema.json

@@ -328,6 +328,7 @@
                         "virtual",
                         "bridge",
                         "lag",
+                        "channel",
                         "100base-fx",
                         "100base-lfx",
                         "100base-tx",

Разница между файлами не показана из-за своего большого размера
+ 733 - 47
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.

+ 13 - 2
docs/administration/management-commands.md

@@ -28,14 +28,22 @@ python3 netbox/manage.py nbshell
 
 ## populate_image_sizes
 
-!!! info "This command was introduced in NetBox v4.6.4."
-
 Populate the cached file size for image attachments that predate the `image_size` field. Running this once after upgrading is recommended for deployments with many existing attachments on a remote storage backend (such as S3). It is safe to run on a live system and may be re-run; any file that cannot be read is skipped and retried on the next run.
 
 ```
 python3 netbox/manage.py populate_image_sizes
 ```
 
+## rebuild_config_context_cache
+
+Pre-render and cache the merged config context data for all devices and virtual machines. The [upgrade script](../installation/upgrading.md) runs this automatically, so it is not usually necessary to invoke it by hand. It is useful to complete an interrupted run, or (with `--force`) to repair the cache after a bulk write which bypassed NetBox's change handling (cache invalidation is driven by model signals, which a direct `queryset.update()` does not emit).
+
+By default, only those objects whose cache is empty are rendered, so the command is safe to interrupt and re-run. This also means that a default run will not correct a cache which is populated but stale, as a write which bypassed cache invalidation leaves it: Pass `--force` to re-render every object regardless of its current cache. Either form may be run on a live system, as any object whose cache is empty falls back to rendering its config context on demand. See [Context Data](../features/context-data.md) for details.
+
+```
+python3 netbox/manage.py rebuild_config_context_cache [--force]
+```
+
 ## rebuild_prefixes
 
 Rebuild the IPAM prefix hierarchy, recalculating the depth and child counts for all prefixes.
@@ -62,6 +70,9 @@ python3 netbox/manage.py renaturalize [app_label.ModelName ...]
 
 ## runscript
 
+!!! warning "Deprecation Warning"
+    The custom scripts functionality has been deprecated beginning in NetBox v4.7, and is scheduled for removal in NetBox v5.0. This command will be removed along with it.
+
 Run a [custom script](../customization/custom-scripts.md) from the command line, outside the web UI or API.
 
 ```

+ 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).

+ 0 - 4
docs/configuration/graphql-api.md

@@ -2,8 +2,6 @@
 
 ## GRAPHQL_DEFAULT_VERSION
 
-!!! note "This parameter was introduced in NetBox v4.5."
-
 Default: `1`
 
 Designates the default version of the GraphQL API served by `/graphql/`. To access a specific version, append the version number to the URL, e.g. `/graphql/v2/`.
@@ -30,8 +28,6 @@ The maximum number of queries that a GraphQL API request may contain.
 
 ## GRAPHQL_MAX_QUERY_DEPTH
 
-!!! note "This parameter was introduced in NetBox v4.6.1."
-
 Default: `None` (no limit)
 
 The maximum allowed depth of any GraphQL query. When set to a positive integer, requests containing queries that exceed this depth will be rejected. Leaving this parameter unset (or setting it to `None` or `0`) disables query depth enforcement.

+ 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 - 3
docs/configuration/miscellaneous.md

@@ -125,8 +125,6 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da
 
 ## STREAMING_EXPORTS
 
-!!! note "This parameter was introduced in NetBox v4.6."
-
 Default: `False`
 
 When set to `True`, CSV bulk exports are returned as a streaming HTTP response, emitting rows to the client as they are rendered rather than buffering the entire dataset in memory first. This can significantly reduce memory usage and time-to-first-byte for very large exports.
@@ -277,7 +275,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 +307,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 - 5
docs/configuration/required-parameters.md

@@ -25,8 +25,6 @@ ALLOWED_HOSTS = ['*']
 
 ## API_TOKEN_PEPPERS
 
-!!! info "This parameter was introduced in NetBox v4.5."
-
 [Cryptographic peppers](https://en.wikipedia.org/wiki/Pepper_(cryptography)) are employed to generate hashes of sensitive values on the server. This parameter defines the peppers used to hash v2 API tokens in NetBox. You must define at least one pepper before creating a v2 API token. See the [API documentation](../integrations/rest-api.md#authentication) for further information about how peppers are used.
 
 ```python
@@ -39,7 +37,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 +57,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 +249,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 - 4
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).
 
 !!! note
     Image sources (`<img src="...">`) are limited to HTTP(S) and relative URLs, subject to `ALLOWED_URL_SCHEMES`.
@@ -171,9 +171,6 @@ Default: `True`
 
 When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes).
 
-!!! info "Changed in NetBox v4.0.2"
-    Prior to NetBox v4.0.2, this setting was disabled by default.
-
 ---
 
 ## LOGIN_TIMEOUT

+ 37 - 14
docs/configuration/system.md

@@ -12,6 +12,22 @@ 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.
+
+This parameter also determines when a custom field operation is deferred to a background job: creating a field with a default value, or deleting a field, is performed within the request only where the field's assigned object types hold no more than this many objects in total (see [field status](../customization/custom-fields.md#field-status)). Setting it to `None` therefore defers every such operation which affects any object.
+
+```python
+BULK_UPDATE_CHUNK_SIZE = 5000
+```
+
+---
+
 ## DATABASE_ROUTERS
 
 Default: `[]` (empty list)
@@ -40,7 +56,7 @@ The filesystem path to NetBox's documentation. This is used when presenting cont
 
 In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` configuration parameter:
 
-* `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally)
+* `SERVER` - Hostname or IP address of the email server (required; use `localhost` if running locally)
 * `PORT` - TCP port to use for the connection (default: `25`)
 * `USERNAME` - Username with which to authenticate
 * `PASSWORD` - Password with which to authenticate
@@ -54,6 +70,9 @@ In order to send email, NetBox needs an email server configured. The following i
 !!! note
     The `USE_SSL` and `USE_TLS` parameters are mutually exclusive.
 
+!!! warning
+    `SERVER` must be defined in order to send email: A deployment which omits it raises an `InvalidMailer` exception when attempting to send. Note that this is raised at send time rather than at startup, so a misconfiguration here will not be apparent until NetBox first tries to send mail.
+
 Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration, Django provides a convenient [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail) function accessible within the NetBox shell:
 
 ```no-highlight
@@ -63,8 +82,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
   'Test Email Subject',
   'Test Email Body',
   'noreply-netbox@example.com',
-  ['users@example.com'],
-  fail_silently=False
+  ['users@example.com']
 )
 ```
 
@@ -72,8 +90,6 @@ Email is sent from NetBox only for critical events or if configured for [logging
 
 ## HOSTNAME
 
-!!! info "This parameter was introduced in NetBox v4.4."
-
 Default: System hostname
 
 The hostname displayed in the user interface identifying the system on which NetBox is running. If not defined, this defaults to the system hostname as reported by Python's `platform.node()`.
@@ -82,8 +98,6 @@ The hostname displayed in the user interface identifying the system on which Net
 
 ## HTTP_CLIENT_IP_HEADERS
 
-!!! info "This parameter was introduced in NetBox v4.6.1."
-
 Default:
 
 ```python
@@ -128,7 +142,7 @@ A list of IP addresses recognized as internal to the system, used to control the
 example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP
 addresses (and [`DEBUG`](./development.md#debug) is `True`).
 
-!!! info "New in NetBox v4.6"
+!!! info "Enabling the toolbar for all clients"
     Setting this parameter to an empty list will enable the toolbar for all requests provided debugging is enabled:
 
     ```python
@@ -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
@@ -262,6 +282,9 @@ The file path to the location where [custom reports](../customization/reports.md
 
 ## SCRIPTS_ROOT
 
+!!! warning "Deprecation Warning"
+    The custom scripts functionality has been deprecated beginning in NetBox v4.7, and is scheduled for removal in NetBox v5.0. This parameter will be removed along with it.
+
 Default: `$INSTALL_ROOT/netbox/scripts/`
 
 The file path to the location where [custom scripts](../customization/custom-scripts.md) will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path.

+ 55 - 2
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
@@ -37,7 +37,34 @@ Unless the field has been assigned a default value, creating a custom field does
 
 This matters only if you query the underlying `custom_field_data` JSON directly, for example in a custom script. The field's key is absent from an object's data until a value is assigned to it, so read it with `obj.cf['field_name']` or `obj.custom_field_data.get('field_name')` rather than by direct subscript.
 
-Assigning a default value, by contrast, does write that value to every existing object at the time the field is created, so that objects can be filtered by it immediately. On a model with a very large number of objects, this can take some time. Note that a default added to a field which already exists is _not_ backfilled: objects with no value continue to report none until they are next saved.
+Assigning a default value, by contrast, does write that value to every existing object at the time the field is created, so that objects can be filtered by it immediately. Note that a default added to a field which already exists is _not_ backfilled: objects with no value continue to report none until they are next saved.
+
+### Field Status
+
+!!! info "This behavior was introduced in NetBox v4.7.0."
+
+Creating a custom field with a default value, and deleting a custom field, both require rewriting the stored data of the objects the field applies to. Where the field is assigned to a large number of objects, this cannot be completed within the request, so it is handed to a background job instead and the field reports its status accordingly:
+
+| Status | Meaning |
+| ------ | ------- |
+| Active | The field is live and available for use. |
+| Provisioning | The field's default value is being written to existing objects. |
+| Deleting | The field's data is being removed from existing objects. |
+
+Whether a background job is required is determined by the total number of objects of the field's assigned object types, measured against the [`BULK_UPDATE_CHUNK_SIZE`](../configuration/system.md#bulk_update_chunk_size) configuration parameter — not by how many of those objects actually hold a value for the field. Deleting a field assigned to a large table is therefore deferred even where the field holds no data at all: NetBox cannot count the objects holding a value without scanning the entire table, which is the cost the threshold exists to avoid.
+
+A field is live only while active. During provisioning or deletion it does not appear on objects, in forms, in filters, or in either API, and its stored data is read and written by nothing but the job responsible for it; it becomes available (or disappears entirely) once the job completes. Objects created in the meantime are unaffected — a field being provisioned still supplies its default to new objects.
+
+A field which is not active cannot be modified while its job runs, as its configuration must not change under the job rewriting its data. This includes assigning it further object types, and unassigning those it already carries: such a change is rejected until the field is live again.
+
+A field pending deletion continues to occupy its name until its data has been removed, so that a new field cannot be created — and an existing field cannot be renamed — to a name whose old values are still present on objects.
+
+These operations require a running [background worker](../features/background-jobs.md) (`rqworker`). A field left mid-operation, for example because no worker was running or because its job failed, remains in its pending status until that job runs to completion.
+
+Such a field can always be deleted, whichever status it holds. Deleting one already pending deletion queues a fresh job to finish removing its data. A field left provisioning has no equivalent in-application retry: requeue its job from the background queues (**Admin > System > Background Tasks**, which requires a staff account), or delete the field and create it again.
+
+!!! note
+    Unassigning an object type from a custom field still removes the field's data from those objects immediately, and remains subject to the request timeout on very large tables. The same applies to renaming a custom field.
 
 ### Filtering
 
@@ -109,6 +136,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 +169,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.

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

@@ -1,5 +1,10 @@
 # Custom Scripts
 
+!!! warning "Deprecation Warning"
+    Beginning in NetBox v4.7, the custom scripts functionality built into core NetBox has been deprecated. It is being replaced by a dedicated open source plugin, which offers an expanded feature set including the organization of scripts into projects, the sharing of Python resources among scripts, and version control for individual scripts.
+
+    The core implementation will remain available and supported throughout the v4.7 and v4.8 release cycles, and is scheduled for removal in NetBox v5.0. No immediate action is required: Existing scripts will continue to work as they do today, and users may migrate to the plugin at any point during the migration period. Migration is intended to be a largely automated process which should not require rewriting scripts.
+
 Custom scripting was introduced to provide a way for users to execute custom logic from within the NetBox UI. Custom scripts enable the user to directly and conveniently manipulate NetBox data in a prescribed fashion. They can be used to accomplish myriad tasks, such as:
 
 * Automatically populate new devices and cables in preparation for a new site deployment

+ 2 - 0
docs/customization/reports.md

@@ -3,6 +3,8 @@
 !!! warning
     Reports are deprecated beginning with NetBox v4.0, and their functionality has been merged with [custom scripts](./custom-scripts.md). While backward compatibility has been maintained, users are advised to convert legacy reports into custom scripts soon, as support for legacy reports will be removed in a future release.
 
+    Beginning with NetBox v4.7, NetBox's built-in custom scripts implementation is deprecated and is being replaced by a dedicated plugin. Converting a legacy report to a custom script remains the recommended first step. See the [custom scripts documentation](./custom-scripts.md) for details.
+
 ## Converting Reports to Scripts
 
 ### Step 1: Update Class Definition

+ 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.
 
@@ -93,11 +95,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
 
@@ -105,7 +107,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 "This feature was introduced 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.

+ 43 - 0
docs/features/cooling.md

@@ -0,0 +1,43 @@
+# Cooling
+
+!!! info "This feature was introduced in NetBox v4.7."
+
+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.
+

+ 3 - 0
docs/features/customization.md

@@ -79,6 +79,9 @@ To learn more about this feature, check out the [documentation for reports](../c
 
 ## Custom Scripts
 
+!!! warning "Deprecation Warning"
+    Beginning in NetBox v4.7, the custom scripts functionality built into core NetBox has been deprecated in favor of a dedicated plugin, and is scheduled for removal in NetBox v5.0. See the [custom scripts documentation](../customization/custom-scripts.md) for details.
+
 Custom scripts are similar to reports, but more powerful. A custom script can prompt the user for input via a form (or API data), and is built to do much more than just reporting. Custom scripts are generally used to automate tasks, such as the population of new objects in NetBox, or exchanging data with external systems. As with reports, they can be run via the UI, REST API, or CLI, and be scheduled to execute at a future time.
 
 The complete Python environment is available to a custom script, including all of NetBox's internal mechanisms: There are no artificial restrictions on what a script can do. As such, custom scripting is considered an advanced feature and requires sufficient familiarity with Python and NetBox's data model.

+ 0 - 2
docs/features/resource-ownership.md

@@ -1,7 +1,5 @@
 # Resource Ownership
 
-!!! info "This feature was introduced in NetBox v4.5."
-
 Most objects in NetBox can be assigned an owner. An owner is a set of users and/or groups who are responsible for the administration of associated objects. For example, you might designate the operations team at a site as the owner for all prefixes and VLANs deployed at that site. The users and groups assigned to an owner are referred to as its members.
 
 !!! note

+ 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 - 7
docs/installation/1-postgresql.md

@@ -2,11 +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 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.
+!!! warning "PostgreSQL 15 or later required"
+    NetBox requires PostgreSQL 15 or later. Please note that MySQL and other relational databases are **not** supported.
 
 ## Installation
 
@@ -15,7 +12,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 +32,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:
 

+ 121 - 11
docs/installation/upgrading.md

@@ -22,26 +22,27 @@ 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
 
 | NetBox Version | Python min | Python max | PostgreSQL min | Redis min |                                       Documentation                                       |
 |:--------------:|:----------:|:----------:|:--------------:|:---------:|:-----------------------------------------------------------------------------------------:|
+|      4.7       |    3.12    |    3.14    |       15       |    6.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.7.0/docs/installation/index.md) |
 |      4.6       |    3.12    |    3.14    |       14       |    5.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.6.0/docs/installation/index.md) |
 |      4.5       |    3.12    |    3.14    |       14       |    5.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.5.0/docs/installation/index.md) |
 |      4.4       |    3.10    |    3.12    |       14       |    5.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.4.0/docs/installation/index.md) |
@@ -58,7 +59,36 @@ 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
+## Verify Database Permissions
+
+NetBox v4.7 and later require the PostgreSQL [`ltree` extension](https://www.postgresql.org/docs/current/ltree.html). NetBox installs this extension automatically when applying database migrations if it is not already present. Installing it requires that the NetBox database user hold the `CREATE` privilege on the database.
+
+!!! note
+    Installations created using NetBox's PostgreSQL setup instructions already satisfy this requirement because those instructions make the NetBox user the database owner. No additional grant is needed for these installations.
+
+If `ltree` is not already installed and the NetBox database user does not hold the `CREATE` privilege, grant it by invoking the PostgreSQL shell as the system Postgres user:
+
+```no-highlight
+sudo -u postgres psql
+```
+
+Then issue the following command, substituting the name of your database and user (role) where applicable:
+
+```postgresql
+GRANT CREATE ON DATABASE netbox TO netbox;
+```
+
+Alternatively, a database administrator can install the extension before upgrading:
+
+```postgresql
+CREATE EXTENSION IF NOT EXISTS ltree;
+```
+
+## 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 +103,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 +146,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 +165,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 +199,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 +209,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
+```

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

@@ -51,9 +51,6 @@ For more detail on constructing GraphQL queries, see the [GraphQL queries docume
 
 ## Filtering
 
-!!! note "Changed in NetBox v4.3"
-    The filtering syntax fo the GraphQL API has changed substantially in NetBox v4.3.
-
 Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only active sites:
 
 ```
@@ -139,8 +136,6 @@ The alternative approach is cursor-based pagination, which operates using absolu
 
 To ensure consistent ordering, objects will always be ordered by their primary keys when cursor-based pagination is used.
 
-!!! note "Cursor-based pagination was introduced in NetBox v4.5.2."
-
 Both pagination strategies support an optional `limit` parameter specifying the maximum number of objects to include in the response. The [`MAX_PAGE_SIZE`](../configuration/miscellaneous.md#max_page_size) configuration parameter (default `1000`) sets a hard ceiling on this value; if no limit is specified, up to `MAX_PAGE_SIZE` records are returned.
 
 When `MAX_PAGE_SIZE` is set to `0` or `None`:

+ 71 - 11
docs/integrations/rest-api.md

@@ -253,8 +253,6 @@ Similarly, you can opt to omit only specific fields by passing the `omit` parame
 GET /api/dcim/sites/?omit=circuit_count,device_count,virtualmachine_count
 ```
 
-!!! note "The `omit` parameter was introduced in NetBox v4.5.2."
-
 Strategic use of the `fields` and `omit` parameters can drastically improve REST API performance, as the exclusion of fields which reference related objects reduces the number and complexity of underlying database queries needed to generate the response.
 
 !!! note
@@ -338,10 +336,6 @@ GET /api/ipam/prefixes/13980/?brief=true
 
 The brief format is supported for both lists and individual objects.
 
-### Excluding Config Contexts
-
-When retrieving devices and virtual machines via the REST API, each will include its rendered [configuration context data](../features/context-data.md) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views.
-
 ## Pagination
 
 API responses which contain a list of many objects will be paginated for efficiency. NetBox employs offset-based pagination by default, which forms a page by skipping the number of objects indicated by the `offset` URL parameter. The root JSON object returned by a list endpoint contains the following attributes:
@@ -592,6 +586,9 @@ http://netbox/api/dcim/sites/ \
 ]
 ```
 
+!!! note
+    The bulk creation of objects is an all-or-none operation, meaning that if NetBox fails to successfully create any of the specified objects (e.g. due to a validation error), the entire operation will be aborted and none of the objects will be created.
+
 ### Updating an Object
 
 To modify an object which has already been created, make a `PATCH` request to the model's _detail_ endpoint specifying its unique numeric ID. Include any data which you wish to update on the object. As with object creation, the `Authorization` and `Content-Type` headers must also be specified.
@@ -666,9 +663,27 @@ Note that there is no requirement for the attributes to be identical among objec
 !!! note
     The bulk update of objects is an all-or-none operation, meaning that if NetBox fails to successfully update any of the specified objects (e.g. due a validation error), the entire operation will be aborted and none of the objects will be updated.
 
-### Concurrent Update Protection
+### Errors in Bulk Operations
 
-!!! info "This feature was introduced in NetBox v4.6."
+!!! info "This feature was introduced in NetBox v4.7."
+
+When a bulk creation or update fails validation, the response identifies each offending object by its index within the submitted list, so that a client can correct and resubmit only the objects which actually failed. (The operation itself remains all-or-none: No objects are written unless every object validates.)
+
+```json
+{
+    "detail": "1 of 3 objects failed validation.",
+    "errors": [
+        {
+            "index": 1,
+            "errors": {
+                "slug": ["This field may not be blank."]
+            }
+        }
+    ]
+}
+```
+
+### Concurrent Update Protection
 
 To guard against the lost-update problem when multiple clients modify the same object, NetBox returns a weak `ETag` response header on detail-view responses (`GET`, `POST`, `PATCH`, `PUT`) for individual objects. Clients may supply this value back on a subsequent `PATCH` or `PUT` request via the `If-Match` request header. If the object's current ETag does not match any of the values supplied, the server rejects the request with a `412 Precondition Failed` response and includes the current ETag in the response so the client can retry.
 
@@ -690,8 +705,6 @@ A literal `If-Match: *` value matches any current ETag and may be used to assert
 
 ### Adding and Removing Tags
 
-!!! info "This feature was introduced in NetBox v4.6."
-
 In addition to replacing an object's tag set wholesale via the `tags` field, taggable models accept two write-only fields, `add_tags` and `remove_tags`, which apply only the specified additions or removals without disturbing existing tags. This is convenient when concurrent clients each manage a distinct subset of an object's tags.
 
 ```no-highlight
@@ -741,6 +754,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 +844,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 - 3
docs/models/dcim/cable.md

@@ -23,8 +23,6 @@ The cable's operational status. Choices include:
 
 ### Profile
 
-!!! note "This field was introduced in NetBox v4.5."
-
 The profile to which the cable conforms. The profile determines the mapping of termination between the two ends and enables logical tracing across complex connections, such as breakout cables. Supported profiles are listed below.
 
 * Straight (single position)
@@ -34,7 +32,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.

+ 19 - 4
docs/models/dcim/interface.md

@@ -28,11 +28,19 @@ 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 when its specific transceiver type is not relevant; a channel subinterface may instead keep its own specific physical type (e.g. directly declaring a channel as 10GBASE-SR) to record the actual transceiver in use.
 
 !!! 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
+
+!!! info "This field was added in NetBox v4.7."
+
+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 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).
@@ -49,7 +57,7 @@ The [virtual routing and forwarding](../ipam/vrf.md) instance to which this inte
 
 The [MAC address](./macaddress.md) assigned to this interface which is designated as its primary.
 
-!!! note "Changed in NetBox v4.2"
+!!! note "MAC address is a property"
     The MAC address of an interface (formerly a concrete database field) is available as a property, `mac_address`, which reflects the value of the primary linked [MAC address](./macaddress.md) object.
 
 ### WWN
@@ -78,10 +86,17 @@ 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. A channel subinterface is likewise bound to its [channelized](#channels) parent interface, whether it uses the generic **channel** type or its own specific physical type.
 
 !!! note
-    An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned.
+    An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned. Renaming a channelized interface updates the names of any channel subinterfaces which follow the `<name>:<channel ID>` convention, to keep their names consistent with their new parent, unless the resulting name is already in use by another interface on the device or would exceed the maximum length of the name field (in either case, that subinterface's name is left unchanged).
+
+### Channel ID
+
+The numeric channel on a [channelized](#channels) parent interface to which this subinterface is bound, identifying it as a channel subinterface. This may be set on the generic **channel** type, or on any other physical interface type (e.g. to record the specific transceiver used on that channel) — but not on a virtual or wireless interface. 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
 

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

@@ -4,6 +4,20 @@ 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
+
+!!! info "This feature was introduced in NetBox v4.7."
+
+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, cooling outflow to cooling intake, 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.
+
+A cooling intake's upstream [cooling outflow](./coolingoutflow.md) is not device-scoped — an intake is routinely supplied by an outflow on another device, such as a CDU — so that assignment is preserved across a cross-device move rather than blocking it.
+
+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 +54,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.

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

@@ -1,3 +1,7 @@
 # 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.
+
+Bay types are importable as part of a device type's or module type's YAML definition (`module-bays[].module_bay_types`), referenced by name. They are included when a device type is exported; a module type's exported definition omits its module bays entirely, so bay types are not carried through it. A referenced name is resolved against bay types belonging to the parent type's own manufacturer or with no manufacturer set (global); a name may match both, since a bay type's uniqueness is scoped to `(manufacturer, name)` rather than name alone, in which case the manufacturer-specific type takes precedence. A name matching only some other manufacturer's bay type is rejected rather than resolved to it.

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

@@ -0,0 +1,37 @@
+# Module Bay Types
+
+!!! info "This feature was introduced in NetBox v4.7."
+
+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.

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

@@ -75,10 +75,24 @@ 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.
+
+Bay types are included, by name, in a module type's exported YAML definition, but are not currently importable back through it; re-importing an exported definition leaves this field unset.
+
 ### Attributes
 
 Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure.

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

@@ -12,8 +12,6 @@ The assignment of platforms to devices and virtual machines is optional.
 
 ## Parent
 
-!!! "This field was introduced in NetBox v4.4."
-
 The parent platform class to which this platform belongs (optional).
 
 ### Name

+ 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.)

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

@@ -12,6 +12,10 @@ Select the NetBox object type or types to which this custom field applies.
 
 The raw field name. This will be used in the database and API, and should consist only of alphanumeric characters and underscores. (Use the `label` field to designate a human-friendly name for the custom field.)
 
+### Status
+
+The field's lifecycle state: `active`, `provisioning`, or `deleting`. This is maintained by NetBox and cannot be set directly. A field is available for use only while active; see [field status](../../customization/custom-fields.md#field-status).
+
 ### Label
 
 An optional human-friendly name for the custom field. If not defined, the field's `name` attribute will be used.
@@ -109,6 +113,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.

+ 0 - 2
docs/models/ipam/iprange.md

@@ -44,8 +44,6 @@ The IP range's operational status. Note that the status of a range does _not_ ha
 
 ### Mark Populated
 
-!!! note "This field was added in NetBox v4.3."
-
 If enabled, NetBox will treat this IP range as being fully populated when calculating available IP space. It will also prevent the creation of IP addresses which fall within the declared range (and assigned VRF, if any).
 
 ### Mark Utilized

+ 54 - 11
docs/models/ipam/service.md

@@ -4,9 +4,8 @@ An application service represents a layer seven application available on a devic
 
 To aid in the efficient creation of application services, users may opt to first create an [application service template](./servicetemplate.md) from which service definitions can be quickly replicated.
 
-!!! note "Changed in NetBox v4.4"
-
-    Previously, application services were referred to simply as "services". The name has been changed in the UI to better reflect their intended use. There is no change to the name of the model or in any programmatic NetBox APIs.
+!!! note "Naming"
+    Application services are referred to simply as "services" in the name of the model and throughout NetBox's programmatic APIs. Only the UI uses the longer name, which better reflects their intended use.
 
 ## Fields
 
@@ -15,22 +14,66 @@ To aid in the efficient creation of application services, users may opt to first
 The parent object to which the application service is assigned. This must be one of [Device](../dcim/device.md),
 [VirtualMachine](../virtualization/virtualmachine.md), or [FHRP Group](./fhrpgroup.md).
 
-!!! note "Changed in NetBox v4.3"
-
-    Previously, `parent` was a property that pointed to either a Device or Virtual Machine. With the capability to assign services to FHRP groups, this is a unified in a concrete field.
-
 ### Name
 
 A service or protocol name.
 
-### Protocol
+### Port Mappings
+
+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`).
+
+In the REST and GraphQL APIs, port mappings are represented as a flat list of `protocol/port` strings — matching how they are stored:
+
+```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`.
 
-The wire protocol on which the service runs. Choices include UDP, TCP, and SCTP.
+### Filtering by Port Mapping, Protocol, and Port
 
-### Ports
+`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:
 
-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.
+| 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"`. A pair's port may be given as a hyphen range, for example `"tcp/8000-8010"`. Protocols may be entered in uppercase or lowercase.

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

@@ -2,9 +2,9 @@
 
 Application service templates can be used to instantiate [application services](./service.md) on [devices](../dcim/device.md) and [virtual machines](../virtualization/virtualmachine.md).
 
-!!! note "Changed in NetBox v4.4"
+!!! note "Naming"
 
-    Previously, application service templates were referred to simply as "service templates". The name has been changed in the UI to better reflect their intended use. There is no change to the name of the model or in any programmatic NetBox APIs.
+    Application service templates are referred to simply as "service templates" in the name of the model and throughout NetBox's programmatic APIs. Only the UI uses the longer name, which better reflects their intended use.
 
 ## Fields
 
@@ -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.

+ 0 - 3
docs/models/virtualization/virtualmachine.md

@@ -46,9 +46,6 @@ The location or host for this VM. At least one must be specified:
 - **Device only**: The VM runs directly on a physical host device without a cluster (e.g. containers). The site is automatically inferred from the device's site.
 - **Cluster + Device**: The VM belongs to a cluster and is pinned to a specific host device within that cluster. The device must be a registered host of the assigned cluster.
 
-!!! info "New in NetBox v4.6"
-    Virtual machines can now be assigned directly to a device without requiring a cluster. This is particularly useful for modeling VMs running on standalone hosts outside of a cluster.
-
 ### Platform
 
 A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system. If a virtual machine type defines a default platform, it will be applied when the VM is created unless an explicit platform is specified.

+ 1 - 1
docs/models/virtualization/vminterface.md

@@ -31,7 +31,7 @@ If not selected, this interface will be treated as disabled/inoperative.
 
 The [MAC address](../dcim/macaddress.md) assigned to this interface which is designated as its primary.
 
-!!! note "Changed in NetBox v4.2"
+!!! note "MAC address is a property"
     The MAC address of an interface (formerly a concrete database field) is available as a property, `mac_address`, which reflects the value of the primary linked [MAC address](../dcim/macaddress.md) object.
 
 ### MTU

+ 0 - 2
docs/plugins/development/background-jobs.md

@@ -41,8 +41,6 @@ This is the human-friendly names of your background job. If omitted, the class n
 
 ### Logging
 
-!!! info "This feature was introduced in NetBox v4.4."
-
 A Python logger is instantiated by the runner for each job. It can be utilized within a job's `run()` method as needed:
 
 ```python

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

@@ -0,0 +1,117 @@
+# Jinja Config Templates
+
+!!! info "This feature was introduced in NetBox v4.7."
+
+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.

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

@@ -0,0 +1,64 @@
+# Event Rule Actions
+
+!!! info "This feature was introduced in NetBox v4.7."
+
+[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

+ 1 - 2
docs/plugins/development/filtersets.md

@@ -6,8 +6,7 @@ Filter sets define the mechanisms available for filtering or searching through a
 
 To support additional functionality standard to NetBox models, such as tag assignment and custom field support, the `NetBoxModelFilterSet` class is available for use by plugins. This should be used as the base filter set class for plugin models which inherit from `NetBoxModel`. Within this class, individual filters can be declared as directed by the `django-filters` documentation. An example is provided below.
 
-!!! info "New in NetBox v4.5: FilterSet Registration"
-    NetBox v4.5 introduced the `register_filterset()` utility function. This enables plugins to register their filtersets to receive advanced functionality, such as the automatic attachment of field-specific lookup modifiers on the filter form. Registration is optional: Unregistered filtersets will continue to work as before, but will not receive the enhanced functionality.
+The `register_filterset()` utility function enables plugins to register their filtersets to receive advanced functionality, such as the automatic attachment of field-specific lookup modifiers on the filter form. Registration is optional: Unregistered filtersets will continue to work as before, but will not receive the enhanced functionality.
 
 ```python
 # filtersets.py

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

@@ -210,6 +210,37 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
     options:
       members: false
 
+## Static Choice Fields
+
+!!! info "This feature was introduced in NetBox v4.7."
+
+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 +261,20 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
     options:
       members: false
 
+## Generic Object Fields
+
+!!! info "This feature was introduced in NetBox v4.7."
+
+`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

+ 98 - 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,102 @@ schema = [
 ]
 ```
 
+## Extending Core Types & Filters
+
+!!! info "This feature was introduced in NetBox v4.7."
+
+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. Declare `models` as an unannotated class attribute or a `ClassVar`. An annotated `models` would be collected as a GraphQL field and is rejected. Output-type extensions are collected from `graphql_extensions.type_extensions` and filter extensions from `graphql_extensions.filter_extensions` by default. These paths can be overridden via the `graphql_type_extensions` and `graphql_filter_extensions` attributes on the PluginConfig.
+
+By default, NetBox imports `type_extensions` and `filter_extensions` from a `graphql_extensions.py` module beside the plugin's `graphql.py`. The `PluginConfig` attributes may override each with a dotted path to a list under any attribute name.
+
+!!! warning
+    Extension modules are imported while plugins initialize, before NetBox's core GraphQL types are assembled. They must not import core GraphQL modules (e.g. `dcim.graphql.types`) at module level. A premature import assembles the affected core types early, and any extension registered afterwards for one of them fails at startup. Reference core types only through `strawberry.lazy()` string annotations. Plugin schema modules (`graphql.py`) are loaded later, during schema assembly, and may import core GraphQL types freely.
+
+### Type Extensions
+
+An output-type extension is a `@strawberry.type` class whose fields and resolvers are spliced into the target type:
+
+```python
+# graphql_extensions.py
+from typing import TYPE_CHECKING, Annotated
+
+import strawberry
+import strawberry_django
+
+from utilities.querysets import RestrictedPrefetch
+from my_plugin.models import Widget
+
+if TYPE_CHECKING:
+    from dcim.graphql.types import DeviceType
+
+
+@strawberry_django.type(Widget, fields='__all__')
+class WidgetType:
+    device: Annotated['DeviceType', strawberry.lazy('dcim.graphql.types')]
+
+
+@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_extensions')]]:
+        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_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. Every name an extension contributes must be new, not only its GraphQL fields and resolvers but also its helper methods and class attributes. A name the core type already provides, or that two extensions both declare, causes NetBox to fail at startup with an error naming the extension classes. This is deliberate, because Python resolves attribute lookups through the composed MRO, so a shared helper or constant name would let one plugin silently redirect another plugin's resolvers. Two extensions may inherit the same name from one shared helper base, which is not a conflict. Explicit GraphQL aliases are checked as well. Registering an extension after its target GraphQL type has been assembled also raises an error, as does an extension targeting a model that never assembles a GraphQL type or filter. Extensions are plain mixin types and may not implement GraphQL interfaces or inherit from core GraphQL classes.
+
 ## 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_extensions.type_extensions`)   |
+| `graphql_filter_extensions` | The dotted path to the list of GraphQL filter extension classes, if any (default: `graphql_extensions.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.

+ 0 - 3
docs/plugins/development/navigation.md

@@ -73,9 +73,6 @@ A `PluginMenuItem` has the following attributes:
 | `staff_only`    | -        | Display only for superusers                          |
 | `buttons`       | -        | An iterable of PluginMenuButton instances to include |
 
-!!! note "Changed in NetBox v4.5"
-    In releases prior to NetBox v4.5, `staff_only` restricted display of a menu item to only users with `is_staff` set to True. In NetBox v4.5, the `is_staff` flag was removed from the user model. Menu items with `staff_only` set to True are now displayed only for superusers.
-
 ## Menu Buttons
 
 Each menu item can include a set of buttons. These can be handy for providing shortcuts related to the menu item. For instance, most items in NetBox's navigation menu include buttons to create and import new objects.

+ 69 - 3
docs/plugins/development/ui-components.md

@@ -1,8 +1,5 @@
 # UI Components
 
-!!! note "New in NetBox v4.6"
-    All UI components described here were introduced in NetBox v4.6. Be sure to set the minimum NetBox version to 4.6.0 for your plugin before incorporating any of these resources.
-
 To simplify the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
 
 ## Page Layout
@@ -53,6 +50,75 @@ class MyView(generic.ObjectView):
 
 ::: netbox.ui.layout.Column
 
+## Breadcrumbs
+
+!!! info "This feature was introduced in NetBox v4.7."
+
+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 - 6
docs/plugins/development/views.md

@@ -79,14 +79,8 @@ The NetBox URL registration process has two parts:
 
 ::: utilities.views.register_model_view
 
-!!! note "Changed in NetBox v4.2"
-    In NetBox v4.2, the `register_model_view()` function was extended to support the registration of list views by passing `detail=False`.
-
 ::: utilities.urls.get_model_urls
 
-!!! note "Changed in NetBox v4.2"
-    In NetBox v4.2, the `get_model_urls()` function was extended to support retrieving registered general model views (e.g. for listing objects) by passing `detail=False`.
-
 ### Example Usage
 
 ```python

+ 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:
     

+ 91 - 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
 
@@ -46,6 +49,13 @@ The following condition will evaluate as true:
 }
 ```
 
+!!! note "Missing keys and absent data"
+    A condition which references a key that does not exist in the data being evaluated fails closed: the condition set evaluates as false, and (for an [event rule](../features/event-rules.md)) an error is logged to `netbox.event_rules` so that a typo does not silently disable the rule.
+
+    Where the data is absent altogether rather than merely missing the referenced key — an event rule evaluating a job which recorded no data, say — the condition does not match. Such an absence is a normal property of the event rather than a mistake, so it is not treated as an error and does not affect the evaluation of the other conditions in the set. Because there is nothing to compare against, the condition does not match whatever the operator, and remains a non-match when `negate` is set: no rule fires on data an event never carried.
+
+    A snapshot which does not exist for the event type is the exception: it resolves to `null`, so that a condition can distinguish (for example) a newly created object from an updated one. See [below](#snapshot-conditions-event-rules).
+
 ### Examples
 
 `name` equals "foo":
@@ -91,6 +101,86 @@ 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)
+
+!!! info "This feature was introduced in NetBox v4.7."
+
+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"
+}
+```
+
+An attribute which resolves in neither snapshot — a misspelling, an attribute the object type does not have, or an event which recorded no snapshots at all — leaves nothing to compare. The condition fails closed (the rule does not fire) and an error is logged to `netbox.event_rules`. This holds regardless of `negate`: negating a condition whose attribute cannot be resolved does not turn it into a match.
+
+### 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 `status` — not `status.value` — when referencing a snapshot attribute, both in `snapshots.prechange.*`/`snapshots.postchange.*` paths and with the `changed`/`unchanged` operators. A `.value` suffix cannot be resolved against a snapshot: the condition fails closed (the rule does not fire) and an error is logged to `netbox.event_rules`.
+
+!!! note "Snapshot availability"
+    For create events, `prechange` is `null`. The `changed` operator evaluates to `true` for any attribute present in the postchange snapshot (each field transitioned from non-existent to its initial value), and a `snapshots.prechange.*` path resolves to `null` — so it matches a condition testing for `null` and fails any other comparison.
+
+    For delete events, `postchange` is `null`. The `changed` operator evaluates to `true` for any attribute present in the prechange snapshot, `unchanged` evaluates to `false`, and a `snapshots.postchange.*` path resolves to `null`.
+
+    An absent snapshot excuses only the absence of the data, not a path which does not describe it. The attribute path is checked against the opposite snapshot, and fails closed (the rule does not fire, and an error is logged) if it cannot be resolved there — whether because it cannot be resolved against a snapshot at all, such as the `.value` suffix above, or because the attribute is misspelled or unknown. Otherwise a typo would resolve to `null` and fire the rule on every create or delete.
+
+    To test only whether a snapshot is absent, reference the snapshot itself rather than an attribute of it: `{"attr": "snapshots.prechange", "value": null}`.
+
+Because an absent snapshot resolves to `null` rather than raising an error — matching a condition testing for `null` and failing any other comparison, whichever operator is used — a snapshot path can be combined safely with other conditions in a rule that also fires on create or delete. For example, this rule fires when a site is created, or when a site whose status was previously `planned` is updated:
+
+```json
+{
+  "or": [
+    {
+      "attr": "snapshots.prechange.status",
+      "value": null
+    },
+    {
+      "attr": "snapshots.prechange.status",
+      "value": "planned"
+    }
+  ]
+}
+```
+
 ## 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.

+ 273 - 0
docs/release-notes/version-4.7.md

@@ -0,0 +1,273 @@
+# NetBox v4.7
+
+## v4.7.0-beta2 (2026-08-26)
+
+!!! danger "Not for Production Use"
+    This is a beta release of NetBox intended for testing and evaluation. **Do not use this software in production.** Also be aware that no upgrade path is provided to future releases.
+
+!!! warning "PostgreSQL 15 or Later Required"
+    This release of NetBox drops support for PostgreSQL 14.
+
+!!! warning "PostgreSQL ltree Extension Required"
+    The PostgreSQL database must support the [ltree extension](https://www.postgresql.org/docs/current/ltree.html). This trusted module ships with PostgreSQL and does not require superuser permission to activate. NetBox will install it automatically during the upgrade if it is not already present. This requires the NetBox database user to hold the `CREATE` privilege on the database. Installations created using NetBox's standard PostgreSQL setup instructions already satisfy this requirement because the NetBox user owns the database. See [Verify Database Permissions](../installation/upgrading.md#verify-database-permissions) for details.
+
+!!! warning "Redis 6.0 or Later Required"
+    This release of NetBox drops support for Redis 5.x.
+
+!!! warning "Extended Upgrade Duration"
+    Two steps in this upgrade scale with the size of the database, and may take considerably longer than a typical NetBox upgrade.
+
+    The migration which replaces django-mptt with `ltree` adds and alters columns on each hierarchical table before backfilling it in a single statement. The schema changes hold an `ACCESS EXCLUSIVE` lock, which blocks reads as well as writes for their duration; the backfill which follows locks every row it updates, blocking concurrent writes to those rows until it commits. On a large deployment this can last several minutes. The table most likely to be affected is `dcim_inventoryitem`, which can hold millions of rows; the others (region, sitegroup, location, devicerole, platform, modulebay, inventoryitemtemplate, tenantgroup, contactgroup, and wirelesslangroup) are typically far smaller.
+
+    The upgrade script then runs the `rebuild_config_context_cache` management command, which issues one `UPDATE` per device and virtual machine.
+
+    Plan a maintenance window accordingly, and note that these migrations are not reversible in practice: Reversing them restores the MPTT columns but does not repopulate them.
+
+### Breaking Changes
+
+* PostgreSQL 14 is no longer supported. NetBox now requires PostgreSQL 15 or later: The upgrade script will abort when connected to an earlier release. (NetBox v4.6 reported this as a warning.)
+* Redis 5.x is no longer supported. NetBox now requires Redis 6.0 or later.
+* Selection and multiple selection custom field values are now returned as objects specifying both the raw value and its human-friendly label (e.g. `{"value": "datacenter", "label": "Data Center"}`) in both the REST and GraphQL APIs. These fields continue to accept the raw value on write.
+* The `protocol` and `ports` fields on the `ipam.Service` and `ipam.ServiceTemplate` models have been replaced by a unified `port_mappings` field, which supports multiple protocols per service. The legacy fields are retained (as deprecated) in the REST and GraphQL APIs, but at the ORM level they are now read-only properties derived from `port_mappings`: Passing `protocol` or `ports` to the model raises a `TypeError`, and assigning to `service.ports` raises an `AttributeError`. This restriction applies only at the ORM level: The REST API continues to accept the legacy pair on write, translating it into `port_mappings`.
+* Because `protocol` is now filtered against the `port_mappings` array rather than a dedicated model field, the character-based REST filter lookups previously generated for it (`protocol__ic`, `protocol__isw`, `protocol__empty`, etc.) are no longer available. The `port__empty` lookup has been removed as well.
+* The GraphQL filters for `ipam.Service` and `ipam.ServiceTemplate` have changed shape: The nested `ports` integer lookup has been replaced by the flat `port`, `port__gt`, `port__gte`, `port__lt`, and `port__lte` parameters (each accepting a list of values), alongside the new `port_mappings` parameter. Additionally, the members of `ServiceProtocolEnum` have been renamed to drop a spurious `ROLE_` prefix (e.g. `ROLE_TCP` is now `TCP`).
+* Config context data is now pre-rendered and cached for each device and virtual machine, and is always included in their REST API representations. The `DeviceWithConfigContextSerializer` and `VirtualMachineWithConfigContextSerializer` classes have been removed (merged into the base serializers), and the `?exclude=config_context` query parameter is now silently ignored.
+* Failed bulk create and update operations via the REST API now return a structured response of the form `{"detail": ..., "errors": [{"index": N, "errors": {...}}]}`, correlating each error with the index of the offending object in the submitted list. (Bulk operations remain all-or-none.)
+* API token plaintexts can no longer be specified by the client when creating a token via the REST API. The `token` field is now read-only, and any value supplied is ignored. (This restriction was already in effect in the web UI.)
+* Executing a custom script via the REST API now requires that the calling token have its write ability enabled.
+* The `username` argument has been removed from `extras.webhooks.send_webhook()` (the value remains available to webhook templates as `request.user`). Any webhook jobs still enqueued when the workers are restarted will fail with a `TypeError`, so the background queues should be allowed to drain before upgrading.
+* Updates to the global search cache are now deferred to a background task. As a result, a newly created or modified object may not appear in search results for a brief period. (When no background worker is running, the index is updated synchronously as before.)
+* Hierarchical models are now backed by a PostgreSQL `ltree` column rather than django-mptt. This covers the nested group models (Region, SiteGroup, Location, DeviceRole, Platform, TenantGroup, ContactGroup, WirelessLANGroup, etc.) as well as ModuleBay, InventoryItem, and InventoryItemTemplate. The `lft`, `rght`, `tree_id`, and `level` columns have been dropped from every migrated model: `level` remains available as a Python property, but can no longer be used in a queryset filter or `order_by()` clause. NetBox's `ltree` implementation deliberately covers only the subset of MPTT's API which NetBox itself uses (`get_ancestors()`, `get_descendants()`, `get_children()`, and `add_related_count()`); methods such as `get_root()`, `get_family()`, `is_leaf_node()`, `move_to()`, and `insert_at()` are no longer available. The MPTT-backed `NestedGroupModel` base class is retained for backward compatibility with plugins, but is deprecated: New code should use `NestedLtreeGroupModel` instead.
+* django-tables2 has been upgraded to v3.0, which renames its `querystring` template tag to `querystring_replace` and removes the `RelatedLinkColumn` class.
+* `social-auth-app-django` and `social-auth-core` have been upgraded to v6.0 and v5.1 respectively, each a major release. Deployments which employ single sign-on should test authentication against a non-production instance before upgrading.
+* The `request` object passed to custom link templates is now a sanitized subset of the current request. Only the `id`, `path`, `path_info`, `method`, `GET`, and `user` attributes are available; cookies, headers, and session state are no longer accessible.
+* URL custom field values are now validated against the [`ALLOWED_URL_SCHEMES`](../configuration/security.md#allowed_url_schemes) configuration parameter. A value entered without a scheme is assumed to use `https` and stored as an absolute URL.
+* Webhooks now support a configurable timeout. If you have lowered `RQ_DEFAULT_TIMEOUT` to 60 seconds or less, you must also set [`WEBHOOK_DEFAULT_TIMEOUT`](../configuration/miscellaneous.md#webhook_default_timeout) to a lower value; NetBox will refuse to start otherwise.
+* Specifying an email server under the [`EMAIL`](../configuration/system.md#email) configuration parameter is now mandatory in order to send mail: A deployment which does not define `EMAIL['SERVER']` will raise an `InvalidMailer` exception when attempting to send, rather than failing at the SMTP connection.
+* NetBox now populates Django's `MAILERS` setting rather than the individual `EMAIL_*` settings which it supersedes. `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `EMAIL_USE_SSL`, `EMAIL_USE_TLS`, `EMAIL_TIMEOUT`, `EMAIL_SSL_CERTFILE`, and `EMAIL_SSL_KEYFILE` are no longer defined, and `EMAIL_BACKEND` is no longer consulted. Plugin code which reads any of these, or which calls `django.core.mail.get_connection()` with an explicit backend (now raising a `RuntimeError`), must be updated. The `EMAIL` configuration parameter itself is unchanged.
+* The upgrade script now runs the `rebuild_config_context_cache` management command to populate the new config context cache. This issues one `UPDATE` per device and virtual machine, and may extend the duration of the upgrade considerably for deployments with a large number of either. The command skips objects whose cache is already populated, so it is safe to interrupt and re-run; it may also be deferred until after NetBox is back online, as any object whose cache is empty falls back to rendering its config context on demand.
+* Creating a custom field which has a default value, and deleting a custom field, are now deferred to a background job where the field's assigned object types hold more than [`BULK_UPDATE_CHUNK_SIZE`](../configuration/system.md#bulk_update_chunk_size) objects in total.
+* The obsolete `populate_custom_field_defaults()` method has been removed from `CustomFieldsMixin`.
+* `CustomField.objects.get_for_model()` and the `custom_fields` property of `CustomFieldsMixin` now return a list rather than a queryset, and `get_for_model()` returns only those fields which are active: Any whose stored data is being updated by a background job is omitted (see [field status](../customization/custom-fields.md#field-status)) unless selected via its `statuses` argument.
+* Removal of deprecated behavior
+    * The `housekeeping` management command has been removed. (Its constituent tasks are performed by the individual management commands introduced in NetBox v4.6.)
+    * NetBox's custom `querystring` template tag has been removed in favor of Django's built-in tag of the same name. The two are not interchangeable: Django's tag reads the current request from the template context, so the `request` argument must be dropped (`{% querystring request page=1 %}` becomes `{% querystring page=1 %}`; passing `request` raises a `TemplateSyntaxError`). It also returns a bare `?` where NetBox's tag returned an empty string.
+    * The legacy Sentry configuration parameters `SENTRY_DSN`, `SENTRY_SAMPLE_RATE`, `SENTRY_SEND_DEFAULT_PII`, and `SENTRY_TRACES_SAMPLE_RATE` have been removed. Use `SENTRY_CONFIG` instead.
+    * The obsolete `DEFAULT_ACTION_PERMISSIONS` constant has been removed.
+    * Support for legacy view action mappings has been dropped, and the `LEGACY_ACTIONS` constant has been removed.
+    * Registered models are no longer populated under `registry['models']`. (Use `ObjectType.objects.public()` instead.) The `registry['denormalized_fields']` store has been removed as well.
+    * The backward compatibility shims for `OptionalLimitOffsetPagination` (now `NetBoxPagination`), `ExpandableIPAddressField` (now `ExpandableIPNetworkField`), and `expand_ipaddress_pattern()` (now `expand_ipnetwork_pattern()`) have been removed.
+    * The `request_id` and `username` keys have been removed from the context available to outgoing webhooks. Use `request.id` and `request.user` instead.
+    * The automatic reverse relationship created by `OwnerMixin` (e.g. `site_set`) has been removed.
+
+### New Features
+
+#### Cooling Infrastructure Modeling ([#22447](https://github.com/netbox-community/netbox/issues/22447))
+
+NetBox has long modeled power distribution end to end, but had no equivalent for cooling. This release introduces a cooling data model which deliberately mirrors the power model, so that the concepts and workflows feel familiar.
+
+Two new top-level models parallel PowerPanel and PowerFeed: **CoolingSource** represents facility-level cooling plant (a chiller, cooling tower, dry cooler, or facility water system) scoped to a site or location, and **CoolingFeed** represents a coolant loop delivered from a source to a rack. Two new device components parallel PowerPort and PowerOutlet: **CoolingIntake** represents a coolant intake on a device (e.g. a server cold-plate connection or a CDU's facility water inlet), and **CoolingOutflow** represents a coolant outlet on a CDU or manifold which supplies downstream equipment. Each intake may reference the upstream outflow which serves it, and both components have corresponding device type templates. CDUs and manifolds are modeled as ordinary devices carrying these components.
+
+Lightweight descriptive attributes have also been added for users who want to record cooling characteristics without modeling the full plumbing: a `cooling_method` field (air, liquid, hybrid, or immersion) on the Device, DeviceType, and ModuleType models, and `cooling_capability` (air-only, hybrid, or liquid-only) and `cooling_capacity` fields on the Rack and RackType models.
+
+#### Channelized Subinterfaces ([#20972](https://github.com/netbox-community/netbox/issues/20972))
+
+Channelized (breakout) interfaces can now be modeled natively. A new `channels` field on the Interface model indicates the number of physical channels into which an interface is divided, and each channel is represented by a subinterface of the new generic `channel` type, bound to its parent via the new `channel_id` field. A single cable terminates to the channelized parent interface, and NetBox traces a distinct cable path for each channel subinterface. Both fields are available on interface templates as well.
+
+#### Multi-Protocol Application Services ([#20285](https://github.com/netbox-community/netbox/issues/20285))
+
+Application services and service templates can now expose the same port on multiple protocols — for example, DNS listening on both `tcp/53` and `udp/53`. The single-protocol `protocol` and `ports` fields have been replaced by a unified `port_mappings` field, represented in the APIs as a flat list of `protocol/port` strings (e.g. `["tcp/80", "udp/53"]`). New `port_mappings`, `protocol`, and `port` filters are available in the UI and in both APIs, with the latter two correlated so that they must be satisfied by a single mapping.
+
+#### Module Bay Types ([#19731](https://github.com/netbox-community/netbox/issues/19731))
+
+A new ModuleBayType model has been introduced to convey which kinds of modules a module bay is able to accommodate (e.g. an SFP28 cage or a PCIe x16 slot). Bay types can be assigned to module bays, module bay templates, and module types; where both a bay and a module type declare bay types, NetBox validates that the two sets share at least one type in common before permitting installation. Bay types assigned to a module bay template propagate automatically to each instantiated module bay.
+
+#### Relocating Installed Modules ([#15289](https://github.com/netbox-community/netbox/issues/15289))
+
+An installed module can now be moved to a different module bay, including a bay on a different device, rather than having to be deleted and recreated. A move relocates the module's entire subtree — its components, its own module bays, and any child modules installed within them — and re-resolves any component names, labels, and positions derived from the module type's templates for the destination bay. Cross-device moves are permitted only where the moved components carry no active topology or device-scoped configuration.
+
+#### Background Processing for REST API Requests ([#21992](https://github.com/netbox-community/netbox/issues/21992))
+
+Bulk write operations via the REST API can now be processed as a background job rather than synchronously, avoiding proxy and gateway timeouts on large batches. Appending `?background=true` to a bulk write request enqueues a job and immediately returns an `HTTP 202 Accepted` response containing the job's ID and URL; the job's `data` field records the response the synchronous request would have returned. Note that validation is deferred to the worker, so a `202` response indicates only that the request was accepted, and the job's final status must be inspected to confirm the outcome.
+
+#### Per-Object Errors for Bulk Operations ([#20054](https://github.com/netbox-community/netbox/issues/20054))
+
+When a bulk create or update via the REST API fails validation, the response now identifies each offending object by its index within the submitted list, along with its specific field errors, rather than reporting only the first failure. This enables clients to correct and resubmit only the objects which actually failed.
+
+#### Pre-Rendered Config Context Data ([#21025](https://github.com/netbox-community/netbox/issues/21025))
+
+Rather than compiling config context data on demand from the full set of applicable ConfigContext instances, NetBox now pre-renders each device's and virtual machine's merged context data and caches it on the object itself. The cache is invalidated automatically whenever an upstream change is detected — a config context being created, modified, or deleted, or a change to an attribute which determines which contexts apply — and repopulated by a non-blocking background job. During the brief window between invalidation and re-render, reads fall back to the original on-demand rendering path, so the data returned is always correct rather than stale.
+
+#### Snapshot-Aware Event Rule Conditions ([#18159](https://github.com/netbox-community/netbox/issues/18159))
+
+Event rule conditions can now inspect the pre-change and post-change snapshots captured at the time of an event, rather than only the object's current data. New `changed` and `unchanged` operators compare an attribute's value across the two snapshots, and the `snapshots.prechange.<attr>` and `snapshots.postchange.<attr>` dot-path syntax exposes either snapshot to any standard operator. This makes it possible to express the long-requested "fire only when status changes _to_ active" rule, avoiding webhooks and scripts triggered by unrelated updates. A new `regex` operator has been added as well, and conditions which reference an attribute that cannot be resolved now fail closed and log an error rather than silently disabling the rule.
+
+### Enhancements
+
+* [#15165](https://github.com/netbox-community/netbox/issues/15165) - Re-render only the affected fieldset, rather than the entire form, when an HTMX-driven selection changes
+* [#18645](https://github.com/netbox-community/netbox/issues/18645) - Support the bulk import of cables having multiple terminations per side
+* [#18821](https://github.com/netbox-community/netbox/issues/18821) - Set or update an interface's primary MAC address in a single operation via the `mac_address` field
+* [#20897](https://github.com/netbox-community/netbox/issues/20897) - Include the label alongside the value for selection custom fields in the REST & GraphQL APIs
+* [#21367](https://github.com/netbox-community/netbox/issues/21367) - Add a read-only `is_primary` field to the MAC address REST API representation
+* [#21712](https://github.com/netbox-community/netbox/issues/21712) - Support description annotations for static choice fields, and permit choices to be declared as dictionaries in `FIELD_CHOICES`
+* [#22205](https://github.com/netbox-community/netbox/issues/22205) - Add an `end_of_life` date field to device types and module types to aid in hardware lifecycle planning
+* [#22231](https://github.com/netbox-community/netbox/issues/22231) - Introduce a `nulls_first` parameter to control the placement of empty values when ordering by a custom field
+* [#22409](https://github.com/netbox-community/netbox/issues/22409) - Disallow client-specified API token plaintexts via the REST API
+* [#22411](https://github.com/netbox-community/netbox/issues/22411) - Enforce token write ability when executing a custom script via the REST API
+* [#22441](https://github.com/netbox-community/netbox/issues/22441) - Record and display the execution time of each background job
+* [#22446](https://github.com/netbox-community/netbox/issues/22446) - Introduce breadcrumbs support for declarative layouts
+* [#22486](https://github.com/netbox-community/netbox/issues/22486) - Support a configurable timeout for webhooks, with a new `WEBHOOK_DEFAULT_TIMEOUT` configuration parameter
+* [#22563](https://github.com/netbox-community/netbox/issues/22563) - Preserve the scroll position of the sidebar navigation when moving between pages
+* [#22595](https://github.com/netbox-community/netbox/issues/22595) - Introduce the [`BULK_UPDATE_CHUNK_SIZE`](../configuration/system.md#bulk_update_chunk_size) configuration parameter to bound the number of rows affected by a single bulk `UPDATE` statement
+* [#22604](https://github.com/netbox-community/netbox/issues/22604) - Document the experimental Python package installation and upgrade workflow
+* [#22607](https://github.com/netbox-community/netbox/issues/22607) - Sanitize the HTTP request passed to the template context when rendering custom links
+* [#22640](https://github.com/netbox-community/netbox/issues/22640) - Enforce `ALLOWED_URL_SCHEMES` when validating URL custom field values
+* [#22757](https://github.com/netbox-community/netbox/issues/22757) - Support arbitrary help text on inline form fields
+* [#22786](https://github.com/netbox-community/netbox/issues/22786) - Publish NetBox releases to the production Python Package Index (PyPI)
+* [#22851](https://github.com/netbox-community/netbox/issues/22851) - Unpin `social-auth-core` to permit the installation of newer PyJWT versions
+* [#23010](https://github.com/netbox-community/netbox/issues/23010) - Defer the provisioning of default and purging of stale custom field data to a background job
+
+### Performance Improvements
+
+* [#21326](https://github.com/netbox-community/netbox/issues/21326) - Defer updates to the global search cache to a background job, so that they no longer delay the response
+* [#21355](https://github.com/netbox-community/netbox/issues/21355) - Maintain denormalized field data using PostgreSQL triggers rather than Python signal handlers
+* [#21418](https://github.com/netbox-community/netbox/issues/21418) - Replace django-mptt with a PostgreSQL `ltree` implementation for hierarchical models
+
+### Plugins
+
+* [#19821](https://github.com/netbox-community/netbox/issues/19821) - Introduce `GenericObjectChoiceField` and `GenericObjectFormMixin` to represent a generic foreign key relation as a single form field
+* [#22351](https://github.com/netbox-community/netbox/issues/22351) - Enable plugins to register custom Jinja filters and to inject context variables for config template rendering
+* [#22592](https://github.com/netbox-community/netbox/issues/22592) - Enable plugins to add fields and filters to NetBox's existing core GraphQL types
+* [#22770](https://github.com/netbox-community/netbox/issues/22770) - Enable plugins to register custom Event Rule action types by subclassing `EventRuleAction`
+
+### Deprecations
+
+* [#22288](https://github.com/netbox-community/netbox/issues/22288) - The `JINJA2_FILTERS` configuration parameter has been renamed to `JINJA_FILTERS`. The old name remains supported, but will be removed in NetBox v5.0.
+* [#22593](https://github.com/netbox-community/netbox/issues/22593) - The `form_factor`, `width`, `outer_width`, `outer_height`, `outer_depth`, and `outer_unit` fields on the Rack model have been deprecated, and will be removed in NetBox v5.0. These values will instead be inferred from the rack's assigned rack type, which will become a mandatory assignment.
+* [#22935](https://github.com/netbox-community/netbox/issues/22935) - The custom scripts functionality in core NetBox has been deprecated in favor of a dedicated plugin, and will be removed in NetBox v5.0.
+* The `protocol` and `ports` fields on application services and service templates have been deprecated in favor of `port_mappings`, and will be removed from the REST & GraphQL APIs in NetBox v5.0.
+* The MPTT-backed `NestedGroupModel` base class has been deprecated in favor of `NestedLtreeGroupModel`, and will be removed in a future release.
+
+### Other Changes
+
+* [#19091](https://github.com/netbox-community/netbox/issues/19091) - Remove NetBox's custom `querystring` template tag in favor of Django's built-in tag
+* [#20546](https://github.com/netbox-community/netbox/issues/20546) - Raise the minimum required PostgreSQL version from 14 to 15
+* [#20547](https://github.com/netbox-community/netbox/issues/20547) - Consolidate paired uniqueness constraints on nullable fields into single constraints using PostgreSQL's `NULLS NOT DISTINCT`
+* [#21565](https://github.com/netbox-community/netbox/issues/21565) - Remove the obsolete `housekeeping` management command
+* [#21883](https://github.com/netbox-community/netbox/issues/21883) - Drop support for the deprecated Sentry configuration parameters
+* [#21886](https://github.com/netbox-community/netbox/issues/21886) - Remove the obsolete `DEFAULT_ACTION_PERMISSIONS` constant
+* [#21888](https://github.com/netbox-community/netbox/issues/21888) - Remove support for legacy view actions
+* [#21891](https://github.com/netbox-community/netbox/issues/21891) - Remove the `models` key from the application registry
+* [#21902](https://github.com/netbox-community/netbox/issues/21902) - Upgrade django-tables2 to v3.0
+* [#22052](https://github.com/netbox-community/netbox/issues/22052) - Remove the backward compatibility shim for `OptionalLimitOffsetPagination`
+* [#22053](https://github.com/netbox-community/netbox/issues/22053) - Remove the backward compatibility shim for `ExpandableIPAddressField`
+* [#22054](https://github.com/netbox-community/netbox/issues/22054) - Remove the backward compatibility shim for `expand_ipaddress_pattern()`
+* [#22161](https://github.com/netbox-community/netbox/issues/22161) - Rename the filterset test mixin base classes to use a `*TestMixin` suffix
+* [#22300](https://github.com/netbox-community/netbox/issues/22300) - Drop the automatic reverse relationship defined by `OwnerMixin`
+* [#22393](https://github.com/netbox-community/netbox/issues/22393) - Drop support for Redis 5.x
+* [#22438](https://github.com/netbox-community/netbox/issues/22438) - Omit the "2" suffix from the new Jinja plugin resources (`jinja_filters`, `get_jinja_context()`, `register_jinja_filters()`) for consistency with `JINJA_FILTERS`
+* [#22485](https://github.com/netbox-community/netbox/issues/22485) - Move the search subsystem's signal wiring into `AppConfig.ready()` and break its import cycle
+* [#22571](https://github.com/netbox-community/netbox/issues/22571) - Migrate from django-pglocks to django-pgware
+* [#22615](https://github.com/netbox-community/netbox/issues/22615) - Remove the legacy `request_id` and `username` keys from the webhook context
+* [#22942](https://github.com/netbox-community/netbox/issues/22942) - Upgrade to Django 6.1
+
+### REST API Changes
+
+* New features:
+    * The `background=true` query parameter requests background processing of a bulk write operation, returning `HTTP 202 Accepted` with the enqueued job's ID and URL
+    * Failed bulk create & update operations now return per-object errors correlated by index
+    * Selection & multiple selection custom field values are now returned as `{value, label}` objects
+    * Config context data is now always included for devices & virtual machines; the `exclude=config_context` query parameter is ignored
+* New endpoints:
+    * `GET/POST /api/dcim/cooling-feeds/`
+    * `GET/PUT/PATCH/DELETE /api/dcim/cooling-feeds/<id>/`
+    * `GET/POST /api/dcim/cooling-intakes/`
+    * `GET/PUT/PATCH/DELETE /api/dcim/cooling-intakes/<id>/`
+    * `GET/POST /api/dcim/cooling-intake-templates/`
+    * `GET/PUT/PATCH/DELETE /api/dcim/cooling-intake-templates/<id>/`
+    * `GET/POST /api/dcim/cooling-outflows/`
+    * `GET/PUT/PATCH/DELETE /api/dcim/cooling-outflows/<id>/`
+    * `GET/POST /api/dcim/cooling-outflow-templates/`
+    * `GET/PUT/PATCH/DELETE /api/dcim/cooling-outflow-templates/<id>/`
+    * `GET/POST /api/dcim/cooling-sources/`
+    * `GET/PUT/PATCH/DELETE /api/dcim/cooling-sources/<id>/`
+    * `GET/POST /api/dcim/module-bay-types/`
+    * `GET/PUT/PATCH/DELETE /api/dcim/module-bay-types/<id>/`
+* `core.Job`
+    * Add read-only duration field `execution_time`
+* `dcim.Device`
+    * Add optional choice field `cooling_method`
+    * Add read-only JSON field `config_context` (previously available only via `DeviceWithConfigContextSerializer`)
+    * Annotate counts of assigned cooling intakes (`cooling_intake_count`) and outflows (`cooling_outflow_count`)
+* `dcim.DeviceType`
+    * Add optional choice field `cooling_method`
+    * Add optional date field `end_of_life`
+    * Annotate counts of cooling intake & outflow templates (`cooling_intake_template_count`, `cooling_outflow_template_count`)
+* `dcim.Interface`
+    * Add optional integer fields `channels` and `channel_id`
+    * The `mac_address` field is now writable, and creates or updates the interface's primary MAC address
+* `dcim.InterfaceTemplate`
+    * Add optional integer fields `channels` and `channel_id`
+    * Add optional foreign key field `parent`
+* `dcim.MACAddress`
+    * Add read-only boolean field `is_primary`
+* `dcim.Module`
+    * Add read-only boolean field `is_bay_compatible`
+    * A module may now be relocated by patching its `module_bay` (the device is derived from the target bay)
+* `dcim.ModuleBay`
+    * Add many-to-many field `module_bay_types`
+    * Add read-only boolean field `is_module_compatible`
+* `dcim.ModuleBayTemplate`
+    * Add many-to-many field `module_bay_types`
+* `dcim.ModuleType`
+    * Add optional choice field `cooling_method`
+    * Add optional date field `end_of_life`
+    * Add many-to-many field `module_bay_types`
+    * Annotate counts of cooling intake & outflow templates (`cooling_intake_template_count`, `cooling_outflow_template_count`)
+* `dcim.Rack`
+    * Add optional choice field `cooling_capability`
+    * Add optional decimal field `cooling_capacity`
+* `dcim.RackType`
+    * Add optional choice field `cooling_capability`
+    * Add optional decimal field `cooling_capacity`
+* `extras.CustomField`
+    * Add boolean field `nulls_first`
+    * Add read-only choice field `status`
+* `extras.EventRule`
+    * Add read-only boolean field `action_is_available`
+    * The `action_object_type` field is now optional, and is no longer restricted to object types which support event rules
+    * The choices available for `action_type` now include any action types registered by plugins
+* `extras.Webhook`
+    * Add optional integer field `timeout`
+* `ipam.Service`
+    * Add the `port_mappings` list field
+    * The `protocol` and `ports` fields are deprecated; they are populated only for single-protocol services and return null otherwise. They remain writable: A request may specify either `port_mappings` or the legacy pair, but not both in conflict
+    * The brief representation now includes `port_mappings` in place of `protocol` and `ports`
+* `ipam.ServiceTemplate`
+    * Add the `port_mappings` list field
+    * The `protocol` and `ports` fields are deprecated; they are populated only for single-protocol services and return null otherwise. They remain writable: A request may specify either `port_mappings` or the legacy pair, but not both in conflict
+    * The brief representation now includes `port_mappings` in place of `protocol` and `ports`
+* `users.Token`
+    * The `token` field is now read-only; a plaintext value can no longer be specified on creation
+* `virtualization.VirtualMachine`
+    * Add read-only JSON field `config_context` (previously available only via `VirtualMachineWithConfigContextSerializer`)
+* `virtualization.VMInterface`
+    * The `mac_address` field is now writable, and creates or updates the interface's primary MAC address
+
+### GraphQL API Changes
+
+* New query fields:
+    * `cooling_feed` / `cooling_feed_list`
+    * `cooling_intake` / `cooling_intake_list`
+    * `cooling_intake_template` / `cooling_intake_template_list`
+    * `cooling_outflow` / `cooling_outflow_list`
+    * `cooling_outflow_template` / `cooling_outflow_template_list`
+    * `cooling_source` / `cooling_source_list`
+    * `module_bay_type` / `module_bay_type_list`
+* Selection & multiple selection custom field values resolved via `custom_fields` are now returned as `{value, label}` objects
+* The `ServiceFilter` and `ServiceTemplateFilter` types now expose the flat `port_mappings`, `protocol`, `port`, `port__gt`, `port__gte`, `port__lt`, and `port__lte` parameters in place of the previous `protocol` and nested `ports` lookups
+* The members of `ServiceProtocolEnum` have been renamed from `ROLE_TCP`, `ROLE_UDP`, and `ROLE_SCTP` to `TCP`, `UDP`, and `SCTP`
+* Interface & interface template types now expose the `channels` and `channel_id` fields
+* Device, device type, module type, rack, and rack type types now expose their new cooling fields, and new enums have been introduced for the cooling choice sets
+* The custom field type and filter now expose the new `status` field, with a corresponding `CustomFieldStatusEnum`
+* Plugins may now extend core output types and filters (see the [plugin GraphQL API documentation](../plugins/development/graphql-api.md))

+ 13 - 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'
@@ -335,6 +346,7 @@ nav:
         - git Cheat Sheet: 'development/git-cheat-sheet.md'
     - Release Notes:
         - Summary: 'release-notes/index.md'
+        - Version 4.7: 'release-notes/version-4.7.md'
         - Version 4.6: 'release-notes/version-4.6.md'
         - Version 4.5: 'release-notes/version-4.5.md'
         - Version 4.4: 'release-notes/version-4.4.md'

+ 15 - 5
netbox/account/views.py

@@ -11,9 +11,8 @@ 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 _
 from django.views.decorators.debug import sensitive_post_parameters
 from django.views.generic import View
@@ -27,6 +26,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
@@ -66,17 +66,21 @@ class LoginView(View):
             'display_name': display_name,
             'icon_name': icon_name,
             'icon_img': icon_img,
-            'url': f'{url}?{urlencode(params)}',
+            'url': url,
+            'params': dict(params),
         }
 
     def get_auth_backends(self, request):
         auth_backends = []
         saml_idps = get_saml_idps()
+        # The login page is re-rendered by post() when authentication fails, in which case the
+        # post-login URL is found in the POST data (as with redirect_to_next() below).
+        request_data = request.POST if request.method == 'POST' else request.GET
 
         for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys():
             url = reverse('social:begin', args=[name])
             params = {}
-            if next := request.GET.get('next'):
+            if next := request_data.get('next'):
                 params['next'] = next
             if name.lower() == 'saml' and saml_idps:
                 for idp in saml_idps:
@@ -345,6 +349,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 +372,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):
@@ -188,3 +188,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(),

+ 150 - 3
netbox/core/api/schema.py

@@ -1,7 +1,10 @@
+import copy
 import re
 import typing
 from collections import OrderedDict
 
+from django.core.exceptions import ImproperlyConfigured
+from django.utils.translation import gettext_lazy as _
 from drf_spectacular.contrib.django_filters import DjangoFilterExtension
 from drf_spectacular.extensions import OpenApiSerializerExtension, OpenApiSerializerFieldExtension, _SchemaType
 from drf_spectacular.openapi import AutoSchema
@@ -14,10 +17,12 @@ from drf_spectacular.plumbing import (
     get_doc,
 )
 from drf_spectacular.types import OpenApiTypes
-from drf_spectacular.utils import Direction, OpenApiParameter
+from drf_spectacular.utils import Direction, OpenApiParameter, OpenApiResponse
+from rest_framework.fields import ReadOnlyField
+from rest_framework.utils import model_meta
 
 from netbox.api.fields import ChoiceField
-from netbox.api.serializers import WritableNestedSerializer
+from netbox.api.serializers import BulkOperationErrorSerializer, WritableNestedSerializer
 from netbox.api.viewsets import NetBoxModelViewSet
 
 # see netbox.api.routers.NetBoxRouter
@@ -182,6 +187,106 @@ class NetBoxAutoSchema(AutoSchema):
 
         return response_serializers
 
+    def _get_bulk_error_responses(self, direction) -> typing.Any:
+        """
+        Return the error responses of the current bulk write action, keyed by status code, or an
+        empty dict if this action is not a bulk write.
+
+        A failed bulk write returns a structured body correlating each failure with the object (or,
+        where no object could be identified, the request position) responsible for it. This is a
+        documented part of the API contract, but drf-spectacular cannot infer it: responses are
+        derived from the request/response serializer alone, which describes only the success case.
+        """
+        action = getattr(self.view, 'action', None)
+
+        if action in ('bulk_update', 'bulk_partial_update'):
+            return {
+                '400': OpenApiResponse(
+                    response=BulkOperationErrorSerializer,
+                    description=_(
+                        "One or more of the objects specified could not be updated. No objects were "
+                        "modified: a bulk update is an all-or-none operation."
+                    ),
+                ),
+                '403': OpenApiResponse(
+                    response=BulkOperationErrorSerializer,
+                    description=_(
+                        "The requesting user is not permitted to apply one or more of the "
+                        "modifications specified. No objects were modified."
+                    ),
+                ),
+            }
+
+        if action == 'bulk_destroy':
+            return {
+                '400': OpenApiResponse(
+                    response=BulkOperationErrorSerializer,
+                    description=_(
+                        "The request was malformed, one or more of the objects specified could not "
+                        "be found, or the deletion of one of them was prevented by a protection "
+                        "rule. No objects were deleted."
+                    ),
+                ),
+                '403': OpenApiResponse(
+                    response=BulkOperationErrorSerializer,
+                    description=_(
+                        "The requesting user is not permitted to delete one or more of the objects "
+                        "specified. No objects were deleted."
+                    ),
+                ),
+                '409': OpenApiResponse(
+                    response=BulkOperationErrorSerializer,
+                    description=_(
+                        "One or more of the objects specified could not be deleted, because a "
+                        "dependent object prevents it. No objects were deleted: a bulk deletion is "
+                        "an all-or-none operation."
+                    ),
+                ),
+            }
+
+        if action == 'create' and viewset_handles_bulk_create(self.view):
+            # A POST to a list endpoint accepts either a single object or a list of them (see
+            # _get_request_for_media_type()), so its error body takes one of two shapes
+            # accordingly: field-keyed errors for a single object, or the bulk envelope for a list.
+            component = self.resolve_serializer(BulkOperationErrorSerializer, direction)
+            return {
+                '400': OpenApiResponse(
+                    response={
+                        'oneOf': [
+                            build_basic_type(OpenApiTypes.OBJECT),
+                            component.ref if component else build_basic_type(OpenApiTypes.OBJECT),
+                        ],
+                    },
+                    description=_(
+                        "The object could not be created. Where a list was submitted, no objects "
+                        "were created: a bulk creation is an all-or-none operation."
+                    ),
+                ),
+                # A 403 always carries a `detail`, and BulkOperationError's `errors` is optional, so
+                # the one component covers both the single-object and the bulk shape here.
+                '403': OpenApiResponse(
+                    response=BulkOperationErrorSerializer,
+                    description=_(
+                        "The requesting user is not permitted to create one or more of the objects "
+                        "specified. No objects were created."
+                    ),
+                ),
+            }
+
+        return {}
+
+    def _get_response_bodies(self, direction='response') -> typing.Any:
+        responses = super()._get_response_bodies(direction=direction)
+
+        # Document the error responses of the bulk write actions, which cannot be inferred (see
+        # _get_bulk_error_responses). A status code already present -- for instance one declared
+        # via @extend_schema on a custom action -- is left as it is.
+        for code, response in self._get_bulk_error_responses(direction).items():
+            if code not in responses:
+                responses[code] = self._get_response_for_code(response, code, direction=direction)
+
+        return responses
+
     def _get_request_for_media_type(self, serializer, direction='request'):
         """
         Override to generate oneOf schema for serializers that support both
@@ -240,6 +345,36 @@ class NetBoxAutoSchema(AutoSchema):
                 ref_name = ref_name[: -len('Serializer')]
         return ref_name
 
+    @staticmethod
+    def _rebuilds_as_writable(serializer, field_name):
+        """
+        Return True if DRF would rebuild the named field in writable form if the field declared on
+        the serializer class were removed (see get_writable_class()).
+
+        This defers to ModelSerializer.build_field(), which is what get_fields() itself calls for
+        any field not explicitly declared on the class -- rather than testing the model for a field
+        of that name, which is a weaker condition. A name backed only by a model property, by a
+        non-editable model field, or by a generic foreign key (which lives in Meta.private_fields
+        and so is absent from DRF's field info) is rebuilt read-only, and is then dropped from the
+        request body altogether.
+        """
+        model = getattr(getattr(serializer, 'Meta', None), 'model', None)
+        if model is None or not hasattr(serializer, 'build_field'):
+            return False
+
+        depth = getattr(serializer.Meta, 'depth', 0)
+        try:
+            field_class, field_kwargs = serializer.build_field(
+                field_name, model_meta.get_field_info(model), model, depth
+            )
+        except ImproperlyConfigured:
+            # build_unknown_field(): the model has nothing of this name at all
+            return False
+
+        if isinstance(field_class, type) and issubclass(field_class, ReadOnlyField):
+            return False
+        return not field_kwargs.get('read_only', False)
+
     def get_writable_class(self, serializer):
         properties = {}
         fields = {} if hasattr(serializer, 'child') else serializer.fields
@@ -253,7 +388,19 @@ class NetBoxAutoSchema(AutoSchema):
             if 'read_only' in dir(child) and child.read_only:
                 remove_fields.append(child_name)
             if isinstance(child, (ChoiceField, WritableNestedSerializer)):
-                properties[child_name] = None
+                if child.read_only or self._rebuilds_as_writable(serializer, child_name):
+                    properties[child_name] = None
+                else:
+                    # DRF cannot rebuild this one writably: it is backed by a read-only property
+                    # (e.g. Service.protocol, derived from port_mappings). Nulling it would leave
+                    # DRF to rebuild it as a ReadOnlyField, which is then omitted from the request
+                    # body altogether -- silently dropping a field the serializer does accept on
+                    # write. Keep the declared field instead; ChoiceFieldFix already renders it
+                    # correctly for the request direction. The copy leaves the bound original
+                    # untouched (Field.__deepcopy__ returns an unbound field built from the same
+                    # arguments), and keeps `properties` non-empty so the writable variant is still
+                    # generated rather than collapsing to None below.
+                    properties[child_name] = copy.deepcopy(child)
 
         if not properties:
             return None

+ 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')
 

+ 6 - 1
netbox/core/apps.py

@@ -29,9 +29,14 @@ class CoreConfig(AppConfig):
 
     def ready(self):
         from core.api import schema  # noqa: F401
-        from core.checks import check_duplicate_indexes, check_postgresql_version, check_redis_version  # noqa: F401
+        from core.checks import (  # noqa: F401
+            check_duplicate_indexes,
+            check_postgresql_version,
+            check_redis_version,
+        )
         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
 

+ 92 - 29
netbox/core/checks.py

@@ -1,7 +1,10 @@
+import logging
+
 from django.apps import apps
 from django.core.cache import cache
-from django.core.checks import Error, Tags, Warning, register
-from django.db import connection
+from django.core.checks import Error, Tags, register
+from django.core.exceptions import ImproperlyConfigured
+from django.db import DatabaseError, InterfaceError, NotSupportedError, OperationalError, connections
 from django.db.models import Index, UniqueConstraint
 
 __all__ = (
@@ -10,6 +13,14 @@ __all__ = (
     'check_redis_version',
 )
 
+# The minimum major version of PostgreSQL required by NetBox. `SHOW server_version_num` reports the
+# server version as a single integer of the form MMmmmm (e.g. 150004 for PostgreSQL 15.4), so the
+# major version is scaled by 10000 when comparing against it.
+POSTGRESQL_MIN_VERSION = 15
+POSTGRESQL_VERSION_MULTIPLIER = 10000
+
+logger = logging.getLogger('netbox.core.checks')
+
 
 @register(Tags.models)
 def check_duplicate_indexes(app_configs, **kwargs):
@@ -46,50 +57,102 @@ def check_duplicate_indexes(app_configs, **kwargs):
 
 
 @register(Tags.database)
-def check_postgresql_version(app_configs, **kwargs):
+def check_postgresql_version(app_configs, databases=None, **kwargs):
     """
-    Warn if the PostgreSQL version is less than 15, as support for PostgreSQL 14
-    will be removed in NetBox v4.7.
+    Report an error if the PostgreSQL version is less than POSTGRESQL_MIN_VERSION.
     """
-    warnings = []
-    try:
-        with connection.cursor() as cursor:
-            cursor.execute('SHOW server_version_num')
-            row = cursor.fetchone()
+    errors = []
+
+    # Validate only those database aliases which Django has asked us to check. A value of None means
+    # that no database may be touched during this run, so there is nothing to validate: commands which
+    # do intend to use a connection declare its alias (e.g. `migrate` passes the value of --database).
+    # This mirrors Django's own database checks; see checks.database.check_database_backends().
+    if databases is None:
+        return errors
+
+    for alias in databases:
+        connection = connections[alias]
+
+        # A plugin may register a connection to some other type of database; only PostgreSQL
+        # connections are subject to NetBox's minimum version requirement.
+        if connection.vendor != 'postgresql':
+            continue
+
+        try:
+            with connection.cursor() as cursor:
+                cursor.execute('SHOW server_version_num')
+                row = cursor.fetchone()
+        except NotSupportedError:
+            # Django refuses to use the connection at all when the server predates the minimum version
+            # which Django itself supports (BaseDatabaseWrapper.check_database_version_supported()), so
+            # the query above never runs. The PostgreSQL backend registers no validation checks of its
+            # own, so report the requirement here rather than letting the raw exception surface as a
+            # traceback the first time something touches the database.
+            errors.append(
+                Error(
+                    f"Database '{alias}': The configured PostgreSQL version is not supported. NetBox "
+                    f"requires PostgreSQL {POSTGRESQL_MIN_VERSION} or later.",
+                    hint=f'Please upgrade to PostgreSQL {POSTGRESQL_MIN_VERSION} or later.',
+                    id='netbox.E001',
+                )
+            )
+            continue
+        except (ImproperlyConfigured, InterfaceError, OperationalError):
+            # The database is unreachable, has yet to be provisioned, or the connection is no longer
+            # usable. (InterfaceError is a sibling of DatabaseError, not a subclass, so it must be
+            # named explicitly.) Leave the version unverified rather than reporting a spurious error.
+            continue
+        except DatabaseError:
+            # The server is reachable but rejected the query (e.g. a connection pooler which intercepts
+            # SHOW). Record why the version could not be determined rather than failing silently.
+            logger.warning(f"Database '{alias}': Failed to determine the PostgreSQL version.", exc_info=True)
+            continue
+
+        if not row:
+            logger.warning(f"Database '{alias}': `SHOW server_version_num` returned no result.")
+            continue
+
+        try:
             pg_version = int(row[0])
-        if pg_version < 150000:
-            major_version = pg_version // 10000
-            warnings.append(
-                Warning(
-                    f'Support for PostgreSQL {major_version} is deprecated and will be removed in NetBox v4.7.',
-                    hint='Please upgrade to PostgreSQL 15 or later.',
-                    id='netbox.W001',
+        except (TypeError, ValueError):
+            # `SHOW server_version_num` reports an integer, but a pooler which answers the statement
+            # itself may report a dotted version instead. Leave the version unverified rather than
+            # raising out of the check and aborting the calling command.
+            logger.warning(f"Database '{alias}': Unable to parse the PostgreSQL version from {row[0]!r}.")
+            continue
+
+        if pg_version < POSTGRESQL_MIN_VERSION * POSTGRESQL_VERSION_MULTIPLIER:
+            major_version = pg_version // POSTGRESQL_VERSION_MULTIPLIER
+            errors.append(
+                Error(
+                    f"Database '{alias}': PostgreSQL {major_version} is not supported. NetBox requires "
+                    f"PostgreSQL {POSTGRESQL_MIN_VERSION} or later.",
+                    hint=f'Please upgrade to PostgreSQL {POSTGRESQL_MIN_VERSION} or later.',
+                    id='netbox.E001',
                 )
             )
-    except Exception:
-        pass
-    return warnings
+
+    return errors
 
 
 @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'),
     )

Некоторые файлы не были показаны из-за большого количества измененных файлов