2
0
Эх сурвалжийг харах

Merge branch 'main' into feature

Jeremy Stretch 17 цаг өмнө
parent
commit
6b334eab85

+ 2 - 4
base_requirements.txt

@@ -167,14 +167,12 @@ social-auth-core<6.0.0
 sorl-thumbnail
 sorl-thumbnail
 
 
 # Strawberry GraphQL
 # 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
 # https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
-strawberry-graphql<0.333.0
+strawberry-graphql
 
 
 # Strawberry GraphQL Django extension
 # 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
 # https://github.com/strawberry-graphql/strawberry-django/releases
-strawberry-graphql-django<0.92.3
+strawberry-graphql-django
 
 
 # SVG image rendering (used for rack elevations)
 # SVG image rendering (used for rack elevations)
 # https://github.com/mozman/svgwrite/blob/master/NEWS.rst
 # https://github.com/mozman/svgwrite/blob/master/NEWS.rst

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

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

@@ -233,6 +233,19 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             with self.subTest(panel=panel):
             with self.subTest(panel=panel):
                 self.assertNotContains(response, url)
                 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):
     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."""
         """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')
         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')
     facility = attrs.TextAttr('facility')
     description = attrs.TextAttr('description')
     description = attrs.TextAttr('description')
     timezone = attrs.TimezoneAttr('time_zone')
     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()
     gps_coordinates = attrs.GPSCoordinatesAttr()
 
 
 
 

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

+ 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

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

@@ -136,4 +136,36 @@ def apply_pagination(
             limit=pagination.limit,
             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)
     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

+ 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',
             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):
     def test_graphql_ip_address_list_assigned_object(self):
         """
         """
         Requesting assigned_object should batch prefetch related objects.
         Requesting assigned_object should batch prefetch related objects.

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

@@ -578,6 +578,64 @@ class AddressAttrTestCase(TestCase):
         context = attr.get_context(obj, 'address', '1 Main St', {})
         context = attr.get_context(obj, 'address', '1 Main St', {})
         self.assertIsNone(context['map_url'])
         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):
 class DateTimeAttrTestCase(TestCase):
 
 

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

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

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

@@ -1,8 +1,17 @@
 {% load i18n %}
 {% load i18n %}
 {% load l10n %}
 {% load l10n %}
 <span>{{ value|linebreaksbr }}</span>
 <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 %}
 {% endif %}

+ 21 - 20
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-14 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"
@@ -1962,31 +1962,31 @@ msgstr ""
 msgid "This user does not have permission to synchronize this data source."
 msgid "This user does not have permission to synchronize this data source."
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/apps.py:47
+#: netbox/core/apps.py:53
 msgid "Object created"
 msgid "Object created"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/apps.py:48
+#: netbox/core/apps.py:54
 msgid "Object updated"
 msgid "Object updated"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/apps.py:49
+#: netbox/core/apps.py:55
 msgid "Object deleted"
 msgid "Object deleted"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/apps.py:50
+#: netbox/core/apps.py:56
 msgid "Job started"
 msgid "Job started"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/apps.py:51
+#: netbox/core/apps.py:57
 msgid "Job completed"
 msgid "Job completed"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/apps.py:52
+#: netbox/core/apps.py:58
 msgid "Job failed"
 msgid "Job failed"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/apps.py:53
+#: netbox/core/apps.py:59
 msgid "Job errored"
 msgid "Job errored"
 msgstr ""
 msgstr ""
 
 
@@ -2851,12 +2851,12 @@ msgstr ""
 msgid "Jobs cannot be assigned to this object type ({type})."
 msgid "Jobs cannot be assigned to this object type ({type})."
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/models/jobs.py:240
+#: netbox/core/models/jobs.py:253
 #, python-brace-format
 #, python-brace-format
 msgid "Invalid status for job termination. Choices are: {choices}"
 msgid "Invalid status for job termination. Choices are: {choices}"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/models/jobs.py:306
+#: netbox/core/models/jobs.py:319
 msgid ""
 msgid ""
 "enqueue() cannot be called with values for both schedule_at and immediate."
 "enqueue() cannot be called with values for both schedule_at and immediate."
 msgstr ""
 msgstr ""
@@ -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 ""
@@ -12427,11 +12427,11 @@ msgstr ""
 msgid "Error deleting widget: "
 msgid "Error deleting widget: "
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/views.py:1747
+#: netbox/extras/views.py:1754
 msgid "Unable to run script: RQ worker process not running."
 msgid "Unable to run script: RQ worker process not running."
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/views.py:1766
+#: netbox/extras/views.py:1773
 #, python-brace-format
 #, python-brace-format
 msgid "Unable to run script: {error}"
 msgid "Unable to run script: {error}"
 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 ""

+ 2 - 2
requirements.txt

@@ -38,8 +38,8 @@ rq==2.12.0
 social-auth-app-django==6.1.0
 social-auth-app-django==6.1.0
 social-auth-core==5.2.0
 social-auth-core==5.2.0
 sorl-thumbnail==13.1.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
 svgwrite==1.4.3
 tablib==3.10.0
 tablib==3.10.0
 tzdata==2026.5
 tzdata==2026.5