فهرست منبع

Merge branch 'main' into 23212-virtual-chassis

Arthur 5 روز پیش
والد
کامیت
3b56ea9c9c
70فایلهای تغییر یافته به همراه2105 افزوده شده و 276 حذف شده
  1. 1 0
      .github/PULL_REQUEST_TEMPLATE.md
  2. 10 2
      .github/workflows/ci.yml
  3. 8 0
      AGENTS.md
  4. 34 0
      AI_POLICY.md
  5. 2 1
      CONTRIBUTING.md
  6. 2 4
      base_requirements.txt
  7. 1 1
      docs/configuration/required-parameters.md
  8. 3 0
      docs/customization/custom-scripts.md
  9. 1 0
      docs/development/user-preferences.md
  10. 8 0
      docs/features/search.md
  11. 1 2
      docs/plugins/development/dashboard-widgets.md
  12. 3 3
      docs/plugins/development/forms.md
  13. 1 1
      docs/plugins/development/index.md
  14. 36 0
      docs/plugins/development/user-interface.md
  15. 3 6
      docs/plugins/development/views.md
  16. 11 11
      netbox/circuits/tests/query_counts.json
  17. 6 0
      netbox/core/apps.py
  18. 13 0
      netbox/core/models/jobs.py
  19. 3 3
      netbox/core/tests/query_counts.json
  20. 59 1
      netbox/core/tests/test_views.py
  21. 43 43
      netbox/dcim/tests/query_counts.json
  22. 13 0
      netbox/dcim/tests/test_views.py
  23. 2 2
      netbox/dcim/ui/panels.py
  24. 14 5
      netbox/extras/api/customfields.py
  25. 2 1
      netbox/extras/api/serializers_/attachments.py
  26. 2 1
      netbox/extras/api/serializers_/bookmarks.py
  27. 2 1
      netbox/extras/api/serializers_/events.py
  28. 2 1
      netbox/extras/api/serializers_/journaling.py
  29. 4 2
      netbox/extras/api/serializers_/notifications.py
  30. 22 9
      netbox/extras/api/serializers_/scripts.py
  31. 3 2
      netbox/extras/api/views.py
  32. 16 0
      netbox/extras/migrations/0145_taggeditem_object_id_bigint.py
  33. 5 0
      netbox/extras/models/tags.py
  34. 16 16
      netbox/extras/tests/query_counts.json
  35. 317 3
      netbox/extras/tests/test_api.py
  36. 122 3
      netbox/extras/tests/test_customfields.py
  37. 12 0
      netbox/extras/tests/test_models.py
  38. 100 0
      netbox/extras/tests/test_views.py
  39. 13 5
      netbox/extras/views.py
  40. 17 17
      netbox/ipam/tests/query_counts.json
  41. 5 1
      netbox/netbox/graphql/filters.py
  42. 32 0
      netbox/netbox/graphql/pagination.py
  43. 6 1
      netbox/netbox/graphql/types.py
  44. 10 3
      netbox/netbox/jobs.py
  45. 303 12
      netbox/netbox/search/deferred.py
  46. 45 7
      netbox/netbox/search/jobs.py
  47. 2 1
      netbox/netbox/tests/dummy_plugin/models.py
  48. 86 0
      netbox/netbox/tests/test_graphql.py
  49. 147 0
      netbox/netbox/tests/test_jobs.py
  50. 336 13
      netbox/netbox/tests/test_search.py
  51. 58 0
      netbox/netbox/tests/test_ui.py
  52. 4 1
      netbox/netbox/ui/attrs.py
  53. 2 2
      netbox/netbox/views/generic/feature_views.py
  54. 0 0
      netbox/project-static/dist/netbox.js
  55. 0 0
      netbox/project-static/dist/netbox.js.map
  56. 1 1
      netbox/project-static/src/util.ts
  57. 1 1
      netbox/templates/extras/inc/script_list_content.html
  58. 13 4
      netbox/templates/ui/attrs/address.html
  59. 2 1
      netbox/tenancy/api/serializers_/contacts.py
  60. 6 6
      netbox/tenancy/tests/query_counts.json
  61. 27 0
      netbox/tenancy/tests/test_api.py
  62. 31 30
      netbox/translations/en/LC_MESSAGES/django.po
  63. 6 6
      netbox/users/tests/query_counts.json
  64. 4 1
      netbox/utilities/testing/base.py
  65. 20 14
      netbox/utilities/testing/tables.py
  66. 3 3
      netbox/utilities/testing/views.py
  67. 7 7
      netbox/virtualization/tests/query_counts.json
  68. 10 10
      netbox/vpn/tests/query_counts.json
  69. 3 3
      netbox/wireless/tests/query_counts.json
  70. 2 2
      requirements.txt

+ 1 - 0
.github/PULL_REQUEST_TEMPLATE.md

@@ -4,6 +4,7 @@
 
       1. An issue has been opened to capture these changes
       2. The issue has been accepted and assigned to you for work
+      3. Your PR complies with our AI policy (see AI_POLICY.md)
 
     Pull requests which do not reference an assigned issue will be closed
     automatically. Please specify your assigned issue number on the line below.

+ 10 - 2
.github/workflows/ci.yml

@@ -30,8 +30,8 @@ concurrency:
 jobs:
 
   # Detect which areas of the codebase changed so downstream jobs can be skipped
-  # when their inputs haven't changed. Jobs that don't match any filter are shown
-  # as "skipped" in GitHub's check list, which satisfies required-status-checks.
+  # when their inputs haven't changed. Not safe for required checks: paths-ignore
+  # can skip the whole run, and a skipped matrix job reports its unexpanded name.
   changes:
     name: Detect changed files
     runs-on: ubuntu-latest
@@ -50,19 +50,27 @@ jobs:
         uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d  # v4.0.3
         id: filter
         with:
+          # release.yaml and ci.yml sit in every filter, so releases and workflow edits run all jobs.
           filters: |
             python:
               - 'netbox/**/*.py'
               - 'netbox/**/migrations/**'
+              - 'netbox/**/templates/**'
+              - 'netbox/**/tests/**'
               - 'requirements*.txt'
               - 'pyproject.toml'
+              - 'netbox/release.yaml'
               - '.github/workflows/ci.yml'
             frontend:
               - 'netbox/project-static/**'
+              - 'scripts/verify-bundles.sh'
+              - 'netbox/release.yaml'
               - '.github/workflows/ci.yml'
             docs:
               - 'docs/**'
               - 'mkdocs.yml'
+              - 'requirements.txt'
+              - 'netbox/release.yaml'
               - '.github/workflows/ci.yml'
 
   lint:

+ 8 - 0
AGENTS.md

@@ -266,6 +266,14 @@ GitHub Actions workflows in `.github/workflows/`:
 - **Extras**: Cross-cutting features (custom fields, tags, webhooks, scripts) belong in the `extras` app.
 - **Plugin API**: Only documented public APIs are stable. Internal code may change without notice.
 
+## AI Policy
+
+All contributions must comply with the project's [AI policy](AI_POLICY.md). In short:
+
+- AI tools may be used to explore the codebase and to generate code.
+- The contributor must fully understand, and be able to explain, every change submitted.
+- Issues, pull request descriptions, and comments must be written in the contributor's own voice. Anything you draft for these is a starting point for the contributor to review and rewrite: keep it brief and plain, and say so when handing it over.
+
 ## Branch & PR Conventions
 
 - Branch naming: `<issue-number>-short-description` (e.g., `1234-device-typerror`)

+ 34 - 0
AI_POLICY.md

@@ -0,0 +1,34 @@
+# AI Contribution Policy
+
+This project **welcomes the thoughtful use of AI tools** when contributing, yet asks all contributors to follow two core principles.
+
+## Never let an LLM speak for you
+
+When maintainers read a comment, an issue, or a pull request from you, they want to know they are hearing your words. Grammar and spelling do not matter. What matters is real connection.
+
+- All comments, issues, and pull request descriptions should be written in your own voice
+- We value clear, human communication over perfect grammar or spelling
+- Avoid copy-pasting AI-generated summaries that don't reflect your own understanding
+
+AI-generated summaries tend to be long-winded, dense, and often inaccurate. Simplicity is an art. The goal is not to sound impressive, but to communicate clearly.
+
+## Never let an LLM think for you
+
+Go ahead and use AI to explore the codebase, or to write the function or test you need. But the final step before contributing should always be understanding what it has written.
+
+- Feel free to use AI tools to generate code or explore ideas
+- Only submit contributions you fully understand and can explain
+- Contributions should reflect your own reasoning and problem-solving
+
+Use AI to point you in the right direction, but always take personal responsibility. Do not quote an LLM in an issue or pull request. Maintainers want to know what you think.
+
+Our aim is ensuring quality and maintaining the joy of collaborating and communicating with real people.
+
+## Summary
+
+- AI may be used to generate code or explore ideas
+- Comments, issues, and pull request descriptions must be written in your own voice
+- Contributions must be fully understood and explainable by the contributor
+- Contribution guidelines must still be followed
+
+This policy is based on the [Human Voice policy, version 1.0.0](https://ai-policy.dev/policies/human-voice/1.0.0) from 2026-09-23. Read more about this and other AI contribution policies on [ai-policy.dev](https://ai-policy.dev/).

+ 2 - 1
CONTRIBUTING.md

@@ -38,7 +38,7 @@ NetBox users are welcome to participate in either role, on stage or in the crowd
 > We do not currently accept issues submitted via GitHub's API: All issues must be submitted using one of the [provided templates](https://github.com/netbox-community/netbox/issues/new/choose). In addition to ensuring high-quality submissions, these templates automatically assign issue types and labels for categorization to help expedite triage. This does not happen when issues are submitted via the API.
 
 > [!IMPORTANT]
-> Every issue submitted to this repository is afforded consideration by a human reviewer. To mitigate abuse, we ask that users refrain from submitting AI-generated issues. Please note that issues which appear to be completely authored by an AI may be rejected without further discussion.
+> Every issue submitted to this repository is afforded consideration by a human reviewer. Please review our [AI policy](AI_POLICY.md) before contributing: While AI tools may be used to help write code, all issues, pull request descriptions, and comments must be written in your own words. Issues which appear to be completely authored by an AI may be rejected without further discussion.
 
 ## :bug: Reporting Bugs
 
@@ -98,6 +98,7 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
 
 * All code submissions must meet the following criteria (CI will enforce these checks where feasible):
   * Consist entirely of original work
+  * Comply with our [AI policy](AI_POLICY.md)
   * Python syntax is valid
   * All tests pass when run with `NETBOX_CONFIGURATION=netbox.configuration_testing ./manage.py test`
   * `ruff check` successfully validates style compliance

+ 2 - 4
base_requirements.txt

@@ -167,14 +167,12 @@ social-auth-core<6.0.0
 sorl-thumbnail
 
 # Strawberry GraphQL
-# Pinned below v0.333.0, which is incompatible with strawberry-graphql-django <v0.93.0 (see #23383)
 # https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
-strawberry-graphql<0.333.0
+strawberry-graphql
 
 # Strawberry GraphQL Django extension
-# Pinned below v0.92.3, which breaks pagination of prefetched list fields (see #23383)
 # https://github.com/strawberry-graphql/strawberry-django/releases
-strawberry-graphql-django<0.92.3
+strawberry-graphql-django
 
 # SVG image rendering (used for rack elevations)
 # https://github.com/mozman/svgwrite/blob/master/NEWS.rst

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

@@ -30,7 +30,7 @@ ALLOWED_HOSTS = ['*']
 ```python
 API_TOKEN_PEPPERS = {
     # DO NOT USE THIS EXAMPLE PEPPER IN PRODUCTION
-    1: 'kp7ht*76fiQAhUi5dHfASLlYUE_S^gI^(7J^K5M!LfoH@vl&b_',
+    1: r'kp7ht*76fiQAh\i5dHfASLlYUE_S^gI^(7J^K5M!LfoH@vl&b_',
 }
 ```
 

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

@@ -552,6 +552,9 @@ Optionally `schedule_at` can be passed in the form data with a datetime string t
 !!! note
     Script input submitted through the REST API is validated against the variables declared by the script. Missing required variables or invalid values result in an HTTP 400 response, and undeclared keys are discarded rather than passed to `run()`. Existing API clients that relied on the previous pass-through behavior may need to update their requests. Scripts declaring a `FileVar` must be run via a `multipart/form-data` request, passing `data` as a JSON string alongside the uploaded file.
 
+!!! note "Changed in NetBox v4.7.4"
+    A script's `result` in the REST API reports only jobs the requesting user is permitted to view (`core.view_job`), and is `null` when there are none. The response to running a script always includes the job just created. To let users poll the results of their own script runs, grant them `core.view_job` with the constraint `{"user": "$user"}`.
+
 ### Via the CLI
 
 Scripts can be run on the CLI by invoking the management command:

+ 1 - 0
docs/development/user-preferences.md

@@ -13,6 +13,7 @@ For end‑user guidance on resetting saved table layouts, see [Features > User P
 | `locale.language`          | The language selected for UI translation                      |
 | `pagination.per_page`      | The number of items to display per page of a paginated table  |
 | `pagination.placement`     | Where to display the paginator controls relative to the table |
+| `plugins.${plugin}.*`      | Preferences registered by plugins                             |
 | `tables.${table}.columns`  | The ordered list of columns to display when viewing the table |
 | `tables.${table}.ordering` | A list of column names by which the table should be ordered   |
 | `ui.copilot_enabled`       | Toggles the NetBox Copilot AI agent                           |

+ 8 - 0
docs/features/search.md

@@ -8,6 +8,14 @@ When entering a search query, the user can choose a specific lookup type: exact
 
 Custom fields defined by NetBox administrators are also included in search results if configured with a search weight. Additionally, NetBox plugins can register their own custom models for inclusion alongside core models.
 
+### Deferred indexing
+
+To avoid enqueuing a separate background job for every change, search index updates are coalesced: changes committed over a short interval are buffered and applied together by a single background job. This settles quickly (on the order of a couple of seconds) when workers are running. A recurring safety-net job also recovers any pending updates whose drain was lost (for example, if a worker was stopped mid-run), so indexing is eventually consistent even after a worker restart. Because a background drain can run longer than its Redis lease, overlapping applies are prevented by a database-level lock, so an object's index entries are never duplicated.
+
+The pending buffer is stored in the tasks Redis database. If that Redis data is lost, some recent index updates may be discarded; run `manage.py reindex` to rebuild the search index from the database.
+
+If a buffered batch repeatedly fails to apply (for example, due to a persistent database error), it is retried by the safety-net job a limited number of times and then discarded, with the failure recorded at error level in the system log. The objects in a discarded batch will not be re-indexed until they are saved again; run `manage.py reindex` to rebuild the search index from the database.
+
 !!! note
     NetBox does not index any static choice field's (including custom fields of type "Selection" or "Multiple selection").
 

+ 1 - 2
docs/plugins/development/dashboard-widgets.md

@@ -30,8 +30,7 @@ class MyWidget2(DashboardWidget):
 
 ```python
 from django import forms
-from extras.dashboard.utils import register_widget
-from extras.dashboard.widgets import DashboardWidget, WidgetConfigForm
+from extras.dashboard.widgets import DashboardWidget, WidgetConfigForm, register_widget
 
 
 @register_widget

+ 3 - 3
docs/plugins/development/forms.md

@@ -77,7 +77,7 @@ The corresponding model-specific subclasses of `NetBoxModelImportForm` are docum
 from django.utils.translation import gettext_lazy as _
 from dcim.models import Site
 from netbox.forms import NetBoxModelImportForm
-from utilities.forms import CSVModelChoiceField
+from utilities.forms.fields import CSVModelChoiceField
 from .models import MyModel
 
 
@@ -120,7 +120,7 @@ from django import forms
 from django.utils.translation import gettext_lazy as _
 from dcim.models import Site
 from netbox.forms import NetBoxModelBulkEditForm
-from utilities.forms import CommentField, DynamicModelChoiceField
+from utilities.forms.fields import CommentField, DynamicModelChoiceField
 from utilities.forms.rendering import FieldSet
 from .models import MyModel, MyModelStatusChoices
 
@@ -170,7 +170,7 @@ The corresponding model-specific subclasses of `NetBoxModelFilterSetForm` are do
 ```python
 from dcim.models import Site
 from netbox.forms import NetBoxModelFilterSetForm
-from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField
+from utilities.forms.fields import DynamicModelMultipleChoiceField, MultipleChoiceField
 from .models import MyModel, MyModelStatusChoices
 
 class MyModelFilterForm(NetBoxModelFilterSetForm):

+ 1 - 1
docs/plugins/development/index.md

@@ -126,7 +126,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
 | `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`)           |
+| `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.
 

+ 36 - 0
docs/plugins/development/user-interface.md

@@ -12,3 +12,39 @@ window.addEventListener('netbox.colorModeChanged', e => {
   console.log('New color mode:', customEvent.detail.netboxColorMode);
 });
 ```
+
+## User Preferences
+
+Plugins can register their own user preferences, which are displayed in a dedicated "Plugins" section of each user's preferences page. To do so, create a file named `preferences.py` in your plugin and define a dictionary named `preferences` mapping each preference name to a `UserPreference` instance.
+
+```python title="preferences.py"
+from django.utils.translation import gettext_lazy as _
+from users.preferences import UserPreference
+
+preferences = {
+    'default_view': UserPreference(
+        label=_('Default view'),
+        choices=(
+            ('summary', _('Summary')),
+            ('detail', _('Detail')),
+        ),
+        description=_('The default view for plugin objects'),
+    ),
+}
+```
+
+!!! tip
+    The path to the preferences dictionary can be modified by setting `user_preferences` in the PluginConfig instance.
+
+A `UserPreference` accepts the following arguments:
+
+| Argument      | Required | Description                                                                       |
+|---------------|----------|-----------------------------------------------------------------------------------|
+| `label`       | Yes      | The human-friendly name of the preference                                         |
+| `choices`     | Yes      | An iterable of `(value, label)` two-tuples                                        |
+| `default`     | -        | The default value for the preference                                              |
+| `description` | -        | A brief description of the preference                                             |
+| `coerce`      | -        | A callable to convert the submitted value to the appropriate type (e.g. `int`)    |
+| `warning`     | -        | A warning message to display alongside the preference                             |
+
+Each preference is stored under the path `plugins.<plugin_name>.<preference_name>`, where `<plugin_name>` is the last component of the plugin's `PluginConfig.name` (e.g. `netbox_foo` for a plugin named `acme.netbox_foo`). Its value for the current user can be retrieved by calling `get()` on the user's config.

+ 3 - 6
docs/plugins/development/views.md

@@ -172,8 +172,7 @@ Below are the class definitions for NetBox's multi-object views. These views han
 
 ::: netbox.views.generic.BulkDeleteView
     options:
-      members:
-        - get_form
+      members: false
 
 ## Feature Views
 
@@ -184,13 +183,11 @@ These views are provided to enable or enhance certain NetBox model features, suc
 
 ::: netbox.views.generic.ObjectChangeLogView
     options:
-      members:
-        - get_form
+      members: false
 
 ::: netbox.views.generic.ObjectJournalView
     options:
-      members:
-        - get_form
+      members: false
 
 ## Extending Core Views
 

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

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

+ 6 - 0
netbox/core/apps.py

@@ -36,6 +36,12 @@ class CoreConfig(AppConfig):
         )
         from netbox import context_managers  # noqa: F401
         from netbox.models.features import register_models
+
+        # Importing netbox.search.jobs registers its system job(s) (e.g. the
+        # search-cache flush safety net) in registry['system_jobs'] before the
+        # RQ worker reads that registry at startup. The module is otherwise only
+        # imported lazily, so this import is load-bearing, not cosmetic.
+        from netbox.search import jobs as search_jobs  # noqa: F401
         from netbox.search import signals as search_signals  # noqa: F401
 
         from . import data_backends, events, search  # noqa: F401

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

@@ -197,6 +197,19 @@ class Job(models.Model):
 
         return f"{int(minutes)} minutes, {seconds:.2f} seconds"
 
+    def _is_rq_job_present(self):
+        """
+        Return True if this job's RQ-side entry still exists. A "scheduled" Job row whose RQ entry
+        has been lost (e.g. by a Redis reset) will never run and is stale. A due job that RQ has
+        already moved from the scheduled registry into its queue is still present and must be kept.
+
+        Only meaningful while the Job row is "scheduled": the RQ entry also lingers after a job has
+        finished (briefly) or failed (up to a year), states a "scheduled" row never occupies.
+        """
+        rq_queue_name = self.queue_name or get_queue_for_model(self.object_type.model if self.object_type else None)
+        queue = django_rq.get_queue(rq_queue_name)
+        return queue.fetch_job(str(self.job_id)) is not None
+
     def delete(self, *args, **kwargs):
         # Use the stored queue name, or fall back to get_queue_for_model for legacy jobs
         rq_queue_name = self.queue_name or get_queue_for_model(self.object_type.model if self.object_type else None)

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

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

+ 59 - 1
netbox/core/tests/test_views.py

@@ -16,7 +16,7 @@ from rq.registry import DeferredJobRegistry, FailedJobRegistry, FinishedJobRegis
 from core.choices import ObjectChangeActionChoices
 from core.models import *
 from dcim.models import Site
-from users.models import User
+from users.models import ObjectPermission, User
 from utilities.testing import TestCase, ViewTestCases, create_tags, disable_logging
 from utilities.testing.mixins import RQQueueTestMixin
 
@@ -171,6 +171,64 @@ class JobTestCase(
         )
 
 
+class ObjectJobsViewTestCase(TestCase):
+    """
+    The Jobs tab of an object must list only the Jobs which the user is permitted to view.
+    """
+    user_permissions = (
+        'core.view_datasource',
+    )
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///var/tmp/source1/',
+        )
+        cls.other_user = User.objects.create_user(username='otheruser')
+        cls.other_job = cls._create_job('other-job', user=cls.other_user)
+        cls.system_job = cls._create_job('system-job', user=None)
+
+    @classmethod
+    def _create_job(cls, name, user):
+        return Job.objects.create(
+            name=name,
+            object_type=ContentType.objects.get_for_model(DataSource),
+            object_id=cls.datasource.pk,
+            status='completed',
+            job_id=uuid.uuid4(),
+            user=user,
+        )
+
+    def setUp(self):
+        super().setUp()
+        self.own_job = self._create_job('own-job', user=self.user)
+        self.url = reverse('core:datasource_jobs', kwargs={'pk': self.datasource.pk})
+
+    def _grant_view_job(self, constraints=None):
+        obj_perm = ObjectPermission(name='View jobs', constraints=constraints, actions=['view'])
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Job))
+
+    def _get_listed_jobs(self):
+        response = self.client.get(self.url)
+        self.assertHttpStatus(response, 200)
+        return {row.record for row in response.context['table'].rows}
+
+    def test_unconstrained_permission_lists_all_jobs(self):
+        self._grant_view_job()
+        self.assertEqual(self._get_listed_jobs(), {self.own_job, self.other_job, self.system_job})
+
+    def test_user_constrained_permission_lists_own_jobs_only(self):
+        self._grant_view_job(constraints={'user': '$user'})
+        self.assertEqual(self._get_listed_jobs(), {self.own_job})
+
+    def test_without_view_job_permission_lists_no_jobs(self):
+        self.assertEqual(self._get_listed_jobs(), set())
+
+
 class JobLogViewTestCase(TestCase):
     user_permissions = (
         'core.view_job',

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

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

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

@@ -233,6 +233,19 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             with self.subTest(panel=panel):
                 self.assertNotContains(response, url)
 
+    def test_get_object_address_copy_buttons(self):
+        """The physical and shipping addresses each render a copy-to-clipboard button."""
+        self.add_permissions('dcim.view_site')
+        site = self._get_queryset().first()
+        site.physical_address = '742 Evergreen Terrace\nSpringfield, USA'
+        site.shipping_address = '1 Loading Dock Way\nSpringfield, USA'
+        site.save()
+
+        response = self.client.get(site.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, 'data-clipboard-text="742 Evergreen Terrace\nSpringfield, USA"')
+        self.assertContains(response, 'data-clipboard-text="1 Loading Dock Way\nSpringfield, USA"')
+
     def test_get_object_vlan_row_covers_group_scopes(self):
         """The VLANs row counts direct, site-scoped and site-group-scoped VLANs and links to the filter."""
         self.add_permissions('dcim.view_site', 'ipam.view_vlan')

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

@@ -37,8 +37,8 @@ class SitePanel(panels.ObjectAttributesPanel):
     facility = attrs.TextAttr('facility')
     description = attrs.TextAttr('description')
     timezone = attrs.TimezoneAttr('time_zone')
-    physical_address = attrs.AddressAttr('physical_address', map_url=True)
-    shipping_address = attrs.AddressAttr('shipping_address', map_url=True)
+    physical_address = attrs.AddressAttr('physical_address', map_url=True, copy_button=True)
+    shipping_address = attrs.AddressAttr('shipping_address', map_url=True, copy_button=True)
     gps_coordinates = attrs.GPSCoordinatesAttr()
 
 

+ 14 - 5
netbox/extras/api/customfields.py

@@ -1,6 +1,7 @@
 from functools import cached_property
 
 from django.core.exceptions import ValidationError as DjangoValidationError
+from django.db.models import prefetch_related_objects
 from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
@@ -10,7 +11,7 @@ from rest_framework.serializers import ListSerializer, ValidationError
 from extras.choices import CustomFieldTypeChoices
 from extras.constants import CUSTOMFIELD_EMPTY_VALUES
 from extras.models import CustomField
-from utilities.api import get_serializer_for_model
+from utilities.api import get_prefetches_for_serializer, get_serializer_for_model
 from utilities.forms.fields import LaxURLField
 
 #
@@ -154,10 +155,9 @@ class CustomFieldListSerializer(ListSerializer):
                 cf for cf in cf_field._get_custom_fields()
                 if cf.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT)
             ]
-            cache = {}
+            pks_by_model = {}
             for cf in object_type_cfs:
-                model = cf.related_object_type.model_class()
-                pks = set()
+                pks = pks_by_model.setdefault(cf.related_object_type.model_class(), set())
                 for item in data:
                     raw = item.custom_field_data.get(cf.name)
                     if raw is not None:
@@ -165,7 +165,16 @@ class CustomFieldListSerializer(ListSerializer):
                             pks.update(raw)
                         else:
                             pks.add(raw)
-                for obj in model.objects.filter(pk__in=pks):
+            cache = {}
+            for model, pks in pks_by_model.items():
+                objects = list(model.objects.filter(pk__in=pks))
+                # A target model may lack a REST serializer, so plan only for found targets
+                if objects:
+                    serializer_class = get_serializer_for_model(model)
+                    # Targets render with their nested (brief) representation
+                    fields = getattr(serializer_class.Meta, 'brief_fields', None)
+                    prefetch_related_objects(objects, *get_prefetches_for_serializer(serializer_class, fields=fields))
+                for obj in objects:
                     cache[(model, obj.pk)] = obj
             self.child.context['cf_object_cache'] = cache
         return super().to_representation(data)

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

@@ -16,7 +16,8 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
     object_type = ContentTypeField(
         queryset=ObjectType.objects.all()
     )
-    parent = GFKSerializerField(read_only=True)
+    # A plugin model can support image attachments without a REST API serializer
+    parent = GFKSerializerField(read_only=True, allow_missing_serializer=True)
     image_width = serializers.IntegerField(read_only=True)
     image_height = serializers.IntegerField(read_only=True)
     image_size = serializers.IntegerField(read_only=True)

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

@@ -14,7 +14,8 @@ class BookmarkSerializer(ValidatedModelSerializer):
     object_type = ContentTypeField(
         queryset=ObjectType.objects.with_feature('bookmarks'),
     )
-    object = GFKSerializerField(read_only=True)
+    # A plugin model can support bookmarks without a REST API serializer
+    object = GFKSerializerField(read_only=True, allow_missing_serializer=True)
     user = UserSerializer(nested=True)
 
     class Meta:

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

@@ -30,7 +30,8 @@ class EventRuleSerializer(OwnerMixin, NetBoxModelSerializer):
         required=False,
         allow_null=True,
     )
-    action_object = GFKSerializerField(read_only=True)
+    # A plugin action can target a model without a REST API serializer
+    action_object = GFKSerializerField(read_only=True, allow_missing_serializer=True)
     action_is_available = serializers.BooleanField(read_only=True)
 
     class Meta:

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

@@ -18,7 +18,8 @@ class JournalEntrySerializer(NetBoxModelSerializer):
     assigned_object_type = ContentTypeField(
         queryset=ObjectType.objects.all()
     )
-    assigned_object = GFKSerializerField(read_only=True)
+    # A plugin model can support journaling without a REST API serializer
+    assigned_object = GFKSerializerField(read_only=True, allow_missing_serializer=True)
     created_by = serializers.PrimaryKeyRelatedField(
         allow_null=True,
         queryset=User.objects.all(),

+ 4 - 2
netbox/extras/api/serializers_/notifications.py

@@ -17,7 +17,8 @@ class NotificationSerializer(ValidatedModelSerializer):
     object_type = ContentTypeField(
         queryset=ObjectType.objects.with_feature('notifications'),
     )
-    object = GFKSerializerField(read_only=True)
+    # A plugin model can support notifications without a REST API serializer
+    object = GFKSerializerField(read_only=True, allow_missing_serializer=True)
     user = UserSerializer(nested=True)
 
     class Meta:
@@ -56,7 +57,8 @@ class SubscriptionSerializer(ValidatedModelSerializer):
     object_type = ContentTypeField(
         queryset=ObjectType.objects.with_feature('notifications'),
     )
-    object = GFKSerializerField(read_only=True)
+    # A plugin model can support notifications without a REST API serializer
+    object = GFKSerializerField(read_only=True, allow_missing_serializer=True)
     user = UserSerializer(nested=True)
 
     class Meta:

+ 22 - 9
netbox/extras/api/serializers_/scripts.py

@@ -128,7 +128,7 @@ class ScriptModuleSerializer(ValidatedModelSerializer):
 class ScriptSerializer(ValidatedModelSerializer):
     description = serializers.SerializerMethodField(read_only=True)
     vars = serializers.SerializerMethodField(read_only=True)
-    result = JobSerializer(nested=True, read_only=True)
+    result = serializers.SerializerMethodField(read_only=True)
 
     class Meta:
         model = Script
@@ -155,18 +155,31 @@ class ScriptSerializer(ValidatedModelSerializer):
             return obj.python_class().description
         return None
 
+    @extend_schema_field(JobSerializer(nested=True, allow_null=True))
+    def get_result(self, obj):
+        if job := self._get_latest_job(obj):
+            return JobSerializer(job, nested=True, context=self.context).data
+        return None
+
+    def _get_latest_job(self, obj):
+        """
+        Return the most recent Job for the script which the requesting user is permitted to view.
+        """
+        request = self.context.get('request')
+        # No request (e.g. serialize_for_event()) means no user to check permissions against
+        if request is None:
+            return None
+        return obj.jobs.restrict(request.user, 'view').order_by('-created').first()
+
 
 class ScriptDetailSerializer(ScriptSerializer):
-    result = serializers.SerializerMethodField(read_only=True)
 
-    @extend_schema_field(JobSerializer())
+    @extend_schema_field(JobSerializer(allow_null=True))
     def get_result(self, obj):
-        job = obj.jobs.all().order_by('-created').first()
-        context = {
-            'request': self.context['request']
-        }
-        data = JobSerializer(job, context=context).data
-        return data
+        # A job passed in by the run action takes precedence over the latest visible job
+        if job := self.context.get('job') or self._get_latest_job(obj):
+            return JobSerializer(job, context={'request': self.context.get('request')}).data
+        return None
 
 
 class ScriptInputSerializer(serializers.Serializer):

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

@@ -436,7 +436,7 @@ class ScriptViewSet(ListModelMixin, RetrieveModelMixin, BaseViewSet):
             data.pop(k, None)
 
         try:
-            ScriptJob.enqueue(
+            job = ScriptJob.enqueue(
                 instance=script,
                 user=request.user,
                 data=data,
@@ -453,7 +453,8 @@ class ScriptViewSet(ListModelMixin, RetrieveModelMixin, BaseViewSet):
             # request-field errors, so report them under the non-field "detail" key.
             raise ValidationError({'detail': e.messages}) from e
 
-        serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
+        # Return the job just enqueued (regardless of view_job permission), never another user's concurrent job
+        serializer = serializers.ScriptDetailSerializer(script, context={'request': request, 'job': job})
         return Response(serializer.data)
 
 

+ 16 - 0
netbox/extras/migrations/0145_taggeditem_object_id_bigint.py

@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0144_customfield_status'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='taggeditem',
+            name='object_id',
+            field=models.PositiveBigIntegerField(db_index=True),
+        ),
+    ]

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

@@ -79,6 +79,11 @@ class TaggedItem(GenericTaggedItemBase):
         related_name="%(app_label)s_%(class)s_items",
         on_delete=models.CASCADE
     )
+    # Overrides django-taggit's 32-bit IntegerField to accommodate 64-bit object IDs
+    object_id = models.PositiveBigIntegerField(
+        verbose_name=_('object ID'),
+        db_index=True
+    )
 
     _netbox_private = True
     objects = RestrictedQuerySet.as_manager()

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

@@ -1,37 +1,37 @@
 {
   "bookmark:api_list_objects": 12,
-  "bookmark:list_objects_with_permission": 17,
+  "bookmark:list_objects_with_permission": 16,
   "configcontext:api_list_objects": 22,
-  "configcontext:list_objects_with_permission": 16,
+  "configcontext:list_objects_with_permission": 15,
   "configcontextprofile:api_list_objects": 12,
-  "configcontextprofile:list_objects_with_permission": 16,
+  "configcontextprofile:list_objects_with_permission": 15,
   "configtemplate:api_list_objects": 10,
-  "configtemplate:list_objects_with_permission": 17,
+  "configtemplate:list_objects_with_permission": 16,
   "customfield:api_list_objects": 10,
-  "customfield:list_objects_with_permission": 19,
+  "customfield:list_objects_with_permission": 18,
   "customfieldchoiceset:api_list_objects": 9,
-  "customfieldchoiceset:list_objects_with_permission": 16,
+  "customfieldchoiceset:list_objects_with_permission": 15,
   "customlink:api_list_objects": 10,
-  "customlink:list_objects_with_permission": 18,
+  "customlink:list_objects_with_permission": 17,
   "eventrule:api_list_objects": 15,
-  "eventrule:list_objects_with_permission": 20,
+  "eventrule:list_objects_with_permission": 19,
   "exporttemplate:api_list_objects": 10,
-  "exporttemplate:list_objects_with_permission": 19,
+  "exporttemplate:list_objects_with_permission": 18,
   "imageattachment:api_list_objects": 11,
-  "imageattachment:list_objects_with_permission": 21,
+  "imageattachment:list_objects_with_permission": 20,
   "journalentry:api_list_objects": 15,
-  "journalentry:list_objects_with_permission": 21,
+  "journalentry:list_objects_with_permission": 20,
   "notification:api_list_objects": 12,
   "notificationgroup:api_list_objects": 11,
-  "notificationgroup:list_objects_with_permission": 18,
+  "notificationgroup:list_objects_with_permission": 17,
   "savedfilter:api_list_objects": 10,
-  "savedfilter:list_objects_with_permission": 19,
+  "savedfilter:list_objects_with_permission": 18,
   "subscription:api_list_objects": 12,
   "tableconfig:api_list_objects": 11,
-  "tableconfig:list_objects_with_permission": 19,
+  "tableconfig:list_objects_with_permission": 18,
   "tag:api_list_objects": 10,
-  "tag:list_objects_with_permission": 18,
+  "tag:list_objects_with_permission": 17,
   "taggeditem:api_list_objects": 12,
   "webhook:api_list_objects": 12,
-  "webhook:list_objects_with_permission": 17
+  "webhook:list_objects_with_permission": 16
 }

+ 317 - 3
netbox/extras/tests/test_api.py

@@ -2,6 +2,7 @@ import datetime
 import hashlib
 import io
 import json
+import uuid
 from contextlib import contextmanager
 from unittest.mock import MagicMock, PropertyMock, patch
 
@@ -31,13 +32,14 @@ from dcim.models import (
     RearPort,
     Site,
 )
-from extras.api.serializers import EventRuleSerializer
+from extras.api.serializers import EventRuleSerializer, ScriptSerializer
 from extras.choices import *
 from extras.models import *
 from extras.scripts import BooleanVar, IntegerVar, MultiObjectVar, ObjectVar, StringVar
 from extras.scripts import Script as PythonClass
 from netbox.event_rules import EventRuleAction, register_event_rule_action
 from netbox.registry import registry
+from netbox.tests.dummy_plugin.models import DummyNetBoxModel
 from users.constants import TOKEN_PREFIX
 from users.models import Group, ObjectPermission, Token, User
 from utilities.tables import get_table_for_model
@@ -303,6 +305,40 @@ class EventRuleActionAPITestCase(APITestCase):
         )
         self.assertIn(user_ct, field.queryset)
 
+    @tag('regression')  # Ref: #23372
+    def test_action_object_without_serializer(self):
+        """A rule whose action targets an object without a REST API serializer has a null action object."""
+        class PluginObjectAction(EventRuleAction):
+            slug = 'test.api_plugin_object_action'
+            label = 'API Plugin Object Action'
+            object_model = DummyNetBoxModel
+
+        register_event_rule_action(PluginObjectAction)
+        self.addCleanup(registry['event_rule_actions'].pop, PluginObjectAction.slug, None)
+
+        self.add_permissions('extras.view_eventrule')
+        webhook = Webhook.objects.create(name='Webhook 1', payload_url='http://example.com/')
+        EventRule.objects.create(name='Webhook Rule', event_types=[OBJECT_CREATED], action_object=webhook)
+        dummy = DummyNetBoxModel.objects.create()
+        event_rule = EventRule(
+            name='Plugin Rule', event_types=[OBJECT_CREATED], action_type=PluginObjectAction.slug, action_object=dummy
+        )
+        event_rule.full_clean()
+        event_rule.save()
+
+        response = self.client.get(reverse('extras-api:eventrule-list'), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        results = {item['id']: item for item in response.data['results']}
+        self.assertIsNone(results.pop(event_rule.pk)['action_object'])
+        for item in results.values():
+            self.assertEqual(item['action_object']['id'], item['action_object_id'])
+
+        response = self.client.get(reverse('extras-api:eventrule-detail', kwargs={'pk': event_rule.pk}), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['action_object'])
+        self.assertEqual(response.data['action_object_type'], 'dummy_plugin.dummynetboxmodel')
+        self.assertEqual(response.data['action_object_id'], dummy.pk)
+
 
 class CustomFieldTestCase(APIViewTestCases.APIViewTestCase):
     model = CustomField
@@ -952,6 +988,28 @@ class BookmarkTestCase(
             },
         ]
 
+    @tag('regression')  # Ref: #23372
+    def test_object_without_serializer(self):
+        """A bookmark of an object without a REST API serializer has a null object."""
+        self.add_permissions('extras.view_bookmark')
+        dummy = DummyNetBoxModel.objects.create()
+        bookmark = Bookmark(object=dummy, user=self.user)
+        bookmark.full_clean()
+        bookmark.save()
+
+        response = self.client.get(reverse('extras-api:bookmark-list'), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        results = {item['id']: item for item in response.data['results']}
+        self.assertIsNone(results.pop(bookmark.pk)['object'])
+        for item in results.values():
+            self.assertEqual(item['object']['id'], item['object_id'])
+
+        response = self.client.get(reverse('extras-api:bookmark-detail', kwargs={'pk': bookmark.pk}), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['object'])
+        self.assertEqual(response.data['object_type'], 'dummy_plugin.dummynetboxmodel')
+        self.assertEqual(response.data['object_id'], dummy.pk)
+
 
 class ExportTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = ExportTemplate
@@ -1427,6 +1485,37 @@ class ImageAttachmentTestCase(
         )
         ImageAttachment.objects.bulk_create(image_attachments)
 
+    @tag('regression')  # Ref: #23372
+    def test_parent_without_serializer(self):
+        """An image attachment whose parent has no REST API serializer has a null parent."""
+        self.add_permissions('extras.view_imageattachment')
+        dummy = DummyNetBoxModel.objects.create()
+        image_attachment = ImageAttachment(
+            parent=dummy,
+            name='Image Attachment 4',
+            image='http://example.com/image4.png',
+            image_height=100,
+            image_width=100,
+        )
+        # The placeholder image is not a stored file
+        image_attachment.full_clean(exclude=['image'])
+        image_attachment.save()
+
+        response = self.client.get(reverse('extras-api:imageattachment-list'), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        results = {item['id']: item for item in response.data['results']}
+        self.assertIsNone(results.pop(image_attachment.pk)['parent'])
+        for item in results.values():
+            self.assertEqual(item['parent']['id'], item['object_id'])
+
+        response = self.client.get(
+            reverse('extras-api:imageattachment-detail', kwargs={'pk': image_attachment.pk}), **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['parent'])
+        self.assertEqual(response.data['object_type'], 'dummy_plugin.dummynetboxmodel')
+        self.assertEqual(response.data['object_id'], dummy.pk)
+
 
 class JournalEntryTestCase(APIViewTestCases.APIViewTestCase):
     model = JournalEntry
@@ -1505,6 +1594,39 @@ class JournalEntryTestCase(APIViewTestCases.APIViewTestCase):
         created_by_after = entry.created_by_id
         self.assertEqual(created_by_before, created_by_after)
 
+    @tag('regression')  # Ref: #23372
+    def test_assigned_object_without_serializer(self):
+        """A journal entry on an object without a REST API serializer has a null assigned object."""
+        self.add_permissions('extras.add_journalentry', 'extras.view_journalentry', 'extras.delete_journalentry')
+        dummy = DummyNetBoxModel.objects.create()
+        data = {
+            'assigned_object_type': 'dummy_plugin.dummynetboxmodel',
+            'assigned_object_id': dummy.pk,
+            'comments': 'Seventh entry',
+        }
+
+        response = self.client.post(reverse('extras-api:journalentry-list'), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertIsNone(response.data['assigned_object'])
+        journal_entry_id = response.data['id']
+        detail_url = reverse('extras-api:journalentry-detail', kwargs={'pk': journal_entry_id})
+
+        response = self.client.get(reverse('extras-api:journalentry-list'), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        results = {item['id']: item for item in response.data['results']}
+        self.assertIsNone(results.pop(journal_entry_id)['assigned_object'])
+        for item in results.values():
+            self.assertEqual(item['assigned_object']['id'], item['assigned_object_id'])
+
+        response = self.client.get(detail_url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['assigned_object'])
+        self.assertEqual(response.data['assigned_object_type'], 'dummy_plugin.dummynetboxmodel')
+        self.assertEqual(response.data['assigned_object_id'], dummy.pk)
+
+        response = self.client.delete(detail_url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+
 
 class ConfigContextProfileTestCase(APIViewTestCases.APIViewTestCase):
     model = ConfigContextProfile
@@ -2502,17 +2624,26 @@ class ScriptRunExecutionTestCase(APITestCase):
                 file_root=ManagedFileRootPathChoices.SCRIPTS,
                 file_path='run_script.py',
             )
-        script = Script.objects.create(
+        cls.script = Script.objects.create(
             module=module,
             name='Test run script',
             is_executable=True,
         )
-        cls.url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
+        cls.url = reverse('extras-api:script-detail', kwargs={'pk': cls.script.pk})
 
     def setUp(self):
         super().setUp()
         self.add_permissions('extras.run_script')
 
+        # A saved Job for the mocked ScriptJob.enqueue() to return, as run() serializes the enqueued job
+        self.job = Job.objects.create(
+            object_type=ObjectType.objects.get_for_model(Script),
+            object_id=self.script.pk,
+            name='Test run script',
+            job_id=uuid.uuid4(),
+            user=self.user,
+        )
+
         # Monkey-patch the Script model to return our TestScriptClass above, restoring
         # the real property afterwards so later tests aren't left with our stub.
         python_class_patch = patch.object(Script, 'python_class', new=self.TestScriptClass)
@@ -2527,6 +2658,7 @@ class ScriptRunExecutionTestCase(APITestCase):
 
     @patch('extras.jobs.ScriptJob.enqueue')
     def test_run_forwards_commit_value(self, mock_enqueue):
+        mock_enqueue.return_value = self.job
         for commit_value in (True, False):
             with self.subTest(commit=commit_value):
                 mock_enqueue.reset_mock()
@@ -2540,6 +2672,7 @@ class ScriptRunExecutionTestCase(APITestCase):
 
     @patch('extras.jobs.ScriptJob.enqueue')
     def test_run_forwards_notifications_value(self, mock_enqueue):
+        mock_enqueue.return_value = self.job
         # Regression: ScriptForm.clean() overwrites an empty '_notifications' with the
         # field's own initial, so a client-supplied value never reached ScriptJob.enqueue.
         payload = {
@@ -2558,6 +2691,7 @@ class ScriptRunExecutionTestCase(APITestCase):
 
     @patch('extras.jobs.ScriptJob.enqueue')
     def test_run_forwards_schedule_at_and_interval(self, mock_enqueue):
+        mock_enqueue.return_value = self.job
         # Regression: schedule_at/interval were likewise read from the form (always
         # absent there) instead of the validated request
         schedule_at = now() + datetime.timedelta(hours=1)
@@ -2577,6 +2711,7 @@ class ScriptRunExecutionTestCase(APITestCase):
 
     @patch('extras.jobs.ScriptJob.enqueue')
     def test_run_converts_objectvar_and_multiobjectvar_ids(self, mock_enqueue):
+        mock_enqueue.return_value = self.job
         payload = {
             'data': {
                 'site': self.sites[0].pk,
@@ -2597,6 +2732,7 @@ class ScriptRunExecutionTestCase(APITestCase):
 
     @patch('extras.jobs.ScriptJob.enqueue')
     def test_run_backfills_default_for_omitted_required_var(self, mock_enqueue):
+        mock_enqueue.return_value = self.job
         # Regression: required vars declaring `default=` were not back-filled before
         # binding the form on the API path (unlike the UI path), so they 400'd even
         # though the client legitimately omitted them.
@@ -2629,6 +2765,7 @@ class ScriptRunExecutionTestCase(APITestCase):
 
     @patch('extras.jobs.ScriptJob.enqueue')
     def test_run_ignores_undeclared_keys(self, mock_enqueue):
+        mock_enqueue.return_value = self.job
         # Binding 'data' to the script's form means keys which don't correspond to a declared
         # variable are dropped rather than forwarded to run(). This is the contract documented
         # under "Running Custom Scripts > Via the API"; pin it so it can't regress silently.
@@ -2659,6 +2796,7 @@ class ScriptRunExecutionTestCase(APITestCase):
 
     @patch('extras.jobs.ScriptJob.enqueue')
     def test_run_resolves_booleanvar_default_and_explicit_values(self, mock_enqueue):
+        mock_enqueue.return_value = self.job
         # BooleanVar renders as a checkbox, and CheckboxInput reads a missing key as False.
         # An omitted BooleanVar must therefore pick up its declared default, while an
         # explicitly supplied False must not be overwritten by that default.
@@ -2677,6 +2815,134 @@ class ScriptRunExecutionTestCase(APITestCase):
                 self.assertIs(mock_enqueue.call_args.kwargs['data']['flag'], expected)
 
 
+@tag('regression')  # Ref: #23364
+class ScriptJobRestrictionTestCase(APITestCase):
+    """
+    A script's result reports the latest job the requesting user is permitted to view (see #23364).
+    """
+
+    class TestScriptClass(PythonClass):
+        class Meta:
+            name = 'Restricted script'
+
+        def run(self, data, commit=True):
+            return 'ok'
+
+    @classmethod
+    def setUpTestData(cls):
+        with patch.object(ScriptModule, 'sync_classes'):
+            module = ScriptModule.objects.create(
+                file_root=ManagedFileRootPathChoices.SCRIPTS,
+                file_path='restricted_script.py',
+            )
+        cls.script = Script.objects.create(
+            module=module,
+            name='Restricted script',
+            is_executable=True,
+        )
+        cls.list_url = reverse('extras-api:script-list')
+        cls.detail_url = reverse('extras-api:script-detail', kwargs={'pk': cls.script.pk})
+
+        cls.other_job = Job.objects.create(
+            object_type=ObjectType.objects.get_for_model(Script),
+            object_id=cls.script.pk,
+            name='Other user job',
+            job_id=uuid.uuid4(),
+            user=User.objects.create_user(username='script-job-other-user'),
+        )
+
+    def setUp(self):
+        super().setUp()
+        self.add_permissions('extras.view_script')
+
+        # The requesting user's own job predates the other user's, so a restricted lookup must fall
+        # through to it rather than report the newest job or none at all
+        self.own_job = self._create_job('Own job', self.other_job.created - datetime.timedelta(hours=1))
+
+        python_class_patch = patch.object(Script, 'python_class', new=self.TestScriptClass)
+        python_class_patch.start()
+        self.addCleanup(python_class_patch.stop)
+
+    def _create_job(self, name, created):
+        job = Job.objects.create(
+            object_type=ObjectType.objects.get_for_model(Script),
+            object_id=self.script.pk,
+            name=name,
+            job_id=uuid.uuid4(),
+            user=self.user,
+        )
+        Job.objects.filter(pk=job.pk).update(created=created)
+        return job
+
+    def _grant_view_job(self, constraints=None):
+        obj_perm = ObjectPermission(name='View jobs', actions=['view'], constraints=constraints)
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Job))
+
+    @staticmethod
+    def _job_path(job):
+        # The list representation of a job is brief, identifying it only by URL
+        return reverse('core-api:job-detail', kwargs={'pk': job.pk})
+
+    def _get_results(self):
+        list_response = self.client.get(self.list_url, **self.header)
+        self.assertHttpStatus(list_response, status.HTTP_200_OK)
+        detail_response = self.client.get(self.detail_url, **self.header)
+        self.assertHttpStatus(detail_response, status.HTTP_200_OK)
+        return list_response.data['results'][0]['result'], detail_response.data['result']
+
+    def test_unconstrained_view_job(self):
+        self._grant_view_job()
+
+        list_result, detail_result = self._get_results()
+
+        self.assertTrue(list_result['url'].endswith(self._job_path(self.other_job)))
+        self.assertEqual(detail_result['id'], self.other_job.pk)
+
+    def test_constrained_view_job(self):
+        self._grant_view_job(constraints={'user': '$user'})
+
+        list_result, detail_result = self._get_results()
+
+        self.assertTrue(list_result['url'].endswith(self._job_path(self.own_job)))
+        self.assertEqual(detail_result['id'], self.own_job.pk)
+
+    def test_without_view_job(self):
+        list_result, detail_result = self._get_results()
+
+        self.assertIsNone(list_result)
+        self.assertIsNone(detail_result)
+
+    def test_serialize_without_request(self):
+        # e.g. serialize_for_event(), which has no user to check permissions against
+        data = ScriptSerializer(self.script, context={'request': None}).data
+
+        self.assertIsNone(data['result'])
+
+    @patch('extras.api.views.any_workers_for_queue', return_value=True)
+    @patch('extras.jobs.ScriptJob.enqueue')
+    def test_run_returns_enqueued_job(self, mock_enqueue, mock_workers):
+        """
+        Running a script returns the job just enqueued, even without view_job permission and when another
+        user's job is newer. Polling the script thereafter reports no result without view_job permission.
+        """
+        self.add_permissions('extras.run_script')
+        mock_enqueue.return_value = self._create_job(
+            'Enqueued job', self.other_job.created - datetime.timedelta(minutes=1)
+        )
+
+        response = self.client.post(self.detail_url, {'data': {}, 'commit': True}, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['result']['id'], mock_enqueue.return_value.pk)
+
+        response = self.client.get(self.detail_url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['result'])
+
+
 class CreatedUpdatedFilterTestCase(APITestCase):
 
     @classmethod
@@ -2815,6 +3081,30 @@ class SubscriptionTestCase(APIViewTestCases.APIViewTestCase):
             'object_type': 'dcim.notamodel',
         }
 
+    @tag('regression')  # Ref: #23372
+    def test_object_without_serializer(self):
+        """A subscription to an object without a REST API serializer has a null object."""
+        self.add_permissions('extras.view_subscription')
+        dummy = DummyNetBoxModel.objects.create()
+        subscription = Subscription(object=dummy, user=self.user)
+        subscription.full_clean()
+        subscription.save()
+
+        response = self.client.get(reverse('extras-api:subscription-list'), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        results = {item['id']: item for item in response.data['results']}
+        self.assertIsNone(results.pop(subscription.pk)['object'])
+        for item in results.values():
+            self.assertEqual(item['object']['id'], item['object_id'])
+
+        response = self.client.get(
+            reverse('extras-api:subscription-detail', kwargs={'pk': subscription.pk}), **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['object'])
+        self.assertEqual(response.data['object_type'], 'dummy_plugin.dummynetboxmodel')
+        self.assertEqual(response.data['object_id'], dummy.pk)
+
 
 class NotificationGroupTestCase(APIViewTestCases.APIViewTestCase):
     model = NotificationGroup
@@ -2965,6 +3255,30 @@ class NotificationTestCase(APIViewTestCases.APIViewTestCase):
             },
         ]
 
+    @tag('regression')  # Ref: #23372
+    def test_object_without_serializer(self):
+        """A notification about an object without a REST API serializer has a null object."""
+        self.add_permissions('extras.view_notification')
+        dummy = DummyNetBoxModel.objects.create()
+        notification = Notification(object=dummy, object_repr=str(dummy), event_type=OBJECT_CREATED, user=self.user)
+        notification.full_clean()
+        notification.save()
+
+        response = self.client.get(reverse('extras-api:notification-list'), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        results = {item['id']: item for item in response.data['results']}
+        self.assertIsNone(results.pop(notification.pk)['object'])
+        for item in results.values():
+            self.assertEqual(item['object']['id'], item['object_id'])
+
+        response = self.client.get(
+            reverse('extras-api:notification-detail', kwargs={'pk': notification.pk}), **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['object'])
+        self.assertEqual(response.data['object_type'], 'dummy_plugin.dummynetboxmodel')
+        self.assertEqual(response.data['object_id'], dummy.pk)
+
 
 class _InMemoryScriptStorage:
     """Stateful stand-in for the scripts storage backend; mirrors its allow_overwrite option."""

+ 122 - 3
netbox/extras/tests/test_customfields.py

@@ -18,9 +18,10 @@ from rest_framework import status
 
 from core.choices import ObjectChangeActionChoices
 from core.models import Job, ObjectChange, ObjectType
+from dcim.choices import InterfaceTypeChoices
 from dcim.filtersets import SiteFilterSet
 from dcim.forms import SiteImportForm
-from dcim.models import Manufacturer, Rack, Site
+from dcim.models import Cable, Device, Interface, Manufacturer, Rack, Site
 from dcim.tables import SiteTable
 from extras.api.customfields import CustomFieldListSerializer
 from extras.choices import *
@@ -32,16 +33,17 @@ from extras.jobs import (
     provision_custom_field,
     purge_custom_field,
 )
-from extras.models import CustomField, CustomFieldChoiceSet
+from extras.models import CustomField, CustomFieldChoiceSet, Tag
 from ipam.api.serializers import VLANSerializer
 from ipam.models import VLAN
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from netbox.context import query_cache
 from netbox.context_managers import event_tracking
 from netbox.tables.columns import CustomFieldColumn
+from netbox.tests.dummy_plugin.models import DummyNetBoxModel
 from utilities.exceptions import AbortRequest
 from utilities.filters import MultiValueCharFilter, MultiValueContentTypeFilter
-from utilities.testing import APITestCase, TestCase
+from utilities.testing import APITestCase, TestCase, create_test_device
 from virtualization.models import VirtualMachine
 
 
@@ -1551,6 +1553,123 @@ class CustomFieldAPITestCase(APITestCase):
             [vlans['VLAN 4'].pk, vlans['VLAN 5'].pk],
         ])
 
+    def test_list_objects_prefetch_object_field_target_relations(self):
+        """Interface targets, their devices and their cables load in one query each."""
+        site_type = ObjectType.objects.get_for_model(Site)
+        interface_type = ObjectType.objects.get_for_model(Interface)
+        for cf_type, name in (
+            (CustomFieldTypeChoices.TYPE_OBJECT, 'interface_field'),
+            (CustomFieldTypeChoices.TYPE_MULTIOBJECT, 'interfaces_field'),
+        ):
+            cf = CustomField.objects.create(type=cf_type, name=name, related_object_type=interface_type)
+            cf.object_types.set([site_type])
+        devices = [create_test_device(f'Device {i}') for i in range(1, 5)]
+        interfaces = [
+            Interface.objects.create(device=device, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
+            for device in devices
+        ]
+        peer_device = create_test_device('Peer Device')
+        cables = []
+        for i, interface in enumerate(interfaces[:2]):
+            peer = Interface.objects.create(
+                device=peer_device, name=f'eth{i}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+            )
+            cable = Cable(a_terminations=[interface], b_terminations=[peer])
+            cable.save()
+            cables.append(cable)
+        sites = (
+            Site.objects.create(name='Site 3', slug='site-3', custom_field_data={
+                'interface_field': interfaces[0].pk,
+                'interfaces_field': [interfaces[1].pk, interfaces[2].pk],
+            }),
+            Site.objects.create(name='Site 4', slug='site-4', custom_field_data={
+                'interface_field': interfaces[3].pk,
+                'interfaces_field': [interfaces[2].pk, interfaces[0].pk],
+            }),
+        )
+        self.add_permissions('dcim.view_site')
+
+        with CaptureQueriesContext(connection) as ctx:
+            response = self.client.get(
+                reverse('dcim-api:site-list'), {'id': [site.pk for site in sites], 'fields': 'name,custom_fields'},
+                **self.header
+            )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        for model in (Interface, Device, Cable):
+            queries = get_primary_table_queries(ctx.captured_queries, model)
+            self.assertEqual(len(queries), 1, f'{model.__name__} rows must be fetched once, got {queries}')
+        # Relations outside the brief representation stay unloaded
+        self.assertEqual(get_primary_table_queries(ctx.captured_queries, Tag), [])
+
+        def summary(value):
+            return value['device']['id'], value['cable'] and value['cable']['id']
+
+        self.assertEqual(
+            {
+                site['name']: (
+                    summary(site['custom_fields']['interface_field']),
+                    [summary(value) for value in site['custom_fields']['interfaces_field']],
+                )
+                for site in response.data['results']
+            },
+            {
+                'Site 3': ((devices[0].pk, cables[0].pk), [(devices[1].pk, cables[1].pk), (devices[2].pk, None)]),
+                'Site 4': ((devices[3].pk, None), [(devices[2].pk, None), (devices[0].pk, cables[0].pk)]),
+            },
+        )
+
+    def test_list_objects_fetch_object_field_targets_once_per_model(self):
+        """Object and multi-object fields referencing one model share one target query."""
+        vlans = {vlan.name: vlan for vlan in VLAN.objects.all()}
+        missing_pk = max(vlan.pk for vlan in vlans.values()) + 1
+        site2 = Site.objects.get(name='Site 2')
+        site3 = Site.objects.create(name='Site 3', slug='site-3', custom_field_data={
+            'object_field': missing_pk,
+            'multiobject_field': [vlans['VLAN 5'].pk, missing_pk, vlans['VLAN 2'].pk, vlans['VLAN 5'].pk],
+        })
+        self.add_permissions('dcim.view_site')
+
+        with CaptureQueriesContext(connection) as ctx:
+            response = self.client.get(
+                reverse('dcim-api:site-list'), {'id': [site2.pk, site3.pk], 'fields': 'name,custom_fields'},
+                **self.header
+            )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        vlan_queries = get_primary_table_queries(ctx.captured_queries, VLAN)
+        self.assertEqual(len(vlan_queries), 1, f'VLAN targets must be fetched once, got {vlan_queries}')
+
+        custom_fields = {site['name']: site['custom_fields'] for site in response.data['results']}
+        self.assertEqual(custom_fields['Site 2']['object_field']['id'], vlans['VLAN 2'].pk)
+        self.assertEqual(
+            [vlan['id'] for vlan in custom_fields['Site 2']['multiobject_field']],
+            [vlans['VLAN 3'].pk, vlans['VLAN 4'].pk],
+        )
+        # A missing target renders as null and drops out of a list, which keeps its order and duplicates
+        self.assertIsNone(custom_fields['Site 3']['object_field'])
+        self.assertEqual(
+            [vlan['id'] for vlan in custom_fields['Site 3']['multiobject_field']],
+            [vlans['VLAN 5'].pk, vlans['VLAN 2'].pk, vlans['VLAN 5'].pk],
+        )
+
+    def test_list_objects_without_object_field_target_serializer(self):
+        """Empty and dangling references to a model without a REST serializer render as null."""
+        cf = CustomField.objects.create(
+            type=CustomFieldTypeChoices.TYPE_OBJECT,
+            name='dummy_field',
+            related_object_type=ObjectType.objects.get_for_model(DummyNetBoxModel),
+        )
+        cf.object_types.set([ObjectType.objects.get_for_model(Site)])
+        missing_pk = max(DummyNetBoxModel.objects.values_list('pk', flat=True), default=0) + 1
+        Site.objects.create(name='Site 3', slug='site-3', custom_field_data={'dummy_field': missing_pk})
+        self.add_permissions('dcim.view_site')
+
+        response = self.client.get(reverse('dcim-api:site-list'), {'fields': 'name,custom_fields'}, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(
+            {site['name']: site['custom_fields']['dummy_field'] for site in response.data['results']},
+            {'Site 1': None, 'Site 2': None, 'Site 3': None},
+        )
+
     def test_get_object_selection_field_representation(self):
         """
         Selection custom fields are rendered as an object exposing both the stored value and its

+ 12 - 0
netbox/extras/tests/test_models.py

@@ -504,6 +504,18 @@ class TagTestCase(TestCase):
         with self.assertRaises(AbortRequest):
             sitegroup.tags.add(tag)
 
+    def test_tag_object_with_64bit_id(self):
+        """
+        Objects whose IDs exceed the 32-bit range can be tagged. Regression test for #23425.
+        """
+        site = Site.objects.create(id=3_000_000_000, name='Site 1', slug='site-1')
+        tag = Tag.objects.create(name='Tag 1', slug='tag-1')
+
+        site.tags.add(tag)
+
+        self.assertEqual(list(Site.objects.get(pk=site.pk).tags.all()), [tag])
+        self.assertEqual(list(Site.objects.filter(tags=tag)), [site])
+
 
 class ConfigContextTestCase(TestCase):
     """

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

@@ -1,3 +1,4 @@
+import datetime
 import hashlib
 import logging
 import uuid
@@ -1733,3 +1734,102 @@ class ScriptResultViewTestCase(TestCase):
         response = self.client.get(url, {'export': 'output'})
         self.assertHttpStatus(response, 404)
         self.assertNotIn(self.SECRET_OUTPUT.encode(), response.content)
+
+
+class ScriptJobsRestrictionTestCase(TestCase):
+    """
+    Script views must display only the Jobs which the user is permitted to view.
+    """
+    # ScriptModule is a proxy model, so viewing it is governed by its concrete model's permission
+    user_permissions = ('extras.view_script', 'core.view_managedfile')
+
+    class TestScriptClass(PythonClass):
+        class Meta:
+            name = 'Restriction test'
+
+        def run(self, data, commit):
+            return 'Complete'
+
+    @classmethod
+    def setUpTestData(cls):
+        with patch.object(ScriptModule, 'sync_classes'):
+            module = ScriptModule.objects.create(
+                file_root=ManagedFileRootPathChoices.SCRIPTS,
+                file_path='restriction_script.py',
+            )
+        cls.script = Script.objects.create(module=module, name='Restriction test', is_executable=True)
+        cls.other_user = User.objects.create_user(username='otheruser')
+
+        # The other user's Job is the most recent, so that the script list must skip it (rather than
+        # hide it) to find the most recent Job the user is permitted to view
+        cls.system_job = cls._create_job('system-job', user=None, minutes_ago=30)
+        cls.other_job = cls._create_job('other-job', user=cls.other_user, minutes_ago=10)
+
+    @classmethod
+    def _create_job(cls, name, user, minutes_ago):
+        started = now() - datetime.timedelta(minutes=minutes_ago)
+        return Job.objects.create(
+            name=name,
+            object_type=ObjectType.objects.get_for_model(Script),
+            object_id=cls.script.pk,
+            status=JobStatusChoices.STATUS_COMPLETED,
+            job_id=uuid.uuid4(),
+            user=user,
+            started=started,
+            completed=started,
+        )
+
+    def setUp(self):
+        super().setUp()
+        python_class_patch = patch.object(Script, 'python_class', new=self.TestScriptClass)
+        python_class_patch.start()
+        self.addCleanup(python_class_patch.stop)
+        self.own_job = self._create_job('own-job', user=self.user, minutes_ago=20)
+
+    def _grant_view_job(self, constraints=None):
+        obj_perm = ObjectPermission(name='View jobs', constraints=constraints, actions=['view'])
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Job))
+
+    def _assert_script_views_show(self, expected_jobs):
+        """
+        Assert that the script's Jobs table and job count reflect only the expected Jobs.
+        """
+        response = self.client.get(reverse('extras:script_jobs', kwargs={'pk': self.script.pk}))
+        self.assertHttpStatus(response, 200)
+        self.assertEqual({row.record for row in response.context['table'].rows}, expected_jobs)
+        self.assertEqual(response.context['job_count'], len(expected_jobs))
+
+        for view_name in ('extras:script', 'extras:script_source'):
+            response = self.client.get(reverse(view_name, kwargs={'pk': self.script.pk}))
+            self.assertHttpStatus(response, 200)
+            self.assertEqual(response.context['job_count'], len(expected_jobs))
+
+    def _assert_script_list_shows(self, expected_job):
+        """
+        Assert that the script list displays the expected Job (or none) as the script's most recent result.
+        """
+        response = self.client.get(reverse('extras:script_list'))
+        self.assertHttpStatus(response, 200)
+        content = response.content.decode()
+        for job in (self.own_job, self.other_job, self.system_job):
+            result_url = reverse('extras:script_result', kwargs={'job_pk': job.pk})
+            if job == expected_job:
+                self.assertIn(result_url, content)
+            else:
+                self.assertNotIn(result_url, content)
+
+    def test_unconstrained_permission(self):
+        self._grant_view_job()
+        self._assert_script_views_show({self.own_job, self.other_job, self.system_job})
+        self._assert_script_list_shows(self.other_job)
+
+    def test_user_constrained_permission(self):
+        self._grant_view_job(constraints={'user': '$user'})
+        self._assert_script_views_show({self.own_job})
+        self._assert_script_list_shows(self.own_job)
+
+    def test_without_view_job_permission(self):
+        self._assert_script_views_show(set())
+        self._assert_script_list_shows(None)

+ 13 - 5
netbox/extras/views.py

@@ -1672,10 +1672,17 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
             'data_source', 'data_file',
         )
 
+        # Find the most recent completed Job for each Script which the user is permitted to view
+        latest_jobs = {
+            script.pk: script.get_latest_jobs().restrict(request.user, 'view').first()
+            for script in available_scripts
+        }
+
         context = {
             'model': ScriptModule,
             'script_modules': script_modules,
             'available_scripts': available_scripts,
+            'latest_jobs': latest_jobs,
         }
 
         # Use partial template for dashboard widgets
@@ -1724,7 +1731,7 @@ class ScriptView(BaseScriptView):
             'script': script,
             'script_class': script_class,
             'form': form,
-            'job_count': script.jobs.count(),
+            'job_count': script.jobs.restrict(request.user, 'view').count(),
         })
 
     def post(self, request, **kwargs):
@@ -1781,7 +1788,7 @@ class ScriptView(BaseScriptView):
             'script': script,
             'script_class': script.python_class(),
             'form': form,
-            'job_count': script.jobs.count(),
+            'job_count': script.jobs.restrict(request.user, 'view').count(),
         })
 
 
@@ -1795,7 +1802,7 @@ class ScriptSourceView(BaseScriptView):
         return render(request, 'extras/script/source.html', {
             'script': script,
             'script_class': script_class,
-            'job_count': script.jobs.count(),
+            'job_count': script.jobs.restrict(request.user, 'view').count(),
             'tab': 'source',
         })
 
@@ -1806,13 +1813,14 @@ class ScriptJobsView(BaseScriptView):
     def get(self, request, **kwargs):
         script = self.get_object(**kwargs)
 
-        jobs_table = ScriptJobTable(data=script.jobs.all(), orderable=False)
+        jobs = script.jobs.restrict(request.user, 'view')
+        jobs_table = ScriptJobTable(data=jobs, orderable=False)
         jobs_table.configure(request)
 
         return render(request, 'extras/script/jobs.html', {
             'script': script,
             'table': jobs_table,
-            'job_count': script.jobs.count(),
+            'job_count': jobs.count(),
             'tab': 'jobs',
         })
 

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

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

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

@@ -49,7 +49,11 @@ class NetBoxModelFilter(
     ChangeLoggingMixin,
     BaseModelFilter
 ):
-    pass
+    """
+    Base GraphQL filter for models which inherit from NetBoxModel. Adds filters for change logging, custom fields,
+    journal entries, and tags. Subclasses of PrimaryModel, OrganizationalModel, and NestedGroupModel should use the
+    corresponding filter class instead.
+    """
 
 
 @dataclass

+ 32 - 0
netbox/netbox/graphql/pagination.py

@@ -136,4 +136,36 @@ def apply_pagination(
             limit=pagination.limit,
         )
 
+    if pagination is not None and related_field_id is None and queryset._result_cache is not None:
+        return _apply_evaluated_pagination(pagination, queryset)
+
     return apply(pagination, queryset, related_field_id=related_field_id)
+
+
+def _apply_evaluated_pagination(pagination: OffsetPaginationInput, queryset: _QS) -> _QS:
+    """
+    Paginate a queryset which has already been evaluated (e.g. by a `prefetch_related` hint on the field).
+
+    strawberry-django's `apply()` slices the queryset directly, which for an evaluated queryset returns a list
+    rather than a QuerySet, and strawberry-django's list hook cannot handle a list. Instead, paginate an
+    unevaluated clone of the queryset, and populate its result cache by applying the same slice to the cached
+    results.
+    """
+    # TODO: Remove this once strawberry-django's `apply()` returns a QuerySet for an evaluated queryset.
+    results = queryset._result_cache
+    offset = pagination.offset
+    limit = _resolve_limit(pagination.limit)
+    stop = offset + limit if limit is not None and limit >= 0 else None
+
+    # Avoid cloning the queryset if the pagination would not exclude any results (the common case, where
+    # only the implicit MAX_PAGE_SIZE limit has been applied).
+    if offset == 0 and (stop is None or stop >= len(results)):
+        return queryset
+
+    # Slicing the unevaluated clone validates the pagination (e.g. rejecting a negative offset) and applies the
+    # limits to its query, so that the results remain consistent should the queryset be cloned again later.
+    paginated = queryset.all()[offset:stop]
+    paginated._result_cache = results[offset:stop]
+    paginated._prefetch_done = True
+
+    return paginated

+ 6 - 1
netbox/netbox/graphql/types.py

@@ -189,7 +189,12 @@ class NetBoxObjectType(
     TagsMixin,
     BaseObjectType
 ):
-    pass
+    """
+    Base GraphQL object type for models which inherit from NetBoxModel. Adds change logging, custom fields, journal
+    entries, and tags. Subclasses of PrimaryModel, OrganizationalModel, and NestedLtreeGroupModel should use
+    PrimaryObjectType, OrganizationalObjectType, and NestedLtreeGroupObjectType respectively, which add the `owner`
+    field (and `level`, for NestedLtreeGroupObjectType).
+    """
 
 
 #

+ 10 - 3
netbox/netbox/jobs.py

@@ -248,9 +248,16 @@ class JobRunner(ABC):
         """
         job = cls.get_jobs(instance).filter(status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES).first()
         if job:
-            # If the job parameters haven't changed, don't schedule a new job and keep the current schedule. Otherwise,
-            # delete the existing job and schedule a new job instead.
-            if (not schedule_at or job.scheduled == schedule_at) and (job.interval == interval):
+            # A system job scheduled in the past whose RQ entry is gone (e.g. a Redis reset) will
+            # never run, so replace it (see #22714). A job still present in RQ, or a non-system job,
+            # is left alone.
+            is_stale = (
+                cls in registry['system_jobs'] and
+                job.status == JobStatusChoices.STATUS_SCHEDULED and
+                job.scheduled <= timezone.now() and
+                not job._is_rq_job_present()
+            )
+            if not is_stale and (not schedule_at or job.scheduled == schedule_at) and (job.interval == interval):
                 return job
             job.delete()
 

+ 303 - 12
netbox/netbox/search/deferred.py

@@ -1,7 +1,13 @@
 import logging
+import time
+import uuid
+from datetime import timedelta
 
 from django.db import DEFAULT_DB_ALIAS, connections, transaction
-from redis.exceptions import RedisError
+from django.utils import timezone
+from django_pg_utils import advisory_lock
+from django_rq.queues import get_connection
+from redis.exceptions import RedisError, ResponseError
 
 from netbox.constants import RQ_QUEUE_DEFAULT
 from netbox.search.backends import search_backend
@@ -17,6 +23,29 @@ logger = logging.getLogger(__name__)
 OP_CACHE = 'cache'
 OP_REMOVE = 'remove'
 
+# Cross-transaction coalescing buffer. Committed batches accumulate in a
+# per-alias Redis hash on the tasks connection and are drained by a single
+# scheduled SearchCacheJob, so N committed transactions produce roughly one job
+# instead of N (the regression this replaces).
+FLUSH_INTERVAL = 2  # Seconds to coalesce before draining.
+LOCK_TTL = 60       # Seconds; scheduling throttle lease (one drain scheduled per alias).
+APPLY_LEASE = 60    # Seconds; per-batch processing lease (one applier per batch).
+
+# A persistently failing batch is retried by the safety net until it has failed
+# this many times, then dropped with an error-level log; the affected objects
+# stay unindexed until re-saved or `manage.py reindex` is run. The cap stops one
+# poisoned batch from blocking indexing of unrelated objects forever.
+MAX_APPLY_ATTEMPTS = 5
+
+PENDING_PREFIX = 'netbox:search:pending:'
+LOCK_PREFIX = 'netbox:search:flush-lock:'
+STAGING_PREFIX = 'netbox:search:draining:'
+LEASE_PREFIX = 'netbox:search:draining-lease:'
+ATTEMPTS_PREFIX = 'netbox:search:apply-attempts:'
+# Bounds growth of a batch's failure counter if its staging key disappears
+# through another path (e.g. a worker that crashed before writing the counter).
+ATTEMPTS_TTL = 3600
+
 # Attributes used to tag a flush callback so we can recognize our own callbacks
 # among those registered on a connection and reach the batch they will flush.
 _FLUSH_ALIAS_ATTR = '_netbox_search_flush_alias'
@@ -140,9 +169,11 @@ def _flush(batch, using):
     time it runs the originating write has already committed, so it must never
     propagate an error back to the caller and turn a successful save into a 500.
 
-    The inline fallback is safe even during a broker outage: the search index
-    lives in PostgreSQL (the extras_cachedvalue table), so a Redis outage only
-    prevents backgrounding, not indexing itself.
+    When a worker is available the batch is merged into the shared Redis buffer
+    and a single drain is scheduled (see _buffer_and_schedule); committed batches
+    from many transactions thus coalesce into far fewer jobs than the
+    one-job-per-transaction it replaces. Otherwise it falls back to inline
+    indexing, a PostgreSQL write with no Redis dependency.
 
     If the broker fails mid-enqueue (after the probe succeeds), Job.enqueue() has
     already saved a Job row before the Redis dispatch raised, so the fallback can
@@ -160,14 +191,274 @@ def _flush(batch, using):
         groups.setdefault(object_type_id, []).append(pk)
 
     try:
-        # Both the worker-availability check and the job enqueue talk to Redis,
-        # and a worker can die between the two. Treat any Redis failure across the
-        # whole dispatch as "no worker available" and fall back to inline
-        # indexing (a PostgreSQL write that does not depend on Redis).
-        if any_workers_for_queue(RQ_QUEUE_DEFAULT):
-            SearchCacheJob.enqueue(using=using, cache_groups=cache_groups, remove_groups=remove_groups)
-            return
+        has_worker = any_workers_for_queue(RQ_QUEUE_DEFAULT)
     except RedisError:
-        logger.warning("Search cache: broker unavailable; indexing inline", exc_info=True)
+        has_worker = False
+
+    if has_worker:
+        try:
+            _buffer_and_schedule(batch, using)
+            return
+        except RedisError:
+            # A worker can die (or the broker can drop) between the availability
+            # probe and the buffer write. Fall back to inline indexing. Any
+            # pending entry already written is deliberately left in place: it is
+            # a recoverable duplicate, and re-applying it is safe because
+            # _apply_deferred_updates is idempotent. Deleting it here could
+            # discard a concurrent writer's update, which is worse.
+            logger.warning("Search cache: broker unavailable; indexing inline", exc_info=True)
 
     search_backend._apply_deferred_updates(using=using, cache_groups=cache_groups, remove_groups=remove_groups)
+
+
+def _buffer_and_schedule(batch, using):
+    """
+    Merge a committed batch into the shared pending hash and ensure exactly one
+    drain is scheduled for the alias.
+    """
+    conn = _connection()
+    conn.hset(
+        _pending_key(using),
+        mapping={_field(object_type_id, pk): op for (object_type_id, pk), op in batch.items()},
+    )
+    # Only the flush that wins the throttle lock schedules a drain; the rest just
+    # merge into the buffer. HSET overwrites a field, so a later op for the same
+    # object wins -- the correct cross-batch semantics (a later save supersedes
+    # an earlier delete).
+    if conn.set(_lock_key(using), '1', nx=True, ex=LOCK_TTL):
+        try:
+            _schedule(using)
+        except Exception:
+            # Nothing will run the scheduled drain; release the lock so the
+            # safety net (or the next flush) can pick the buffer up promptly.
+            conn.delete(_lock_key(using))
+            raise
+
+
+def _schedule(using):
+    SearchCacheJob.enqueue(
+        using=using,
+        schedule_at=timezone.now() + timedelta(seconds=FLUSH_INTERVAL),
+    )
+
+
+def drain_and_apply(using, log=None):
+    """
+    Claim and apply the pending batch for a single database alias.
+
+    Called by SearchCacheJob after its scheduled delay. The pending hash is moved
+    atomically to a durable staging key (see _claim) and applied there, so a
+    crash before the work completes leaves the batch recoverable.
+    """
+    staging = _claim(using)
+    if staging is not None:
+        _apply_staging(using, staging, log or logger)
+
+
+def _claim(using):
+    """
+    Atomically take ownership of the pending hash for an alias.
+
+    RENAME is the claim: exactly one drainer can win it. The resulting staging
+    key is immutable (producers only ever write to the pending key), so no update
+    buffered while the batch is applied can be lost or overwritten.
+
+    Returns the staging key, or None if there was nothing pending.
+    """
+    conn = _connection()
+    staging = f'{STAGING_PREFIX}{using}:{int(time.time())}:{uuid.uuid4().hex}'
+    try:
+        conn.rename(_pending_key(using), staging)
+    except ResponseError:
+        return None
+    # Release the throttle once the batch is claimed, so updates arriving during
+    # the (potentially slow) apply can schedule a fresh drain rather than being
+    # stranded until the safety net runs.
+    conn.delete(_lock_key(using))
+    return staging
+
+
+def _apply_staging(using, staging, log):
+    """
+    Apply a claimed staging batch, deleting it only once application succeeds.
+
+    The per-batch Redis lease is only a cheap pre-filter: it is bounded by
+    APPLY_LEASE while the applier can run for up to the job timeout, so the lease
+    alone cannot guarantee exclusive execution (the safety net can take a fresh
+    lease and start applying the same batch while the original is still running).
+    Actual exclusion is provided by a PostgreSQL session-level advisory lock held
+    for the whole apply-and-cleanup lifecycle. `_apply_deferred_updates` is
+    idempotent, so any retry after the lock is released converges.
+
+    The lease is never deleted by its owner (it only expires), so a slow or
+    crashed drainer can never delete a lease a successor has taken over.
+    """
+    conn = _connection()
+    batch_id = staging.rsplit(':', 1)[1]
+
+    if not conn.set(_lease_key(batch_id), '1', nx=True, ex=APPLY_LEASE):
+        # Another drainer holds the lease; let it finish.
+        return
+
+    data = conn.hgetall(staging)
+    if not data:
+        conn.delete(staging)
+        return
+
+    _warn_if_stale(staging, log)
+
+    cache_groups = {}
+    remove_groups = {}
+    for raw_field, raw_op in data.items():
+        field = raw_field.decode() if isinstance(raw_field, bytes) else raw_field
+        op = raw_op.decode() if isinstance(raw_op, bytes) else raw_op
+        object_type_id, pk = field.split(':', 1)
+        groups = remove_groups if op == OP_REMOVE else cache_groups
+        groups.setdefault(int(object_type_id), []).append(int(pk))
+
+    # The advisory lock must span the apply *and* the staging deletion below,
+    # otherwise a second applier could acquire it after the apply but before the
+    # delete and apply the same batch again, leaving duplicate rows.
+    db = using or DEFAULT_DB_ALIAS
+    with advisory_lock(_apply_lock_key(db), wait=False, using=db) as acquired:
+        if not acquired:
+            # Another applier holds the advisory lock: its Redis lease may have
+            # expired while it is still running. Do not apply concurrently (that
+            # would duplicate rows). It deletes staging on success; if it is
+            # killed, PostgreSQL releases the lock when its session ends and the
+            # safety net retries the retained batch. Contention is not an apply
+            # failure, so it is not counted against MAX_APPLY_ATTEMPTS.
+            return
+
+        try:
+            search_backend._apply_deferred_updates(
+                using=using,
+                cache_groups=cache_groups,
+                remove_groups=remove_groups,
+                log=log,
+            )
+        except Exception:
+            if _record_apply_failure(conn, batch_id) >= MAX_APPLY_ATTEMPTS:
+                # Retrying a persistently failing batch forever would block
+                # indexing of unrelated objects and grow staging without bound.
+                # Drop it with an error-level log; the batch's objects stay
+                # unindexed until re-saved or `manage.py reindex` is run.
+                log.error(
+                    f"Search cache: dropping deferred batch {staging} after "
+                    f"{MAX_APPLY_ATTEMPTS} failed apply attempts; run `manage.py reindex` "
+                    f"to rebuild the search index",
+                    exc_info=True,
+                )
+                conn.delete(staging)
+                conn.delete(_attempts_key(batch_id))
+                return
+            raise
+
+        # Retained until this returns without raising: a crash or database error
+        # leaves the staging key for the safety net to retry.
+        conn.delete(staging)
+        conn.delete(_attempts_key(batch_id))
+
+    # Updates that arrived while this batch was being applied land in a fresh
+    # pending hash; schedule one more drain if none is already scheduled.
+    if conn.exists(_pending_key(using)) and conn.set(_lock_key(using), '1', nx=True, ex=LOCK_TTL):
+        _schedule(using)
+
+
+def drain_stranded(log=None):
+    """
+    Recover deferred work that no drain is scheduled to apply.
+
+    Scans for abandoned staging batches (a worker crashed or failed mid-apply)
+    and for pending hashes with no throttle lock (the drain meant to apply them
+    never ran). A no-op when there is nothing to recover, so the safety-net job
+    stays cheap.
+    """
+    log = log or logger
+    conn = _connection()
+
+    # Collect first, then apply: claiming a pending hash (RENAME) creates a new
+    # staging key, which must not be re-scanned as a stranded batch this pass.
+    targets = []
+    for raw_key in conn.scan_iter(match=f'{STAGING_PREFIX}*'):
+        staging = raw_key.decode() if isinstance(raw_key, bytes) else raw_key
+        targets.append((_alias_from_staging(staging), staging))
+
+    for raw_key in conn.scan_iter(match=f'{PENDING_PREFIX}*'):
+        pending = raw_key.decode() if isinstance(raw_key, bytes) else raw_key
+        using = pending[len(PENDING_PREFIX):]
+        if conn.exists(_lock_key(using)):
+            # A drain is already scheduled for this alias; leave it alone.
+            continue
+        staging = _claim(using)
+        if staging is not None:
+            targets.append((using, staging))
+
+    # Isolate failures: one batch that cannot be applied must not prevent the
+    # rest from being recovered. A failed batch is left staged for the next pass
+    # (its lease expires).
+    for using, staging in targets:
+        try:
+            _apply_staging(using, staging, log)
+        except Exception:
+            log.warning(f"Search cache: failed to apply deferred batch {staging}", exc_info=True)
+
+
+def _record_apply_failure(conn, batch_id):
+    """
+    Increment and return the failed-apply count for a staging batch, refreshing
+    its TTL so the counter cannot outlive the batch indefinitely.
+    """
+    key = _attempts_key(batch_id)
+    attempts = conn.incr(key)
+    conn.expire(key, ATTEMPTS_TTL)
+    return attempts
+
+
+def _apply_lock_key(db):
+    # A single advisory lock key per database/alias, shared by every background
+    # applier (the staged drain and the legacy SearchCacheJob path).
+    return f'netbox.search.apply:{db}'
+
+
+def _warn_if_stale(staging, log):
+    """
+    Log when a batch has been waiting far longer than a normal apply should take
+    (e.g. a database outage), so persistent staging growth is observable.
+    """
+    try:
+        claimed_at = int(staging.rsplit(':', 2)[1])
+    except (IndexError, ValueError):
+        return
+    age = int(time.time() - claimed_at)
+    if age > APPLY_LEASE:
+        log.warning(f"Search cache: applying a deferred batch claimed {age}s ago ({staging})")
+
+
+def _connection():
+    return get_connection(RQ_QUEUE_DEFAULT)
+
+
+def _field(object_type_id, pk):
+    return f'{object_type_id}:{pk}'
+
+
+def _pending_key(using):
+    return f'{PENDING_PREFIX}{using}'
+
+
+def _lock_key(using):
+    return f'{LOCK_PREFIX}{using}'
+
+
+def _lease_key(batch_id):
+    return f'{LEASE_PREFIX}{batch_id}'
+
+
+def _attempts_key(batch_id):
+    return f'{ATTEMPTS_PREFIX}{batch_id}'
+
+
+def _alias_from_staging(staging):
+    # 'netbox:search:draining:<using>:<epoch>:<batch_id>' -> '<using>'
+    return staging[len(STAGING_PREFIX):].rsplit(':', 2)[0]

+ 45 - 7
netbox/netbox/search/jobs.py

@@ -1,6 +1,8 @@
 import logging
 
-from netbox.jobs import JobRunner
+from django.db import DEFAULT_DB_ALIAS
+
+from netbox.jobs import JobRunner, system_job
 from netbox.search.backends import search_backend
 
 # Internal search-indexing machinery; not part of the public/plugin API.
@@ -16,9 +18,45 @@ class SearchCacheJob(JobRunner):
         name = 'Search cache update'
 
     def run(self, using=None, cache_groups=None, remove_groups=None, **kwargs):
-        search_backend._apply_deferred_updates(
-            using=using,
-            cache_groups=cache_groups,
-            remove_groups=remove_groups,
-            log=self.logger,
-        )
+        using = using or DEFAULT_DB_ALIAS
+
+        # Back-compat: a job enqueued by a pre-coalescing version carried the
+        # dirty objects directly. Apply them as-is so in-flight legacy jobs keep
+        # working across a rolling upgrade. Serialize with a concurrent new-style
+        # drain via the same advisory lock so the two can't apply the same object
+        # concurrently (which would duplicate CachedValue rows). wait=True: a
+        # legacy job carries its own one-shot batch and cannot be retried, so it
+        # must not skip.
+        if cache_groups or remove_groups:
+            from django_pg_utils import advisory_lock
+
+            from netbox.search.deferred import _apply_lock_key
+            with advisory_lock(_apply_lock_key(using), wait=True, using=using):
+                search_backend._apply_deferred_updates(
+                    using=using,
+                    cache_groups=cache_groups,
+                    remove_groups=remove_groups,
+                    log=self.logger,
+                )
+            return
+
+        # Imported here to avoid a module-level circular import: deferred.py
+        # imports this module for SearchCacheJob.
+        from netbox.search import deferred
+        deferred.drain_and_apply(using, self.logger)
+
+
+@system_job(interval=5)
+class SearchCacheSafetyNetJob(JobRunner):
+    """
+    Periodic sweep that recovers deferred search-cache batches which were never
+    applied (e.g. the worker meant to drain them died, or application failed
+    part-way through). Retained staging data is retried; the batch is deleted
+    only once it has been applied successfully.
+    """
+    class Meta:
+        name = 'Search cache flush safety net'
+
+    def run(self, *args, **kwargs):
+        from netbox.search import deferred
+        deferred.drain_stranded(self.logger)

+ 2 - 1
netbox/netbox/tests/dummy_plugin/models.py

@@ -1,6 +1,7 @@
 from django.db import models
 
 from netbox.models import NetBoxModel
+from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from utilities.querysets import RestrictedQuerySet
 
 
@@ -32,5 +33,5 @@ class DummySiteAttachment(models.Model):
         ordering = ['name']
 
 
-class DummyNetBoxModel(NetBoxModel):
+class DummyNetBoxModel(ContactsMixin, ImageAttachmentsMixin, NetBoxModel):
     pass

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

@@ -619,6 +619,92 @@ class GraphQLAPITestCase(APITestCase):
             msg=f'Expected batched tag prefetch, got {tag_queries} tag queries for 10 devices',
         )
 
+    def test_graphql_device_list_tags_pagination(self):
+        """
+        Pagination is applied independently to a prefetched list field (tags) for each object, without
+        resorting to a query per object.
+        """
+        self.add_permissions('dcim.view_device', 'extras.view_tag')
+
+        manufacturer = Manufacturer.objects.create(name='Pagination Manufacturer', slug='pagination-manufacturer')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer,
+            model='Pagination Model',
+            slug='pagination-model',
+        )
+        device_role = DeviceRole.objects.create(name='Pagination Role', slug='pagination-role')
+        site = Site.objects.first()
+        devices = Device.objects.bulk_create([
+            Device(name=f'Pagination Device {index}', device_type=device_type, role=device_role, site=site)
+            for index in range(5)
+        ])
+        tags = [
+            Tag.objects.create(name=f'Pagination Tag {index}', slug=f'pagination-tag-{index}')
+            for index in range(8)
+        ]
+        devices[0].tags.set(tags[:5])
+        devices[1].tags.set(tags[3:])
+        url = reverse('graphql')
+
+        def get_device_tags(tags_field):
+            query = """
+            {
+                device_list(filters: {role: {slug: {exact: "pagination-role"}}}) {
+                    name
+                    TAGS_FIELD {
+                        slug
+                    }
+                }
+            }
+            """.replace('TAGS_FIELD', tags_field)
+            with CaptureQueriesContext(connection) as context:
+                response = self.client.post(url, data={'query': query}, format='json', **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            data = json.loads(response.content)
+            self.assertNotIn('errors', data)
+            tag_queries = sum(1 for record in context.captured_queries if 'extras_tag' in record['sql'])
+            self.assertLessEqual(tag_queries, 2, msg=f'Expected batched tag prefetch, got {tag_queries} tag queries')
+            return {
+                device['name']: [tag['slug'] for tag in device['tags']]
+                for device in data['data']['device_list']
+                # The remaining devices have no tags, and exist only to detect a tag query per device
+                if device['name'] in ('Pagination Device 0', 'Pagination Device 1')
+            }
+
+        # Explicit pagination
+        self.assertEqual(get_device_tags('tags(pagination: {offset: 1, limit: 2})'), {
+            'Pagination Device 0': ['pagination-tag-1', 'pagination-tag-2'],
+            'Pagination Device 1': ['pagination-tag-4', 'pagination-tag-5'],
+        })
+
+        # No pagination: all tags are returned
+        self.assertEqual(get_device_tags('tags'), {
+            'Pagination Device 0': [tag.slug for tag in tags[:5]],
+            'Pagination Device 1': [tag.slug for tag in tags[3:]],
+        })
+
+        # MAX_PAGE_SIZE is enforced when no pagination is specified
+        with override_settings(MAX_PAGE_SIZE=3):
+            self.assertEqual(get_device_tags('tags'), {
+                'Pagination Device 0': [tag.slug for tag in tags[:3]],
+                'Pagination Device 1': [tag.slug for tag in tags[3:6]],
+            })
+
+        # A negative offset is rejected
+        query = """
+        {
+            device_list(filters: {role: {slug: {exact: "pagination-role"}}}) {
+                tags(pagination: {offset: -2}) {
+                    slug
+                }
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        data = json.loads(response.content)
+        self.assertIn('errors', data)
+        self.assertIn('Negative indexing is not supported', data['errors'][0]['message'])
+
     def test_graphql_ip_address_list_assigned_object(self):
         """
         Requesting assigned_object should batch prefetch related objects.

+ 147 - 0
netbox/netbox/tests/test_jobs.py

@@ -5,6 +5,7 @@ from unittest.mock import patch
 from django.test import RequestFactory, TestCase
 from django.urls import reverse
 from django.utils import timezone
+from django_rq import get_queue
 
 from core.choices import JobStatusChoices
 from core.exceptions import JobFailed
@@ -20,6 +21,11 @@ from ..jobs import *
 from ..jobs import _INSTALL_ROOT
 
 
+def _noop():
+    """A no-op callable used to register a real RQ job in tests."""
+    pass
+
+
 class TestJobRunner(JobRunner):
 
     def run(self, *args, **kwargs):
@@ -157,6 +163,147 @@ class EnqueueTestCase(BaseJobRunnerTestCase):
         self.assertRaises(Job.DoesNotExist, job1.refresh_from_db)
         self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1)
 
+    def test_enqueue_once_replaces_stale_scheduled_system_job(self):
+        """
+        A system job scheduled in the past whose RQ-side entry is gone will never fire and must
+        be replaced (see #22714). The bare Job row below has no RQ entry, mirroring the repro.
+        """
+        stale = Job.objects.create(
+            name=TestSystemJobRunner.name,
+            status=JobStatusChoices.STATUS_SCHEDULED,
+            interval=60,
+            scheduled=timezone.now() - timedelta(days=1),
+            job_id=uuid.uuid4(),
+        )
+
+        # Mirrors how rqworker.py calls enqueue_once() for system jobs at startup: no
+        # schedule_at, only the registered interval.
+        job = TestSystemJobRunner.enqueue_once(interval=60)
+
+        self.assertNotEqual(job, stale)
+        self.assertRaises(Job.DoesNotExist, stale.refresh_from_db)
+        self.assertEqual(TestSystemJobRunner.get_jobs().count(), 1)
+
+    def test_enqueue_once_reuses_scheduled_system_job_still_in_rq(self):
+        """
+        An overdue system job whose RQ entry still exists is merely waiting its turn, not stale,
+        so it must be reused rather than replaced.
+        """
+        scheduled = Job.objects.create(
+            name=TestSystemJobRunner.name,
+            status=JobStatusChoices.STATUS_SCHEDULED,
+            interval=60,
+            scheduled=timezone.now() - timedelta(days=1),
+            job_id=uuid.uuid4(),
+            queue_name='default',
+        )
+        # Register a real RQ-side scheduled entry for this job_id.
+        get_queue('default').enqueue_at(
+            timezone.now() + timedelta(days=1), _noop, job_id=str(scheduled.job_id)
+        )
+
+        job = TestSystemJobRunner.enqueue_once(interval=60)
+
+        self.assertEqual(job, scheduled)
+        self.assertEqual(TestSystemJobRunner.get_jobs().count(), 1)
+
+    def test_enqueue_once_reuses_queued_system_job(self):
+        """
+        Once a due job is moved from the scheduled registry into its queue, it is still live and
+        must be reused, not replaced, even though it is no longer in the scheduled registry.
+        """
+        queued = Job.objects.create(
+            name=TestSystemJobRunner.name,
+            status=JobStatusChoices.STATUS_SCHEDULED,
+            interval=60,
+            scheduled=timezone.now() - timedelta(days=1),
+            job_id=uuid.uuid4(),
+            queue_name='default',
+        )
+        # Enqueue directly onto the queue (not the scheduled registry), as RQ does once due.
+        get_queue('default').enqueue(_noop, job_id=str(queued.job_id))
+
+        job = TestSystemJobRunner.enqueue_once(interval=60)
+
+        self.assertEqual(job, queued)
+        self.assertEqual(TestSystemJobRunner.get_jobs().count(), 1)
+
+    def test_enqueue_once_reuses_future_scheduled_system_job(self):
+        """
+        A system job scheduled for a future time has not come due, so it is not stale and must
+        be reused regardless of its RQ state.
+        """
+        scheduled = Job.objects.create(
+            name=TestSystemJobRunner.name,
+            status=JobStatusChoices.STATUS_SCHEDULED,
+            interval=60,
+            scheduled=timezone.now() + timedelta(days=1),
+            job_id=uuid.uuid4(),
+        )
+
+        job = TestSystemJobRunner.enqueue_once(interval=60)
+
+        self.assertEqual(job, scheduled)
+        self.assertEqual(TestSystemJobRunner.get_jobs().count(), 1)
+
+    def test_enqueue_once_reuses_stale_scheduled_non_system_job(self):
+        """
+        Staleness replacement is limited to system jobs. A past-due non-system scheduled job
+        (e.g. a DataSource sync) may legitimately await its turn and must be reused.
+        """
+        scheduled = Job.objects.create(
+            name=TestJobRunner.name,
+            status=JobStatusChoices.STATUS_SCHEDULED,
+            interval=60,
+            scheduled=timezone.now() - timedelta(days=1),
+            job_id=uuid.uuid4(),
+        )
+
+        job = TestJobRunner.enqueue_once(interval=60)
+
+        self.assertEqual(job, scheduled)
+        self.assertEqual(TestJobRunner.get_jobs().count(), 1)
+
+    def test_enqueue_once_reuses_pending_job_with_no_schedule(self):
+        """
+        A pending job (not yet picked up by a worker) has no `scheduled` timestamp at all.
+        That must not be mistaken for a stale schedule and must not raise when compared
+        against the current time.
+        """
+        pending = Job.objects.create(
+            name=TestJobRunner.name,
+            status=JobStatusChoices.STATUS_PENDING,
+            interval=60,
+            scheduled=None,
+            job_id=uuid.uuid4(),
+        )
+
+        job = TestJobRunner.enqueue_once(interval=60)
+
+        self.assertEqual(job, pending)
+        self.assertEqual(TestJobRunner.get_jobs().count(), 1)
+
+    def test_enqueue_once_reuses_running_job_with_past_schedule(self):
+        """
+        Once a job starts, its `scheduled` timestamp is left in the past (that's normal —
+        `start()` doesn't clear it) while status moves to "running". A concurrent
+        enqueue_once() call (e.g. a second worker starting up mid-run) must not mistake
+        that for staleness and delete an in-flight job out from under itself.
+        """
+        running = Job.objects.create(
+            name=TestJobRunner.name,
+            status=JobStatusChoices.STATUS_RUNNING,
+            interval=60,
+            scheduled=timezone.now() - timedelta(minutes=5),
+            started=timezone.now(),
+            job_id=uuid.uuid4(),
+        )
+
+        job = TestJobRunner.enqueue_once(interval=60)
+
+        self.assertEqual(job, running)
+        self.assertEqual(TestJobRunner.get_jobs().count(), 1)
+
     def test_enqueue_once_with_enqueue(self):
         instance = DataSource()
         job1 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at(2))

+ 336 - 13
netbox/netbox/tests/test_search.py

@@ -1,5 +1,10 @@
+import os
+import subprocess
+import sys
+import time
 from unittest import mock
 
+from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.db import DEFAULT_DB_ALIAS, ProgrammingError, connection, transaction
 from django.db.models.signals import post_delete, post_save
@@ -12,6 +17,7 @@ from extras.models import CachedValue
 from netbox.search import deferred
 from netbox.search.backends import SearchBackend, search_backend
 from netbox.search.jobs import SearchCacheJob
+from utilities.testing.mixins import RQQueueTestMixin
 
 
 def scheduled_search_flushes():
@@ -187,7 +193,7 @@ class SearchBackendTestCase(TestCase):
         self.assertEqual(len(results), 0)
 
 
-class DeferredCachingTestCase(TestCase):
+class DeferredCachingTestCase(RQQueueTestMixin, TestCase):
     """
     Tests for the deferred (post-commit) search caching machinery in
     netbox.search.deferred.
@@ -196,6 +202,11 @@ class DeferredCachingTestCase(TestCase):
     commit, so these tests assert on real CachedValue state and on the real
     per-transaction buffer (connection.run_on_commit) rather than mocking the
     queue.
+
+    Some tests patch the worker probe True and write to the real tasks Redis
+    (DB 0). RQQueueTestMixin flushes that database around each test and
+    serializes this class against the other Redis-backed suites, so a parallel
+    worker's flush can't wipe the coalescing keys mid-test.
     """
 
     def _scheduled_flush_aliases(self):
@@ -372,9 +383,11 @@ class DeferredCachingTestCase(TestCase):
 
     def test_flush_enqueues_job_when_worker_available(self):
         """
-        When an RQ worker is available, the flush enqueues a SearchCacheJob
-        carrying the dirty objects (and the originating database alias) rather
-        than indexing inline.
+        When an RQ worker is available, a committed batch is merged into the
+        shared Redis buffer and a single drain is scheduled, rather than indexing
+        inline. The originating database alias is carried on the scheduled job so
+        the deferred write targets the originating schema (e.g. a branch schema
+        under netbox-branching).
         """
         site_ct = ContentType.objects.get_for_model(Site)
 
@@ -386,15 +399,10 @@ class DeferredCachingTestCase(TestCase):
                     site = Site.objects.create(name='Enqueued', slug='enqueued')
 
         enqueue.assert_called_once()
-        kwargs = enqueue.call_args.kwargs
-        self.assertEqual(kwargs['cache_groups'], {site_ct.pk: [site.pk]})
-        self.assertEqual(kwargs['remove_groups'], {})
-        # The database alias captured from the post_save signal is forwarded to
-        # the job verbatim. This is what lets a deferred write target the
-        # originating schema (e.g. a branch schema under netbox-branching) even
-        # though the worker has no active routing context; cross-schema routing
-        # itself is covered by the netbox-branching test suite.
-        self.assertEqual(kwargs['using'], DEFAULT_DB_ALIAS)
+        self.assertEqual(enqueue.call_args.kwargs['using'], DEFAULT_DB_ALIAS)
+        # The object was buffered for a later drain rather than enqueued inline.
+        buffered = deferred._connection().hgetall(deferred._pending_key(DEFAULT_DB_ALIAS))
+        self.assertIn(f'{site_ct.pk}:{site.pk}', {k.decode() for k in buffered})
 
     def test_flush_falls_back_inline_when_broker_unreachable(self):
         """
@@ -452,6 +460,10 @@ class DeferredCachingTestCase(TestCase):
             CachedValue.objects.filter(object_type=content_type, object_id=site.pk).count(),
             len(SiteIndex.fields)
         )
+        # The scheduling lock is released so recovery can proceed promptly.
+        self.assertFalse(
+            deferred._connection().exists(deferred._lock_key(DEFAULT_DB_ALIAS))
+        )
 
     def test_cache_update_skips_deleted_object(self):
         """
@@ -569,6 +581,317 @@ class DeferredCachingTestCase(TestCase):
         self.assertIn(column, str(ctx.exception))
 
 
+class SearchCacheCoalescingTestCase(RQQueueTestMixin, TestCase):
+    """
+    Tests for the cross-transaction coalescing buffer in netbox.search.deferred.
+
+    These use the real tasks Redis connection (DB 0), so the RENAME/lock/lease
+    primitives are exercised for real rather than mocked. RQQueueTestMixin
+    flushes that database around each test and serializes this class against the
+    other Redis-backed suites; otherwise a parallel worker's flush would wipe the
+    coalescing keys mid-test.
+    """
+
+    @staticmethod
+    def _create_site(slug='coalesce'):
+        # Create the object, then discard any inline-indexed rows so tests start
+        # from a known-empty cache for it. Populate every indexed field so the
+        # expected CachedValue count is len(SiteIndex.fields).
+        site = Site.objects.create(
+            name=slug, slug=slug, facility='Alpha', description='desc',
+            physical_address='1 Test Way', shipping_address='1 Test Way',
+            comments='Lorem ipsum',
+        )
+        ct = ContentType.objects.get_for_model(Site)
+        CachedValue.objects.filter(object_type=ct, object_id=site.pk).delete()
+        return ct, site.pk
+
+    def _buffer(self, batch):
+        with mock.patch('netbox.search.deferred.any_workers_for_queue', return_value=True):
+            with mock.patch.object(SearchCacheJob, 'enqueue'):
+                deferred._flush(batch, DEFAULT_DB_ALIAS)
+
+    def test_many_committed_transactions_coalesce_into_one_job(self):
+        """
+        Regression guard for #23259: many separate committed transactions must
+        coalesce into a single scheduled job, not one job per transaction.
+        """
+        ct, pk = self._create_site()
+
+        with mock.patch('netbox.search.deferred.any_workers_for_queue', return_value=True):
+            with mock.patch.object(SearchCacheJob, 'enqueue') as enqueue:
+                for i in range(25):
+                    deferred._flush({(ct.pk, pk + i): deferred.OP_CACHE}, DEFAULT_DB_ALIAS)
+
+        enqueue.assert_called_once()
+        buffered = deferred._connection().hgetall(deferred._pending_key(DEFAULT_DB_ALIAS))
+        self.assertEqual(len(buffered), 25)
+
+    def test_later_op_wins_in_buffer(self):
+        ct, pk = self._create_site()
+        self._buffer({(ct.pk, pk): deferred.OP_CACHE})
+        self._buffer({(ct.pk, pk): deferred.OP_REMOVE})
+        buffered = deferred._connection().hgetall(deferred._pending_key(DEFAULT_DB_ALIAS))
+        self.assertEqual(buffered[f'{ct.pk}:{pk}'.encode()], b'remove')
+
+    def test_drain_applies_and_clears_buffer(self):
+        ct, pk = self._create_site()
+        self._buffer({(ct.pk, pk): deferred.OP_CACHE})
+        deferred.drain_and_apply(DEFAULT_DB_ALIAS)
+        self.assertEqual(
+            CachedValue.objects.filter(object_type=ct, object_id=pk).count(),
+            len(SiteIndex.fields),
+        )
+        conn = deferred._connection()
+        self.assertFalse(conn.exists(deferred._pending_key(DEFAULT_DB_ALIAS)))
+        self.assertEqual(list(conn.scan_iter(match=f'{deferred.STAGING_PREFIX}*')), [])
+
+    def test_drain_removes_and_clears(self):
+        ct, pk = self._create_site()
+        search_backend.cache(Site.objects.get(pk=pk))
+        self.assertTrue(CachedValue.objects.filter(object_type=ct, object_id=pk).exists())
+        self._buffer({(ct.pk, pk): deferred.OP_REMOVE})
+        deferred.drain_and_apply(DEFAULT_DB_ALIAS)
+        self.assertFalse(CachedValue.objects.filter(object_type=ct, object_id=pk).exists())
+
+    def test_second_claim_returns_none(self):
+        ct, pk = self._create_site()
+        conn = deferred._connection()
+        conn.hset(deferred._pending_key(DEFAULT_DB_ALIAS), mapping={f'{ct.pk}:{pk}': 'cache'})
+        self.assertIsNotNone(deferred._claim(DEFAULT_DB_ALIAS))
+        self.assertIsNone(deferred._claim(DEFAULT_DB_ALIAS))
+
+    def test_update_arriving_during_drain_is_rescheduled(self):
+        ct, pk = self._create_site()
+        conn = deferred._connection()
+        conn.hset(deferred._pending_key(DEFAULT_DB_ALIAS), mapping={f'{ct.pk}:{pk}': 'cache'})
+
+        def apply_and_arrive(*args, **kwargs):
+            # Simulate a commit landing while the claimed batch is being applied.
+            conn.hset(deferred._pending_key(DEFAULT_DB_ALIAS), mapping={f'{ct.pk}:{pk}': 'remove'})
+
+        with mock.patch.object(search_backend, '_apply_deferred_updates', side_effect=apply_and_arrive):
+            with mock.patch.object(SearchCacheJob, 'enqueue') as enqueue:
+                deferred.drain_and_apply(DEFAULT_DB_ALIAS)
+
+        enqueue.assert_called_once()
+        self.assertTrue(conn.exists(deferred._pending_key(DEFAULT_DB_ALIAS)))
+
+    def test_stranded_staging_is_recovered(self):
+        ct, pk = self._create_site()
+        conn = deferred._connection()
+        staging = f'{deferred.STAGING_PREFIX}{DEFAULT_DB_ALIAS}:{int(time.time())}:deadbeef'
+        conn.hset(staging, mapping={f'{ct.pk}:{pk}': 'cache'})
+
+        deferred.drain_stranded()
+
+        self.assertEqual(
+            CachedValue.objects.filter(object_type=ct, object_id=pk).count(),
+            len(SiteIndex.fields),
+        )
+        self.assertFalse(conn.exists(staging))
+
+    def test_failed_apply_retains_staging_and_retry_converges(self):
+        ct, pk = self._create_site()
+        conn = deferred._connection()
+        staging = f'{deferred.STAGING_PREFIX}{DEFAULT_DB_ALIAS}:{int(time.time())}:cafe'
+        conn.hset(staging, mapping={f'{ct.pk}:{pk}': 'cache'})
+
+        with mock.patch.object(
+            search_backend, '_apply_deferred_updates', side_effect=ProgrammingError('boom')
+        ):
+            deferred.drain_stranded()
+
+        # The only copy is retained on failure, never discarded.
+        self.assertTrue(conn.exists(staging))
+
+        # Simulate lease expiry, then retry for real: the retry converges.
+        for raw_key in conn.scan_iter(match=f'{deferred.LEASE_PREFIX}*'):
+            conn.delete(raw_key)
+        deferred.drain_stranded()
+
+        self.assertEqual(
+            CachedValue.objects.filter(object_type=ct, object_id=pk).count(),
+            len(SiteIndex.fields),
+        )
+        self.assertFalse(conn.exists(staging))
+
+    def test_advisory_lock_contention_skips_apply(self):
+        """
+        If another applier holds the advisory lock (its Redis lease may have
+        expired while it is still running), a second applier must not apply the
+        same batch concurrently -- that is the #23259 duplicate-row path.
+        """
+        ct, pk = self._create_site()
+        conn = deferred._connection()
+        staging = f'{deferred.STAGING_PREFIX}{DEFAULT_DB_ALIAS}:{int(time.time())}:1ease'
+        conn.hset(staging, mapping={f'{ct.pk}:{pk}': 'cache'})
+        batch_id = staging.rsplit(':', 1)[1]
+
+        with mock.patch('netbox.search.deferred.advisory_lock') as advisory:
+            advisory.return_value.__enter__.return_value = False
+            with mock.patch.object(search_backend, '_apply_deferred_updates') as apply:
+                deferred._apply_staging(DEFAULT_DB_ALIAS, staging, deferred.logger)
+                apply.assert_not_called()
+
+        # The batch is retained for the current lock holder (or the safety net),
+        # and contention is not counted as an apply failure.
+        self.assertTrue(conn.exists(staging))
+        self.assertFalse(conn.exists(deferred._attempts_key(batch_id)))
+
+    def test_repeated_apply_failure_is_capped_and_batch_dropped(self):
+        """
+        A batch that keeps failing is retained below the cap, then dropped at the
+        cap with an error-level log instead of blocking indexing of unrelated
+        objects forever.
+        """
+        ct, pk = self._create_site()
+        conn = deferred._connection()
+        staging = f'{deferred.STAGING_PREFIX}{DEFAULT_DB_ALIAS}:{int(time.time())}:cafe2'
+        conn.hset(staging, mapping={f'{ct.pk}:{pk}': 'cache'})
+        batch_id = staging.rsplit(':', 1)[1]
+
+        with mock.patch.object(
+            search_backend, '_apply_deferred_updates', side_effect=ProgrammingError('boom')
+        ):
+            with self.assertLogs('netbox.search.deferred', level='ERROR') as logs:
+                for _ in range(deferred.MAX_APPLY_ATTEMPTS - 1):
+                    # Simulate the lease expiring between safety-net passes.
+                    conn.delete(deferred._lease_key(batch_id))
+                    with self.assertRaises(ProgrammingError):
+                        deferred._apply_staging(DEFAULT_DB_ALIAS, staging, deferred.logger)
+                    self.assertTrue(conn.exists(staging))
+
+                conn.delete(deferred._lease_key(batch_id))
+                deferred._apply_staging(DEFAULT_DB_ALIAS, staging, deferred.logger)
+
+        self.assertFalse(conn.exists(staging))
+        self.assertFalse(conn.exists(deferred._attempts_key(batch_id)))
+        self.assertTrue(any('dropping deferred batch' in m for m in logs.output))
+
+    def test_successful_apply_clears_failure_counter(self):
+        ct, pk = self._create_site()
+        conn = deferred._connection()
+        staging = f'{deferred.STAGING_PREFIX}{DEFAULT_DB_ALIAS}:{int(time.time())}:f00d'
+        conn.hset(staging, mapping={f'{ct.pk}:{pk}': 'cache'})
+        batch_id = staging.rsplit(':', 1)[1]
+        conn.set(deferred._attempts_key(batch_id), 3)
+
+        deferred._apply_staging(DEFAULT_DB_ALIAS, staging, deferred.logger)
+
+        self.assertFalse(conn.exists(staging))
+        self.assertFalse(conn.exists(deferred._attempts_key(batch_id)))
+
+    def test_lease_prevents_concurrent_apply(self):
+        ct, pk = self._create_site()
+        conn = deferred._connection()
+        staging = f'{deferred.STAGING_PREFIX}{DEFAULT_DB_ALIAS}:{int(time.time())}:feed'
+        conn.hset(staging, mapping={f'{ct.pk}:{pk}': 'cache'})
+        conn.set(deferred._lease_key('feed'), '1', nx=True, ex=60)
+
+        with mock.patch.object(search_backend, '_apply_deferred_updates') as apply:
+            deferred._apply_staging(DEFAULT_DB_ALIAS, staging, deferred.logger)
+            apply.assert_not_called()
+
+        self.assertTrue(conn.exists(staging))
+
+    def test_expired_lease_allows_takeover(self):
+        ct, pk = self._create_site()
+        conn = deferred._connection()
+        staging = f'{deferred.STAGING_PREFIX}{DEFAULT_DB_ALIAS}:{int(time.time())}:beef'
+        conn.hset(staging, mapping={f'{ct.pk}:{pk}': 'cache'})
+        # A lease is set then expires before the batch is applied.
+        conn.set(deferred._lease_key('beef'), '1', nx=True, ex=60)
+        conn.delete(deferred._lease_key('beef'))
+
+        deferred._apply_staging(DEFAULT_DB_ALIAS, staging, deferred.logger)
+
+        self.assertEqual(
+            CachedValue.objects.filter(object_type=ct, object_id=pk).count(),
+            len(SiteIndex.fields),
+        )
+        self.assertFalse(conn.exists(staging))
+
+    def test_draining_one_batch_does_not_delete_newer_pending(self):
+        ct, pk = self._create_site()
+        conn = deferred._connection()
+        staging = f'{deferred.STAGING_PREFIX}{DEFAULT_DB_ALIAS}:{int(time.time())}:abcd'
+        conn.hset(staging, mapping={f'{ct.pk}:{pk}': 'cache'})
+        # A newer commit arrives after the batch was claimed.
+        conn.hset(deferred._pending_key(DEFAULT_DB_ALIAS), mapping={f'{ct.pk}:{pk + 1}': 'remove'})
+
+        with mock.patch.object(SearchCacheJob, 'enqueue'):
+            deferred._apply_staging(DEFAULT_DB_ALIAS, staging, deferred.logger)
+
+        buffered = conn.hgetall(deferred._pending_key(DEFAULT_DB_ALIAS))
+        self.assertIn(f'{ct.pk}:{pk + 1}'.encode(), buffered)
+        self.assertFalse(conn.exists(staging))
+
+    def test_safety_net_skips_alias_with_scheduled_drain(self):
+        ct, pk = self._create_site()
+        conn = deferred._connection()
+        conn.hset(deferred._pending_key(DEFAULT_DB_ALIAS), mapping={f'{ct.pk}:{pk}': 'cache'})
+        conn.set(deferred._lock_key(DEFAULT_DB_ALIAS), '1', nx=True, ex=60)
+
+        with mock.patch.object(search_backend, '_apply_deferred_updates') as apply:
+            deferred.drain_stranded()
+            apply.assert_not_called()
+
+        self.assertTrue(conn.exists(deferred._pending_key(DEFAULT_DB_ALIAS)))
+
+    def test_safety_net_noop_when_empty(self):
+        with mock.patch.object(search_backend, '_apply_deferred_updates') as apply:
+            deferred.drain_stranded()
+            apply.assert_not_called()
+
+    def test_legacy_job_arguments_still_apply(self):
+        """
+        A SearchCacheJob enqueued by a pre-coalescing version carries cache_groups
+        / remove_groups directly; it must still be applied (rolling upgrade).
+        """
+        ct, pk = self._create_site()
+        SearchCacheJob.run(
+            mock.Mock(),
+            using=DEFAULT_DB_ALIAS,
+            cache_groups={ct.pk: [pk]},
+            remove_groups={},
+        )
+        self.assertEqual(
+            CachedValue.objects.filter(object_type=ct, object_id=pk).count(),
+            len(SiteIndex.fields),
+        )
+
+    def test_safety_net_job_registered_by_app_ready(self):
+        """
+        The safety-net job must be registered by CoreConfig.ready() importing
+        netbox.search.jobs -- not merely by this test module's own import of that
+        module, which would make an in-process assertion vacuous. Boot Django in
+        a clean interpreter that never imports netbox.search.jobs explicitly and
+        inspect the registry there.
+        """
+        script = (
+            "import django; django.setup();"
+            "from netbox.registry import registry;"
+            "jobs = [c for c in registry['system_jobs']"
+            " if getattr(c, 'name', None) == 'Search cache flush safety net'];"
+            "assert len(jobs) == 1, jobs;"
+            "assert registry['system_jobs'][jobs[0]]['interval'] == 5, jobs;"
+            "print('REGISTERED')"
+        )
+        env = os.environ.copy()
+        env.setdefault('DJANGO_SETTINGS_MODULE', 'netbox.settings')
+        env.setdefault('NETBOX_CONFIGURATION', 'netbox.configuration_testing')
+        result = subprocess.run(
+            [sys.executable, '-c', script],
+            cwd=str(settings.BASE_DIR),
+            env=env,
+            capture_output=True,
+            text=True,
+        )
+        self.assertEqual(result.returncode, 0, result.stderr)
+        self.assertIn('REGISTERED', result.stdout)
+
+
 class AutocommitCachingTestCase(TransactionTestCase):
     """
     Tests for the synchronous (autocommit) indexing path. Uses TransactionTestCase

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

@@ -578,6 +578,64 @@ class AddressAttrTestCase(TestCase):
         context = attr.get_context(obj, 'address', '1 Main St', {})
         self.assertIsNone(context['map_url'])
 
+    def test_copy_button_disabled_by_default(self):
+        attr = attrs.AddressAttr('address')
+        obj = SimpleNamespace(address='1 Main St')
+        context = attr.get_context(obj, 'address', '1 Main St', {})
+        self.assertFalse(context['copy_button'])
+
+    def test_get_context_copy_button(self):
+        attr = attrs.AddressAttr('address', copy_button=True)
+        obj = SimpleNamespace(address='1 Main St')
+        context = attr.get_context(obj, 'address', '1 Main St', {})
+        self.assertTrue(context['copy_button'])
+
+    def test_render_with_copy_button(self):
+        attr = attrs.AddressAttr('address', map_url=False, copy_button=True)
+        obj = SimpleNamespace(address='1 Main St\nSuite 100')
+        rendered = attr.render(obj, {'name': 'address'})
+        self.assertIn('copy-content', rendered)
+        # The raw value (line breaks included) is copied, rather than a selection of the rendered HTML
+        self.assertIn('data-clipboard-text="1 Main St\nSuite 100"', rendered)
+        self.assertNotIn('data-clipboard-target', rendered)
+
+    def test_render_copy_button_hidden_when_printing(self):
+        attr = attrs.AddressAttr('address', map_url=False, copy_button=True)
+        obj = SimpleNamespace(address='1 Main St')
+        rendered = attr.render(obj, {'name': 'address'})
+        self.assertIn('d-print-none', rendered)
+
+    def test_render_copy_button_escapes_value(self):
+        attr = attrs.AddressAttr('address', map_url=False, copy_button=True)
+        obj = SimpleNamespace(address='O\'Brien & Sons "HQ" <Suite 5>')
+        rendered = attr.render(obj, {'name': 'address'})
+        self.assertIn(
+            'data-clipboard-text="O&#x27;Brien &amp; Sons &quot;HQ&quot; &lt;Suite 5&gt;"',
+            rendered,
+        )
+        self.assertNotIn('<Suite 5>', rendered)
+
+    def test_render_without_copy_button(self):
+        attr = attrs.AddressAttr('address', map_url=False)
+        obj = SimpleNamespace(address='1 Main St\nSuite 100')
+        rendered = attr.render(obj, {'name': 'address'})
+        self.assertNotIn('copy-content', rendered)
+        self.assertNotIn('data-clipboard-text', rendered)
+
+    def test_render_map_link_without_copy_button(self):
+        attr = attrs.AddressAttr('address', map_url='https://maps.google.com/?q=')
+        obj = SimpleNamespace(address='1 Main St')
+        rendered = attr.render(obj, {'name': 'address'})
+        self.assertIn('href="https://maps.google.com/?q=1 Main St"', rendered)
+        self.assertNotIn('copy-content', rendered)
+
+    def test_render_map_link_and_copy_button(self):
+        attr = attrs.AddressAttr('address', map_url='https://maps.google.com/?q=', copy_button=True)
+        obj = SimpleNamespace(address='1 Main St')
+        rendered = attr.render(obj, {'name': 'address'})
+        self.assertIn('href="https://maps.google.com/?q=1 Main St"', rendered)
+        self.assertIn('data-clipboard-text="1 Main St"', rendered)
+
 
 class DateTimeAttrTestCase(TestCase):
 

+ 4 - 1
netbox/netbox/ui/attrs.py

@@ -488,12 +488,14 @@ class AddressAttr(MapURLMixin, ObjectAttribute):
     Parameters:
          map_url (bool/str): The URL to use when rendering the address. If True, the address will render as a
               hyperlink using settings.MAPS_URL.
+         copy_button (bool): Set to True to include a copy-to-clipboard button
     """
     template_name = 'ui/attrs/address.html'
 
-    def __init__(self, *args, map_url=True, **kwargs):
+    def __init__(self, *args, map_url=True, copy_button=False, **kwargs):
         super().__init__(*args, **kwargs)
         self._map_url = map_url
+        self.copy_button = copy_button
 
     def get_context(self, obj, attr, value, context):
         map_url = self.map_url
@@ -502,6 +504,7 @@ class AddressAttr(MapURLMixin, ObjectAttribute):
             map_url = None
         return {
             'map_url': map_url,
+            'copy_button': self.copy_button,
         }
 
 

+ 2 - 2
netbox/netbox/views/generic/feature_views.py

@@ -202,8 +202,8 @@ class ObjectJobsView(ConditionalLoginRequiredMixin, View):
         self.model = model
         obj = self.get_object(request, **kwargs)
 
-        # Gather all Jobs for this object
-        jobs = self.get_jobs(obj)
+        # Gather all Jobs for this object which the user is permitted to view
+        jobs = self.get_jobs(obj).restrict(request.user, 'view')
         jobs_table = JobTable(data=jobs, orderable=False)
         jobs_table.configure(request)
 

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
netbox/project-static/dist/netbox.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 1 - 1
netbox/project-static/src/util.ts

@@ -92,7 +92,7 @@ export function resetSelect<S extends HTMLSelectElement>(select: S): void {
  * Type guard to determine if a value is an `Element`.
  */
 export function isElement(obj: Element | null | undefined): obj is Element {
-  return typeof obj !== null && typeof obj !== 'undefined';
+  return obj !== null && obj !== undefined;
 }
 
 export async function apiRequest<R extends Dict, D extends ReqData = undefined>(

+ 1 - 1
netbox/templates/extras/inc/script_list_content.html

@@ -39,7 +39,7 @@
           <tbody>
             {% for script in scripts %}
               {% if script in available_scripts %}
-                {% with last_job=script.get_latest_jobs|first %}
+                {% with last_job=latest_jobs|get_key:script.pk %}
                   <tr>
                     <td>
                       {% if script.is_executable %}

+ 13 - 4
netbox/templates/ui/attrs/address.html

@@ -1,8 +1,17 @@
 {% load i18n %}
 {% load l10n %}
 <span>{{ value|linebreaksbr }}</span>
-{% if map_url %}
-  <a href="{{ map_url }}{{ value }}" target="_blank" class="btn btn-primary btn-sm print-none">
-    <i class="mdi mdi-map-marker"></i> {% trans "Map" %}
-  </a>
+{% if map_url or copy_button %}
+  <div class="d-flex gap-1">
+    {% if map_url %}
+      <a href="{{ map_url }}{{ value }}" target="_blank" class="btn btn-primary btn-sm print-none">
+        <i class="mdi mdi-map-marker"></i> {% trans "Map" %}
+      </a>
+    {% endif %}
+    {% if copy_button %}
+      <a class="btn btn-sm btn-primary copy-content d-print-none" data-clipboard-text="{{ value }}" title="{% trans "Copy to clipboard" %}">
+        <i class="mdi mdi-content-copy"></i>
+      </a>
+    {% endif %}
+  </div>
 {% endif %}

+ 2 - 1
netbox/tenancy/api/serializers_/contacts.py

@@ -68,7 +68,8 @@ class ContactAssignmentSerializer(NetBoxModelSerializer):
     object_type = ContentTypeField(
         queryset=ContentType.objects.all()
     )
-    object = GFKSerializerField(read_only=True)
+    # A plugin model can support contacts without a REST API serializer
+    object = GFKSerializerField(read_only=True, allow_missing_serializer=True)
     contact = ContactSerializer(nested=True)
     role = ContactRoleSerializer(nested=True, required=False, allow_null=True)
     priority = ChoiceField(choices=ContactPriorityChoices, allow_blank=True, required=False, default=lambda: '')

+ 6 - 6
netbox/tenancy/tests/query_counts.json

@@ -1,14 +1,14 @@
 {
   "contact:api_list_objects": 13,
-  "contact:list_objects_with_permission": 18,
+  "contact:list_objects_with_permission": 17,
   "contactassignment:api_list_objects": 16,
-  "contactassignment:list_objects_with_permission": 22,
+  "contactassignment:list_objects_with_permission": 21,
   "contactgroup:api_list_objects": 13,
-  "contactgroup:list_objects_with_permission": 17,
+  "contactgroup:list_objects_with_permission": 16,
   "contactrole:api_list_objects": 12,
-  "contactrole:list_objects_with_permission": 17,
+  "contactrole:list_objects_with_permission": 16,
   "tenant:api_list_objects": 13,
-  "tenant:list_objects_with_permission": 18,
+  "tenant:list_objects_with_permission": 17,
   "tenantgroup:api_list_objects": 13,
-  "tenantgroup:list_objects_with_permission": 17
+  "tenantgroup:list_objects_with_permission": 16
 }

+ 27 - 0
netbox/tenancy/tests/test_api.py

@@ -6,6 +6,7 @@ from django.urls import reverse
 from rest_framework import status
 
 from dcim.models import Site
+from netbox.tests.dummy_plugin.models import DummyNetBoxModel
 from tenancy.choices import *
 from tenancy.models import *
 from utilities.testing import APITestCase, APIViewTestCases, disable_logging
@@ -368,3 +369,29 @@ class ContactAssignmentTestCase(APIViewTestCases.APIViewTestCase):
                 'priority': ContactPriorityChoices.PRIORITY_TERTIARY,
             },
         ]
+
+    @tag('regression')  # Ref: #23372
+    def test_object_without_serializer(self):
+        """A contact assignment for an object without a REST API serializer has a null object."""
+        self.add_permissions('tenancy.view_contactassignment')
+        dummy = DummyNetBoxModel.objects.create()
+        contact_assignment = ContactAssignment(
+            object=dummy, contact=Contact.objects.first(), role=ContactRole.objects.first()
+        )
+        contact_assignment.full_clean()
+        contact_assignment.save()
+
+        response = self.client.get(reverse('tenancy-api:contactassignment-list'), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        results = {item['id']: item for item in response.data['results']}
+        self.assertIsNone(results.pop(contact_assignment.pk)['object'])
+        for item in results.values():
+            self.assertEqual(item['object']['id'], item['object_id'])
+
+        response = self.client.get(
+            reverse('tenancy-api:contactassignment-detail', kwargs={'pk': contact_assignment.pk}), **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['object'])
+        self.assertEqual(response.data['object_type'], 'dummy_plugin.dummynetboxmodel')
+        self.assertEqual(response.data['object_id'], dummy.pk)

+ 31 - 30
netbox/translations/en/LC_MESSAGES/django.po

@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-10-13 05:04+0000\n"
+"POT-Creation-Date: 2026-10-17 05:02+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -1519,7 +1519,7 @@ msgstr ""
 #: netbox/dcim/models/device_component_templates.py:75
 #: netbox/dcim/models/device_components.py:76 netbox/dcim/models/racks.py:753
 #: netbox/extras/models/configs.py:47 netbox/extras/models/configs.py:100
-#: netbox/extras/models/configs.py:441 netbox/extras/models/customfields.py:205
+#: netbox/extras/models/configs.py:443 netbox/extras/models/customfields.py:205
 #: netbox/extras/models/models.py:83 netbox/extras/models/models.py:221
 #: netbox/extras/models/models.py:531 netbox/extras/models/models.py:599
 #: netbox/extras/models/models.py:683 netbox/extras/models/models.py:826
@@ -1561,7 +1561,7 @@ msgstr ""
 #: netbox/dcim/models/power.py:39 netbox/dcim/models/power.py:90
 #: netbox/dcim/models/racks.py:313 netbox/dcim/models/sites.py:164
 #: netbox/extras/models/configs.py:42 netbox/extras/models/configs.py:84
-#: netbox/extras/models/configs.py:437 netbox/extras/models/customfields.py:164
+#: netbox/extras/models/configs.py:439 netbox/extras/models/customfields.py:164
 #: netbox/extras/models/models.py:78 netbox/extras/models/models.py:216
 #: netbox/extras/models/models.py:421 netbox/extras/models/models.py:527
 #: netbox/extras/models/models.py:589 netbox/extras/models/models.py:679
@@ -1962,31 +1962,31 @@ msgstr ""
 msgid "This user does not have permission to synchronize this data source."
 msgstr ""
 
-#: netbox/core/apps.py:47
+#: netbox/core/apps.py:53
 msgid "Object created"
 msgstr ""
 
-#: netbox/core/apps.py:48
+#: netbox/core/apps.py:54
 msgid "Object updated"
 msgstr ""
 
-#: netbox/core/apps.py:49
+#: netbox/core/apps.py:55
 msgid "Object deleted"
 msgstr ""
 
-#: netbox/core/apps.py:50
+#: netbox/core/apps.py:56
 msgid "Job started"
 msgstr ""
 
-#: netbox/core/apps.py:51
+#: netbox/core/apps.py:57
 msgid "Job completed"
 msgstr ""
 
-#: netbox/core/apps.py:52
+#: netbox/core/apps.py:58
 msgid "Job failed"
 msgstr ""
 
-#: netbox/core/apps.py:53
+#: netbox/core/apps.py:59
 msgid "Job errored"
 msgstr ""
 
@@ -2851,12 +2851,12 @@ msgstr ""
 msgid "Jobs cannot be assigned to this object type ({type})."
 msgstr ""
 
-#: netbox/core/models/jobs.py:240
+#: netbox/core/models/jobs.py:253
 #, python-brace-format
 msgid "Invalid status for job termination. Choices are: {choices}"
 msgstr ""
 
-#: netbox/core/models/jobs.py:306
+#: netbox/core/models/jobs.py:319
 msgid ""
 "enqueue() cannot be called with values for both schedule_at and immediate."
 msgstr ""
@@ -9482,7 +9482,7 @@ msgstr ""
 msgid "VLAN Groups"
 msgstr ""
 
-#: netbox/dcim/tests/test_api.py:81
+#: netbox/dcim/tests/test_api.py:82
 msgid "Test case must set peer_termination_type"
 msgstr ""
 
@@ -9741,12 +9741,12 @@ msgstr ""
 msgid "Set {mac} as primary MAC address for {interface}."
 msgstr ""
 
-#: netbox/extras/api/customfields.py:111
+#: netbox/extras/api/customfields.py:112
 #, python-brace-format
 msgid "Custom field '{name}' does not exist for this object type."
 msgstr ""
 
-#: netbox/extras/api/customfields.py:127
+#: netbox/extras/api/customfields.py:128
 #, python-brace-format
 msgid "Unknown related object(s): {name}"
 msgstr ""
@@ -9815,17 +9815,17 @@ msgid ""
 "backend is configured to allow overwrites."
 msgstr ""
 
-#: netbox/extras/api/serializers_/scripts.py:197
+#: netbox/extras/api/serializers_/scripts.py:210
 msgid ""
 "Invalid data payload; expected an object mapping variable names to values."
 msgstr ""
 
-#: netbox/extras/api/serializers_/scripts.py:207
-#: netbox/extras/api/serializers_/scripts.py:217
+#: netbox/extras/api/serializers_/scripts.py:220
+#: netbox/extras/api/serializers_/scripts.py:230
 msgid "Scheduling is not enabled for this script."
 msgstr ""
 
-#: netbox/extras/api/serializers_/scripts.py:209
+#: netbox/extras/api/serializers_/scripts.py:222
 #: netbox/extras/forms/reports.py:45 netbox/extras/forms/scripts.py:62
 msgid "Scheduled time must be in the future."
 msgstr ""
@@ -11143,7 +11143,7 @@ msgstr ""
 msgid "config contexts"
 msgstr ""
 
-#: netbox/extras/models/configs.py:206 netbox/extras/models/configs.py:410
+#: netbox/extras/models/configs.py:206 netbox/extras/models/configs.py:412
 msgid "JSON data must be in object form. Example:"
 msgstr ""
 
@@ -11158,30 +11158,30 @@ msgid ""
 "rendered config context"
 msgstr ""
 
-#: netbox/extras/models/configs.py:446
+#: netbox/extras/models/configs.py:448
 msgid "debug"
 msgstr ""
 
-#: netbox/extras/models/configs.py:449
+#: netbox/extras/models/configs.py:451
 msgid ""
 "Enable verbose error output when rendering this template. Not recommended "
 "for production use."
 msgstr ""
 
-#: netbox/extras/models/configs.py:458
+#: netbox/extras/models/configs.py:460
 msgid "config template"
 msgstr ""
 
-#: netbox/extras/models/configs.py:459
+#: netbox/extras/models/configs.py:461
 msgid "config templates"
 msgstr ""
 
-#: netbox/extras/models/configs.py:511
+#: netbox/extras/models/configs.py:513
 #, python-brace-format
 msgid "Template: {name}"
 msgstr ""
 
-#: netbox/extras/models/configs.py:513
+#: netbox/extras/models/configs.py:515
 #, python-brace-format
 msgid "Line: {lineno}"
 msgstr ""
@@ -12427,11 +12427,11 @@ msgstr ""
 msgid "Error deleting widget: "
 msgstr ""
 
-#: netbox/extras/views.py:1747
+#: netbox/extras/views.py:1754
 msgid "Unable to run script: RQ worker process not running."
 msgstr ""
 
-#: netbox/extras/views.py:1766
+#: netbox/extras/views.py:1773
 #, python-brace-format
 msgid "Unable to run script: {error}"
 msgstr ""
@@ -15677,7 +15677,7 @@ msgid ""
 "Invalid max_items value: {max_items}! Must be a positive integer or None."
 msgstr ""
 
-#: netbox/netbox/ui/attrs.py:518
+#: netbox/netbox/ui/attrs.py:521
 msgid "GPS coordinates"
 msgstr ""
 
@@ -16692,6 +16692,7 @@ msgstr ""
 
 #: netbox/templates/dcim/device/attrs/ipaddress.html:10
 #: netbox/templates/ui/actions/copy_content.html:2
+#: netbox/templates/ui/attrs/address.html:12
 #: netbox/templates/ui/attrs/numeric.html:9
 #: netbox/templates/ui/attrs/text.html:4
 #: netbox/templates/users/inc/new_token_banner.html:17
@@ -17938,7 +17939,7 @@ msgstr ""
 msgid "Add Tenant"
 msgstr ""
 
-#: netbox/templates/ui/attrs/address.html:6
+#: netbox/templates/ui/attrs/address.html:8
 #: netbox/templates/ui/attrs/gps_coordinates.html:6
 msgid "Map"
 msgstr ""

+ 6 - 6
netbox/users/tests/query_counts.json

@@ -1,14 +1,14 @@
 {
   "group:api_list_objects": 10,
-  "group:list_objects_with_permission": 16,
+  "group:list_objects_with_permission": 15,
   "objectpermission:api_list_objects": 12,
-  "objectpermission:list_objects_with_permission": 17,
+  "objectpermission:list_objects_with_permission": 16,
   "owner:api_list_objects": 11,
-  "owner:list_objects_with_permission": 18,
+  "owner:list_objects_with_permission": 17,
   "ownergroup:api_list_objects": 9,
-  "ownergroup:list_objects_with_permission": 16,
+  "ownergroup:list_objects_with_permission": 15,
   "token:api_list_objects": 10,
-  "token:list_objects_with_permission": 17,
+  "token:list_objects_with_permission": 16,
   "user:api_list_objects": 12,
-  "user:list_objects_with_permission": 16
+  "user:list_objects_with_permission": 15
 }

+ 4 - 1
netbox/utilities/testing/base.py

@@ -8,7 +8,7 @@ from django.core.exceptions import FieldDoesNotExist
 from django.db import transaction
 from django.db.models import DateField, DateTimeField, JSONField, ManyToManyField, ManyToManyRel
 from django.forms.models import model_to_dict
-from django.test import Client
+from django.test import Client, override_settings
 from django.test import TestCase as _TestCase
 from netaddr import IPNetwork
 from taggit.managers import TaggableManager
@@ -28,6 +28,9 @@ __all__ = (
 )
 
 
+# Store test client sessions in signed cookies rather than the database, which spares each test the several
+# queries otherwise needed to log in its user
+@override_settings(SESSION_ENGINE='django.contrib.sessions.backends.signed_cookies')
 class TestCase(_TestCase):
     user_permissions = ()
 

+ 20 - 14
netbox/utilities/testing/tables.py

@@ -85,7 +85,8 @@ class ModelTableTestCase(TestCase):
         smoke test.
         """
         if cls.queryset_sources is not None:
-            return tuple(cls.queryset_sources)
+            # Clone each QuerySet to avoid caching results on the class-level instances
+            return tuple((label, queryset.all()) for label, queryset in cls.queryset_sources)
 
         model = cls.table._meta.model
         app_label = model._meta.app_label
@@ -111,12 +112,12 @@ class ModelTableTestCase(TestCase):
 
         return tuple(sources)
 
-    def iter_orderable_columns(self, queryset):
+    def iter_orderable_columns(self, table):
         """
-        Yield the names of all orderable columns for *queryset*, excluding
-        any listed in ``excluded_orderable_columns``.
+        Yield the names of all orderable columns in *table*, excluding any
+        listed in ``excluded_orderable_columns``.
         """
-        for column in self.get_table(queryset).columns:
+        for column in table.columns:
             if not column.orderable:
                 continue
             if column.name in self.excluded_orderable_columns:
@@ -138,20 +139,25 @@ class TableTestCases:
 
         def test_every_orderable_column_renders(self):
             """
-            Verify that each declared ordering can be applied without error.
+            Smoke test: verify that each orderable column's ordering can be
+            applied and executed (against empty querysets) without error.
 
-            This is intentionally a smoke test. It validates ordering against
-            the configured queryset sources but does not create model
-            instances by default, so it complements rather than replaces
-            data-backed rendering tests for tables whose behavior depends on
-            populated querysets.
+            Each table is instantiated and rendered once per source, as both
+            are expensive and independent of ordering. Its initial queryset is
+            then restored before each ordering is applied and executed.
             """
             request = self.get_request()
 
             for source_name, queryset in self.get_queryset_sources():
-                for column_name in self.iter_orderable_columns(queryset):
+                table = self.get_table(queryset)
+                initial_queryset = table.data.data
+
+                with self.subTest(source=source_name):
+                    table.as_html(request)
+
+                for column_name in self.iter_orderable_columns(table):
                     for direction, prefix in (("asc", ""), ("desc", "-")):
                         with self.subTest(source=source_name, column=column_name, direction=direction):
-                            table = self.get_table(queryset)
+                            table.data.data = initial_queryset
                             table.order_by = f"{prefix}{column_name}"
-                            table.as_html(request)
+                            list(table.data)

+ 3 - 3
netbox/utilities/testing/views.py

@@ -1046,7 +1046,7 @@ class ViewTestCases:
                 self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 403)
 
         def test_bulk_delete_objects_with_permission(self):
-            pk_list = list(self._get_queryset().values_list('pk', flat=True))[:3]
+            pk_list = list(self._get_queryset().values_list('pk', flat=True)[:3])
             data = {
                 'pk': pk_list,
                 'confirm': True,
@@ -1083,7 +1083,7 @@ class ViewTestCases:
                         message=data['changelog_message'])
 
         def test_bulk_delete_objects_with_constrained_permission(self):
-            pk_list = self._get_queryset().values_list('pk', flat=True)
+            pk_list = list(self._get_queryset().values_list('pk', flat=True)[:3])
             data = {
                 'pk': pk_list,
                 'confirm': True,
@@ -1111,7 +1111,7 @@ class ViewTestCases:
 
             # Bulk delete permitted objects
             self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
-            self.assertEqual(self._get_queryset().count(), 0)
+            self.assertFalse(self._get_queryset().filter(pk__in=pk_list).exists())
 
     class BulkRenameObjectsViewTestCase(ModelViewTestCase):
         """

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

@@ -1,16 +1,16 @@
 {
   "cluster:api_list_objects": 14,
-  "cluster:list_objects_with_permission": 19,
+  "cluster:list_objects_with_permission": 18,
   "clustergroup:api_list_objects": 12,
-  "clustergroup:list_objects_with_permission": 17,
+  "clustergroup:list_objects_with_permission": 16,
   "clustertype:api_list_objects": 12,
-  "clustertype:list_objects_with_permission": 17,
+  "clustertype:list_objects_with_permission": 16,
   "virtualdisk:api_list_objects": 13,
-  "virtualdisk:list_objects_with_permission": 18,
+  "virtualdisk:list_objects_with_permission": 17,
   "virtualmachine:api_list_objects": 17,
-  "virtualmachine:list_objects_with_permission": 21,
+  "virtualmachine:list_objects_with_permission": 20,
   "virtualmachinetype:api_list_objects": 13,
-  "virtualmachinetype:list_objects_with_permission": 18,
+  "virtualmachinetype:list_objects_with_permission": 17,
   "vminterface:api_list_objects": 18,
-  "vminterface:list_objects_with_permission": 18
+  "vminterface:list_objects_with_permission": 17
 }

+ 10 - 10
netbox/vpn/tests/query_counts.json

@@ -1,22 +1,22 @@
 {
   "ikepolicy:api_list_objects": 13,
-  "ikepolicy:list_objects_with_permission": 18,
+  "ikepolicy:list_objects_with_permission": 17,
   "ikeproposal:api_list_objects": 12,
-  "ikeproposal:list_objects_with_permission": 17,
+  "ikeproposal:list_objects_with_permission": 16,
   "ipsecpolicy:api_list_objects": 13,
-  "ipsecpolicy:list_objects_with_permission": 18,
+  "ipsecpolicy:list_objects_with_permission": 17,
   "ipsecprofile:api_list_objects": 14,
-  "ipsecprofile:list_objects_with_permission": 19,
+  "ipsecprofile:list_objects_with_permission": 18,
   "ipsecproposal:api_list_objects": 12,
-  "ipsecproposal:list_objects_with_permission": 17,
+  "ipsecproposal:list_objects_with_permission": 16,
   "l2vpn:api_list_objects": 14,
-  "l2vpn:list_objects_with_permission": 17,
+  "l2vpn:list_objects_with_permission": 16,
   "l2vpntermination:api_list_objects": 15,
-  "l2vpntermination:list_objects_with_permission": 22,
+  "l2vpntermination:list_objects_with_permission": 21,
   "tunnel:api_list_objects": 13,
-  "tunnel:list_objects_with_permission": 18,
+  "tunnel:list_objects_with_permission": 17,
   "tunnelgroup:api_list_objects": 12,
-  "tunnelgroup:list_objects_with_permission": 17,
+  "tunnelgroup:list_objects_with_permission": 16,
   "tunneltermination:api_list_objects": 17,
-  "tunneltermination:list_objects_with_permission": 25
+  "tunneltermination:list_objects_with_permission": 24
 }

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

@@ -1,8 +1,8 @@
 {
   "wirelesslan:api_list_objects": 12,
-  "wirelesslan:list_objects_with_permission": 18,
+  "wirelesslan:list_objects_with_permission": 17,
   "wirelesslangroup:api_list_objects": 12,
-  "wirelesslangroup:list_objects_with_permission": 18,
+  "wirelesslangroup:list_objects_with_permission": 17,
   "wirelesslink:api_list_objects": 17,
-  "wirelesslink:list_objects_with_permission": 21
+  "wirelesslink:list_objects_with_permission": 20
 }

+ 2 - 2
requirements.txt

@@ -38,8 +38,8 @@ rq==2.12.0
 social-auth-app-django==6.1.0
 social-auth-core==5.2.0
 sorl-thumbnail==13.1.0
-strawberry-graphql==0.332.1
-strawberry-graphql-django==0.92.2
+strawberry-graphql==0.334.2
+strawberry-graphql-django==0.93.0
 svgwrite==1.4.3
 tablib==3.10.0
 tzdata==2026.5

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است