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

Merge remote-tracking branch 'origin/main' into 23413-vouch

# Conflicts:
#	.github/PULL_REQUEST_TEMPLATE.md
Jeremy Stretch 5 часов назад
Родитель
Сommit
fbaa7ff2c0
39 измененных файлов с 607 добавлено и 188 удалено
  1. 1 0
      .github/PULL_REQUEST_TEMPLATE.md
  2. 8 0
      AGENTS.md
  3. 34 0
      AI_POLICY.md
  4. 2 1
      CONTRIBUTING.md
  5. 1 1
      docs/configuration/required-parameters.md
  6. 3 0
      docs/customization/custom-scripts.md
  7. 1 0
      docs/development/user-preferences.md
  8. 1 2
      docs/plugins/development/dashboard-widgets.md
  9. 3 3
      docs/plugins/development/forms.md
  10. 1 1
      docs/plugins/development/index.md
  11. 36 0
      docs/plugins/development/user-interface.md
  12. 3 6
      docs/plugins/development/views.md
  13. 11 11
      netbox/circuits/tests/query_counts.json
  14. 3 3
      netbox/core/tests/query_counts.json
  15. 43 43
      netbox/dcim/tests/query_counts.json
  16. 14 5
      netbox/extras/api/customfields.py
  17. 22 9
      netbox/extras/api/serializers_/scripts.py
  18. 3 2
      netbox/extras/api/views.py
  19. 16 0
      netbox/extras/migrations/0145_taggeditem_object_id_bigint.py
  20. 5 0
      netbox/extras/models/tags.py
  21. 16 16
      netbox/extras/tests/query_counts.json
  22. 148 3
      netbox/extras/tests/test_api.py
  23. 122 3
      netbox/extras/tests/test_customfields.py
  24. 12 0
      netbox/extras/tests/test_models.py
  25. 17 17
      netbox/ipam/tests/query_counts.json
  26. 5 1
      netbox/netbox/graphql/filters.py
  27. 6 1
      netbox/netbox/graphql/types.py
  28. 0 0
      netbox/project-static/dist/netbox.js
  29. 0 0
      netbox/project-static/dist/netbox.js.map
  30. 1 1
      netbox/project-static/src/util.ts
  31. 6 6
      netbox/tenancy/tests/query_counts.json
  32. 10 9
      netbox/translations/en/LC_MESSAGES/django.po
  33. 6 6
      netbox/users/tests/query_counts.json
  34. 4 1
      netbox/utilities/testing/base.py
  35. 20 14
      netbox/utilities/testing/tables.py
  36. 3 3
      netbox/utilities/testing/views.py
  37. 7 7
      netbox/virtualization/tests/query_counts.json
  38. 10 10
      netbox/vpn/tests/query_counts.json
  39. 3 3
      netbox/wireless/tests/query_counts.json

+ 1 - 0
.github/PULL_REQUEST_TEMPLATE.md

@@ -5,6 +5,7 @@
       1. You have been vouched for as a contributor (see CONTRIBUTING.md)
       1. You have been vouched for as a contributor (see CONTRIBUTING.md)
       2. An issue has been opened to capture these changes
       2. An issue has been opened to capture these changes
       3. The issue has been accepted and assigned to you for work
       3. The issue has been accepted and assigned to you for work
+      4. Your PR complies with our AI policy (see AI_POLICY.md)
 
 
     Pull requests from contributors who have not been vouched for, or which
     Pull requests from contributors who have not been vouched for, or which
     do not reference an assigned issue, will be closed automatically. Please
     do not reference an assigned issue, will be closed automatically. Please

+ 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.
 - **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.
 - **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 & PR Conventions
 
 
 - Branch naming: `<issue-number>-short-description` (e.g., `1234-device-typerror`)
 - 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.
 > 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]
 > [!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
 ## :bug: Reporting Bugs
 
 
@@ -104,6 +104,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):
 * All code submissions must meet the following criteria (CI will enforce these checks where feasible):
   * Consist entirely of original work
   * Consist entirely of original work
+  * Comply with our [AI policy](AI_POLICY.md)
   * Python syntax is valid
   * Python syntax is valid
   * All tests pass when run with `NETBOX_CONFIGURATION=netbox.configuration_testing ./manage.py test`
   * All tests pass when run with `NETBOX_CONFIGURATION=netbox.configuration_testing ./manage.py test`
   * `ruff check` successfully validates style compliance
   * `ruff check` successfully validates style compliance

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

@@ -30,7 +30,7 @@ ALLOWED_HOSTS = ['*']
 ```python
 ```python
 API_TOKEN_PEPPERS = {
 API_TOKEN_PEPPERS = {
     # DO NOT USE THIS EXAMPLE PEPPER IN PRODUCTION
     # 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
 !!! 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.
     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
 ### Via the CLI
 
 
 Scripts can be run on the CLI by invoking the management command:
 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                      |
 | `locale.language`          | The language selected for UI translation                      |
 | `pagination.per_page`      | The number of items to display per page of a paginated table  |
 | `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 |
 | `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}.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   |
 | `tables.${table}.ordering` | A list of column names by which the table should be ordered   |
 | `ui.copilot_enabled`       | Toggles the NetBox Copilot AI agent                           |
 | `ui.copilot_enabled`       | Toggles the NetBox Copilot AI agent                           |

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

@@ -30,8 +30,7 @@ class MyWidget2(DashboardWidget):
 
 
 ```python
 ```python
 from django import forms
 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
 @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 django.utils.translation import gettext_lazy as _
 from dcim.models import Site
 from dcim.models import Site
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
-from utilities.forms import CSVModelChoiceField
+from utilities.forms.fields import CSVModelChoiceField
 from .models import MyModel
 from .models import MyModel
 
 
 
 
@@ -120,7 +120,7 @@ from django import forms
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from dcim.models import Site
 from dcim.models import Site
 from netbox.forms import NetBoxModelBulkEditForm
 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 utilities.forms.rendering import FieldSet
 from .models import MyModel, MyModelStatusChoices
 from .models import MyModel, MyModelStatusChoices
 
 
@@ -170,7 +170,7 @@ The corresponding model-specific subclasses of `NetBoxModelFilterSetForm` are do
 ```python
 ```python
 from dcim.models import Site
 from dcim.models import Site
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
-from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField
+from utilities.forms.fields import DynamicModelMultipleChoiceField, MultipleChoiceField
 from .models import MyModel, MyModelStatusChoices
 from .models import MyModel, MyModelStatusChoices
 
 
 class MyModelFilterForm(NetBoxModelFilterSetForm):
 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_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_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`)    |
 | `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.
 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);
   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
 ::: netbox.views.generic.BulkDeleteView
     options:
     options:
-      members:
-        - get_form
+      members: false
 
 
 ## Feature Views
 ## Feature Views
 
 
@@ -184,13 +183,11 @@ These views are provided to enable or enhance certain NetBox model features, suc
 
 
 ::: netbox.views.generic.ObjectChangeLogView
 ::: netbox.views.generic.ObjectChangeLogView
     options:
     options:
-      members:
-        - get_form
+      members: false
 
 
 ::: netbox.views.generic.ObjectJournalView
 ::: netbox.views.generic.ObjectJournalView
     options:
     options:
-      members:
-        - get_form
+      members: false
 
 
 ## Extending Core Views
 ## Extending Core Views
 
 

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

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

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

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

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

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

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

@@ -1,6 +1,7 @@
 from functools import cached_property
 from functools import cached_property
 
 
 from django.core.exceptions import ValidationError as DjangoValidationError
 from django.core.exceptions import ValidationError as DjangoValidationError
+from django.db.models import prefetch_related_objects
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 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.choices import CustomFieldTypeChoices
 from extras.constants import CUSTOMFIELD_EMPTY_VALUES
 from extras.constants import CUSTOMFIELD_EMPTY_VALUES
 from extras.models import CustomField
 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
 from utilities.forms.fields import LaxURLField
 
 
 #
 #
@@ -154,10 +155,9 @@ class CustomFieldListSerializer(ListSerializer):
                 cf for cf in cf_field._get_custom_fields()
                 cf for cf in cf_field._get_custom_fields()
                 if cf.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT)
                 if cf.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT)
             ]
             ]
-            cache = {}
+            pks_by_model = {}
             for cf in object_type_cfs:
             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:
                 for item in data:
                     raw = item.custom_field_data.get(cf.name)
                     raw = item.custom_field_data.get(cf.name)
                     if raw is not None:
                     if raw is not None:
@@ -165,7 +165,16 @@ class CustomFieldListSerializer(ListSerializer):
                             pks.update(raw)
                             pks.update(raw)
                         else:
                         else:
                             pks.add(raw)
                             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
                     cache[(model, obj.pk)] = obj
             self.child.context['cf_object_cache'] = cache
             self.child.context['cf_object_cache'] = cache
         return super().to_representation(data)
         return super().to_representation(data)

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

@@ -128,7 +128,7 @@ class ScriptModuleSerializer(ValidatedModelSerializer):
 class ScriptSerializer(ValidatedModelSerializer):
 class ScriptSerializer(ValidatedModelSerializer):
     description = serializers.SerializerMethodField(read_only=True)
     description = serializers.SerializerMethodField(read_only=True)
     vars = 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:
     class Meta:
         model = Script
         model = Script
@@ -155,18 +155,31 @@ class ScriptSerializer(ValidatedModelSerializer):
             return obj.python_class().description
             return obj.python_class().description
         return None
         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):
 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):
     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):
 class ScriptInputSerializer(serializers.Serializer):

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

@@ -436,7 +436,7 @@ class ScriptViewSet(ListModelMixin, RetrieveModelMixin, BaseViewSet):
             data.pop(k, None)
             data.pop(k, None)
 
 
         try:
         try:
-            ScriptJob.enqueue(
+            job = ScriptJob.enqueue(
                 instance=script,
                 instance=script,
                 user=request.user,
                 user=request.user,
                 data=data,
                 data=data,
@@ -453,7 +453,8 @@ class ScriptViewSet(ListModelMixin, RetrieveModelMixin, BaseViewSet):
             # request-field errors, so report them under the non-field "detail" key.
             # request-field errors, so report them under the non-field "detail" key.
             raise ValidationError({'detail': e.messages}) from e
             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)
         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",
         related_name="%(app_label)s_%(class)s_items",
         on_delete=models.CASCADE
         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
     _netbox_private = True
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()

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

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

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

@@ -2,6 +2,7 @@ import datetime
 import hashlib
 import hashlib
 import io
 import io
 import json
 import json
+import uuid
 from contextlib import contextmanager
 from contextlib import contextmanager
 from unittest.mock import MagicMock, PropertyMock, patch
 from unittest.mock import MagicMock, PropertyMock, patch
 
 
@@ -31,7 +32,7 @@ from dcim.models import (
     RearPort,
     RearPort,
     Site,
     Site,
 )
 )
-from extras.api.serializers import EventRuleSerializer
+from extras.api.serializers import EventRuleSerializer, ScriptSerializer
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
 from extras.scripts import BooleanVar, IntegerVar, MultiObjectVar, ObjectVar, StringVar
 from extras.scripts import BooleanVar, IntegerVar, MultiObjectVar, ObjectVar, StringVar
@@ -2623,17 +2624,26 @@ class ScriptRunExecutionTestCase(APITestCase):
                 file_root=ManagedFileRootPathChoices.SCRIPTS,
                 file_root=ManagedFileRootPathChoices.SCRIPTS,
                 file_path='run_script.py',
                 file_path='run_script.py',
             )
             )
-        script = Script.objects.create(
+        cls.script = Script.objects.create(
             module=module,
             module=module,
             name='Test run script',
             name='Test run script',
             is_executable=True,
             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):
     def setUp(self):
         super().setUp()
         super().setUp()
         self.add_permissions('extras.run_script')
         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
         # Monkey-patch the Script model to return our TestScriptClass above, restoring
         # the real property afterwards so later tests aren't left with our stub.
         # the real property afterwards so later tests aren't left with our stub.
         python_class_patch = patch.object(Script, 'python_class', new=self.TestScriptClass)
         python_class_patch = patch.object(Script, 'python_class', new=self.TestScriptClass)
@@ -2648,6 +2658,7 @@ class ScriptRunExecutionTestCase(APITestCase):
 
 
     @patch('extras.jobs.ScriptJob.enqueue')
     @patch('extras.jobs.ScriptJob.enqueue')
     def test_run_forwards_commit_value(self, mock_enqueue):
     def test_run_forwards_commit_value(self, mock_enqueue):
+        mock_enqueue.return_value = self.job
         for commit_value in (True, False):
         for commit_value in (True, False):
             with self.subTest(commit=commit_value):
             with self.subTest(commit=commit_value):
                 mock_enqueue.reset_mock()
                 mock_enqueue.reset_mock()
@@ -2661,6 +2672,7 @@ class ScriptRunExecutionTestCase(APITestCase):
 
 
     @patch('extras.jobs.ScriptJob.enqueue')
     @patch('extras.jobs.ScriptJob.enqueue')
     def test_run_forwards_notifications_value(self, mock_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
         # Regression: ScriptForm.clean() overwrites an empty '_notifications' with the
         # field's own initial, so a client-supplied value never reached ScriptJob.enqueue.
         # field's own initial, so a client-supplied value never reached ScriptJob.enqueue.
         payload = {
         payload = {
@@ -2679,6 +2691,7 @@ class ScriptRunExecutionTestCase(APITestCase):
 
 
     @patch('extras.jobs.ScriptJob.enqueue')
     @patch('extras.jobs.ScriptJob.enqueue')
     def test_run_forwards_schedule_at_and_interval(self, mock_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
         # Regression: schedule_at/interval were likewise read from the form (always
         # absent there) instead of the validated request
         # absent there) instead of the validated request
         schedule_at = now() + datetime.timedelta(hours=1)
         schedule_at = now() + datetime.timedelta(hours=1)
@@ -2698,6 +2711,7 @@ class ScriptRunExecutionTestCase(APITestCase):
 
 
     @patch('extras.jobs.ScriptJob.enqueue')
     @patch('extras.jobs.ScriptJob.enqueue')
     def test_run_converts_objectvar_and_multiobjectvar_ids(self, mock_enqueue):
     def test_run_converts_objectvar_and_multiobjectvar_ids(self, mock_enqueue):
+        mock_enqueue.return_value = self.job
         payload = {
         payload = {
             'data': {
             'data': {
                 'site': self.sites[0].pk,
                 'site': self.sites[0].pk,
@@ -2718,6 +2732,7 @@ class ScriptRunExecutionTestCase(APITestCase):
 
 
     @patch('extras.jobs.ScriptJob.enqueue')
     @patch('extras.jobs.ScriptJob.enqueue')
     def test_run_backfills_default_for_omitted_required_var(self, mock_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
         # 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
         # binding the form on the API path (unlike the UI path), so they 400'd even
         # though the client legitimately omitted them.
         # though the client legitimately omitted them.
@@ -2750,6 +2765,7 @@ class ScriptRunExecutionTestCase(APITestCase):
 
 
     @patch('extras.jobs.ScriptJob.enqueue')
     @patch('extras.jobs.ScriptJob.enqueue')
     def test_run_ignores_undeclared_keys(self, mock_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
         # 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
         # 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.
         # under "Running Custom Scripts > Via the API"; pin it so it can't regress silently.
@@ -2780,6 +2796,7 @@ class ScriptRunExecutionTestCase(APITestCase):
 
 
     @patch('extras.jobs.ScriptJob.enqueue')
     @patch('extras.jobs.ScriptJob.enqueue')
     def test_run_resolves_booleanvar_default_and_explicit_values(self, mock_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.
         # 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
         # An omitted BooleanVar must therefore pick up its declared default, while an
         # explicitly supplied False must not be overwritten by that default.
         # explicitly supplied False must not be overwritten by that default.
@@ -2798,6 +2815,134 @@ class ScriptRunExecutionTestCase(APITestCase):
                 self.assertIs(mock_enqueue.call_args.kwargs['data']['flag'], expected)
                 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):
 class CreatedUpdatedFilterTestCase(APITestCase):
 
 
     @classmethod
     @classmethod

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

@@ -18,9 +18,10 @@ from rest_framework import status
 
 
 from core.choices import ObjectChangeActionChoices
 from core.choices import ObjectChangeActionChoices
 from core.models import Job, ObjectChange, ObjectType
 from core.models import Job, ObjectChange, ObjectType
+from dcim.choices import InterfaceTypeChoices
 from dcim.filtersets import SiteFilterSet
 from dcim.filtersets import SiteFilterSet
 from dcim.forms import SiteImportForm
 from dcim.forms import SiteImportForm
-from dcim.models import Manufacturer, Rack, Site
+from dcim.models import Cable, Device, Interface, Manufacturer, Rack, Site
 from dcim.tables import SiteTable
 from dcim.tables import SiteTable
 from extras.api.customfields import CustomFieldListSerializer
 from extras.api.customfields import CustomFieldListSerializer
 from extras.choices import *
 from extras.choices import *
@@ -32,16 +33,17 @@ from extras.jobs import (
     provision_custom_field,
     provision_custom_field,
     purge_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.api.serializers import VLANSerializer
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from netbox.context import query_cache
 from netbox.context import query_cache
 from netbox.context_managers import event_tracking
 from netbox.context_managers import event_tracking
 from netbox.tables.columns import CustomFieldColumn
 from netbox.tables.columns import CustomFieldColumn
+from netbox.tests.dummy_plugin.models import DummyNetBoxModel
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
 from utilities.filters import MultiValueCharFilter, MultiValueContentTypeFilter
 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
 from virtualization.models import VirtualMachine
 
 
 
 
@@ -1551,6 +1553,123 @@ class CustomFieldAPITestCase(APITestCase):
             [vlans['VLAN 4'].pk, vlans['VLAN 5'].pk],
             [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):
     def test_get_object_selection_field_representation(self):
         """
         """
         Selection custom fields are rendered as an object exposing both the stored value and its
         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):
         with self.assertRaises(AbortRequest):
             sitegroup.tags.add(tag)
             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):
 class ConfigContextTestCase(TestCase):
     """
     """

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

@@ -1,37 +1,37 @@
 {
 {
   "aggregate:api_list_objects": 13,
   "aggregate:api_list_objects": 13,
-  "aggregate:list_objects_with_permission": 21,
+  "aggregate:list_objects_with_permission": 20,
   "asn:api_list_objects": 16,
   "asn:api_list_objects": 16,
-  "asn:list_objects_with_permission": 21,
+  "asn:list_objects_with_permission": 20,
   "asnrange:api_list_objects": 14,
   "asnrange:api_list_objects": 14,
-  "asnrange:list_objects_with_permission": 19,
+  "asnrange:list_objects_with_permission": 18,
   "fhrpgroup:api_list_objects": 13,
   "fhrpgroup:api_list_objects": 13,
-  "fhrpgroup:list_objects_with_permission": 18,
+  "fhrpgroup:list_objects_with_permission": 17,
   "fhrpgroupassignment:api_list_objects": 18,
   "fhrpgroupassignment:api_list_objects": 18,
   "ipaddress:api_list_objects": 13,
   "ipaddress:api_list_objects": 13,
-  "ipaddress:list_objects_with_permission": 18,
+  "ipaddress:list_objects_with_permission": 17,
   "iprange:api_list_objects": 12,
   "iprange:api_list_objects": 12,
-  "iprange:list_objects_with_permission": 17,
+  "iprange:list_objects_with_permission": 16,
   "prefix:api_list_objects": 12,
   "prefix:api_list_objects": 12,
-  "prefix:list_objects_with_permission": 26,
+  "prefix:list_objects_with_permission": 25,
   "rir:api_list_objects": 12,
   "rir:api_list_objects": 12,
-  "rir:list_objects_with_permission": 17,
+  "rir:list_objects_with_permission": 16,
   "role:api_list_objects": 12,
   "role:api_list_objects": 12,
-  "role:list_objects_with_permission": 17,
+  "role:list_objects_with_permission": 16,
   "routetarget:api_list_objects": 12,
   "routetarget:api_list_objects": 12,
-  "routetarget:list_objects_with_permission": 18,
+  "routetarget:list_objects_with_permission": 17,
   "service:api_list_objects": 15,
   "service:api_list_objects": 15,
-  "service:list_objects_with_permission": 18,
+  "service:list_objects_with_permission": 17,
   "servicetemplate:api_list_objects": 12,
   "servicetemplate:api_list_objects": 12,
-  "servicetemplate:list_objects_with_permission": 17,
+  "servicetemplate:list_objects_with_permission": 16,
   "vlan:api_list_objects": 14,
   "vlan:api_list_objects": 14,
-  "vlan:list_objects_with_permission": 21,
+  "vlan:list_objects_with_permission": 20,
   "vlangroup:api_list_objects": 12,
   "vlangroup:api_list_objects": 12,
-  "vlangroup:list_objects_with_permission": 22,
+  "vlangroup:list_objects_with_permission": 21,
   "vlantranslationpolicy:api_list_objects": 13,
   "vlantranslationpolicy:api_list_objects": 13,
-  "vlantranslationpolicy:list_objects_with_permission": 17,
+  "vlantranslationpolicy:list_objects_with_permission": 16,
   "vlantranslationrule:api_list_objects": 13,
   "vlantranslationrule:api_list_objects": 13,
-  "vlantranslationrule:list_objects_with_permission": 18,
+  "vlantranslationrule:list_objects_with_permission": 17,
   "vrf:api_list_objects": 20,
   "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,
     ChangeLoggingMixin,
     BaseModelFilter
     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
 @dataclass

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

@@ -189,7 +189,12 @@ class NetBoxObjectType(
     TagsMixin,
     TagsMixin,
     BaseObjectType
     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).
+    """
 
 
 
 
 #
 #

Разница между файлами не показана из-за своего большого размера
+ 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`.
  * Type guard to determine if a value is an `Element`.
  */
  */
 export function isElement(obj: Element | null | undefined): obj is 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>(
 export async function apiRequest<R extends Dict, D extends ReqData = undefined>(

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

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

+ 10 - 9
netbox/translations/en/LC_MESSAGES/django.po

@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-10-16 05:02+0000\n"
+"POT-Creation-Date: 2026-10-17 05:02+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -9741,12 +9741,12 @@ msgstr ""
 msgid "Set {mac} as primary MAC address for {interface}."
 msgid "Set {mac} as primary MAC address for {interface}."
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/api/customfields.py:111
+#: netbox/extras/api/customfields.py:112
 #, python-brace-format
 #, python-brace-format
 msgid "Custom field '{name}' does not exist for this object type."
 msgid "Custom field '{name}' does not exist for this object type."
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/api/customfields.py:127
+#: netbox/extras/api/customfields.py:128
 #, python-brace-format
 #, python-brace-format
 msgid "Unknown related object(s): {name}"
 msgid "Unknown related object(s): {name}"
 msgstr ""
 msgstr ""
@@ -9815,17 +9815,17 @@ msgid ""
 "backend is configured to allow overwrites."
 "backend is configured to allow overwrites."
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/api/serializers_/scripts.py:197
+#: netbox/extras/api/serializers_/scripts.py:210
 msgid ""
 msgid ""
 "Invalid data payload; expected an object mapping variable names to values."
 "Invalid data payload; expected an object mapping variable names to values."
 msgstr ""
 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."
 msgid "Scheduling is not enabled for this script."
 msgstr ""
 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
 #: netbox/extras/forms/reports.py:45 netbox/extras/forms/scripts.py:62
 msgid "Scheduled time must be in the future."
 msgid "Scheduled time must be in the future."
 msgstr ""
 msgstr ""
@@ -15677,7 +15677,7 @@ msgid ""
 "Invalid max_items value: {max_items}! Must be a positive integer or None."
 "Invalid max_items value: {max_items}! Must be a positive integer or None."
 msgstr ""
 msgstr ""
 
 
-#: netbox/netbox/ui/attrs.py:518
+#: netbox/netbox/ui/attrs.py:521
 msgid "GPS coordinates"
 msgid "GPS coordinates"
 msgstr ""
 msgstr ""
 
 
@@ -16692,6 +16692,7 @@ msgstr ""
 
 
 #: netbox/templates/dcim/device/attrs/ipaddress.html:10
 #: netbox/templates/dcim/device/attrs/ipaddress.html:10
 #: netbox/templates/ui/actions/copy_content.html:2
 #: 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/numeric.html:9
 #: netbox/templates/ui/attrs/text.html:4
 #: netbox/templates/ui/attrs/text.html:4
 #: netbox/templates/users/inc/new_token_banner.html:17
 #: netbox/templates/users/inc/new_token_banner.html:17
@@ -17938,7 +17939,7 @@ msgstr ""
 msgid "Add Tenant"
 msgid "Add Tenant"
 msgstr ""
 msgstr ""
 
 
-#: netbox/templates/ui/attrs/address.html:6
+#: netbox/templates/ui/attrs/address.html:8
 #: netbox/templates/ui/attrs/gps_coordinates.html:6
 #: netbox/templates/ui/attrs/gps_coordinates.html:6
 msgid "Map"
 msgid "Map"
 msgstr ""
 msgstr ""

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

@@ -1,14 +1,14 @@
 {
 {
   "group:api_list_objects": 10,
   "group:api_list_objects": 10,
-  "group:list_objects_with_permission": 16,
+  "group:list_objects_with_permission": 15,
   "objectpermission:api_list_objects": 12,
   "objectpermission:api_list_objects": 12,
-  "objectpermission:list_objects_with_permission": 17,
+  "objectpermission:list_objects_with_permission": 16,
   "owner:api_list_objects": 11,
   "owner:api_list_objects": 11,
-  "owner:list_objects_with_permission": 18,
+  "owner:list_objects_with_permission": 17,
   "ownergroup:api_list_objects": 9,
   "ownergroup:api_list_objects": 9,
-  "ownergroup:list_objects_with_permission": 16,
+  "ownergroup:list_objects_with_permission": 15,
   "token:api_list_objects": 10,
   "token:api_list_objects": 10,
-  "token:list_objects_with_permission": 17,
+  "token:list_objects_with_permission": 16,
   "user:api_list_objects": 12,
   "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 import transaction
 from django.db.models import DateField, DateTimeField, JSONField, ManyToManyField, ManyToManyRel
 from django.db.models import DateField, DateTimeField, JSONField, ManyToManyField, ManyToManyRel
 from django.forms.models import model_to_dict
 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 django.test import TestCase as _TestCase
 from netaddr import IPNetwork
 from netaddr import IPNetwork
 from taggit.managers import TaggableManager
 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):
 class TestCase(_TestCase):
     user_permissions = ()
     user_permissions = ()
 
 

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

@@ -85,7 +85,8 @@ class ModelTableTestCase(TestCase):
         smoke test.
         smoke test.
         """
         """
         if cls.queryset_sources is not None:
         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
         model = cls.table._meta.model
         app_label = model._meta.app_label
         app_label = model._meta.app_label
@@ -111,12 +112,12 @@ class ModelTableTestCase(TestCase):
 
 
         return tuple(sources)
         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:
             if not column.orderable:
                 continue
                 continue
             if column.name in self.excluded_orderable_columns:
             if column.name in self.excluded_orderable_columns:
@@ -138,20 +139,25 @@ class TableTestCases:
 
 
         def test_every_orderable_column_renders(self):
         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()
             request = self.get_request()
 
 
             for source_name, queryset in self.get_queryset_sources():
             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", "-")):
                     for direction, prefix in (("asc", ""), ("desc", "-")):
                         with self.subTest(source=source_name, column=column_name, direction=direction):
                         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.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)
                 self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 403)
 
 
         def test_bulk_delete_objects_with_permission(self):
         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 = {
             data = {
                 'pk': pk_list,
                 'pk': pk_list,
                 'confirm': True,
                 'confirm': True,
@@ -1083,7 +1083,7 @@ class ViewTestCases:
                         message=data['changelog_message'])
                         message=data['changelog_message'])
 
 
         def test_bulk_delete_objects_with_constrained_permission(self):
         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 = {
             data = {
                 'pk': pk_list,
                 'pk': pk_list,
                 'confirm': True,
                 'confirm': True,
@@ -1111,7 +1111,7 @@ class ViewTestCases:
 
 
             # Bulk delete permitted objects
             # Bulk delete permitted objects
             self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
             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):
     class BulkRenameObjectsViewTestCase(ModelViewTestCase):
         """
         """

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

@@ -1,16 +1,16 @@
 {
 {
   "cluster:api_list_objects": 14,
   "cluster:api_list_objects": 14,
-  "cluster:list_objects_with_permission": 19,
+  "cluster:list_objects_with_permission": 18,
   "clustergroup:api_list_objects": 12,
   "clustergroup:api_list_objects": 12,
-  "clustergroup:list_objects_with_permission": 17,
+  "clustergroup:list_objects_with_permission": 16,
   "clustertype:api_list_objects": 12,
   "clustertype:api_list_objects": 12,
-  "clustertype:list_objects_with_permission": 17,
+  "clustertype:list_objects_with_permission": 16,
   "virtualdisk:api_list_objects": 13,
   "virtualdisk:api_list_objects": 13,
-  "virtualdisk:list_objects_with_permission": 18,
+  "virtualdisk:list_objects_with_permission": 17,
   "virtualmachine:api_list_objects": 17,
   "virtualmachine:api_list_objects": 17,
-  "virtualmachine:list_objects_with_permission": 21,
+  "virtualmachine:list_objects_with_permission": 20,
   "virtualmachinetype:api_list_objects": 13,
   "virtualmachinetype:api_list_objects": 13,
-  "virtualmachinetype:list_objects_with_permission": 18,
+  "virtualmachinetype:list_objects_with_permission": 17,
   "vminterface:api_list_objects": 18,
   "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:api_list_objects": 13,
-  "ikepolicy:list_objects_with_permission": 18,
+  "ikepolicy:list_objects_with_permission": 17,
   "ikeproposal:api_list_objects": 12,
   "ikeproposal:api_list_objects": 12,
-  "ikeproposal:list_objects_with_permission": 17,
+  "ikeproposal:list_objects_with_permission": 16,
   "ipsecpolicy:api_list_objects": 13,
   "ipsecpolicy:api_list_objects": 13,
-  "ipsecpolicy:list_objects_with_permission": 18,
+  "ipsecpolicy:list_objects_with_permission": 17,
   "ipsecprofile:api_list_objects": 14,
   "ipsecprofile:api_list_objects": 14,
-  "ipsecprofile:list_objects_with_permission": 19,
+  "ipsecprofile:list_objects_with_permission": 18,
   "ipsecproposal:api_list_objects": 12,
   "ipsecproposal:api_list_objects": 12,
-  "ipsecproposal:list_objects_with_permission": 17,
+  "ipsecproposal:list_objects_with_permission": 16,
   "l2vpn:api_list_objects": 14,
   "l2vpn:api_list_objects": 14,
-  "l2vpn:list_objects_with_permission": 17,
+  "l2vpn:list_objects_with_permission": 16,
   "l2vpntermination:api_list_objects": 15,
   "l2vpntermination:api_list_objects": 15,
-  "l2vpntermination:list_objects_with_permission": 22,
+  "l2vpntermination:list_objects_with_permission": 21,
   "tunnel:api_list_objects": 13,
   "tunnel:api_list_objects": 13,
-  "tunnel:list_objects_with_permission": 18,
+  "tunnel:list_objects_with_permission": 17,
   "tunnelgroup:api_list_objects": 12,
   "tunnelgroup:api_list_objects": 12,
-  "tunnelgroup:list_objects_with_permission": 17,
+  "tunnelgroup:list_objects_with_permission": 16,
   "tunneltermination:api_list_objects": 17,
   "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:api_list_objects": 12,
-  "wirelesslan:list_objects_with_permission": 18,
+  "wirelesslan:list_objects_with_permission": 17,
   "wirelesslangroup:api_list_objects": 12,
   "wirelesslangroup:api_list_objects": 12,
-  "wirelesslangroup:list_objects_with_permission": 18,
+  "wirelesslangroup:list_objects_with_permission": 17,
   "wirelesslink:api_list_objects": 17,
   "wirelesslink:api_list_objects": 17,
-  "wirelesslink:list_objects_with_permission": 21
+  "wirelesslink:list_objects_with_permission": 20
 }
 }

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