Explorar o código

Merge branch 'feature' into 22231-cf-order

Arthur hai 6 días
pai
achega
d0ba554449
Modificáronse 100 ficheiros con 3082 adicións e 754 borrados
  1. 24 0
      docs/customization/custom-fields.md
  2. 1 1
      docs/features/search.md
  3. 0 3
      docs/installation/2-redis.md
  4. 1 1
      docs/installation/index.md
  5. 1 1
      docs/installation/upgrading.md
  6. 4 0
      docs/models/core/job.md
  7. 12 0
      docs/plugins/development/forms.md
  8. 9 34
      netbox/circuits/forms/bulk_edit.py
  9. 21 106
      netbox/circuits/forms/model_forms.py
  10. 8 8
      netbox/circuits/migrations/0053_owner.py
  11. 4 4
      netbox/circuits/tests/test_forms.py
  12. 4 4
      netbox/circuits/tests/test_views.py
  13. 2 2
      netbox/circuits/ui/panels.py
  14. 2 2
      netbox/core/api/serializers_/jobs.py
  15. 8 9
      netbox/core/checks.py
  16. 14 1
      netbox/core/filtersets.py
  17. 11 0
      netbox/core/forms/filtersets.py
  18. 1 1
      netbox/core/migrations/0020_owner.py
  19. 16 0
      netbox/core/migrations/0025_add_job_execution_time.py
  20. 8 0
      netbox/core/models/jobs.py
  21. 8 1
      netbox/core/tables/jobs.py
  22. 16 0
      netbox/core/tests/test_api.py
  23. 19 4
      netbox/core/tests/test_changelog.py
  24. 13 1
      netbox/core/tests/test_filtersets.py
  25. 37 0
      netbox/core/tests/test_models.py
  26. 1 0
      netbox/core/ui/panels.py
  27. 4 4
      netbox/dcim/api/serializers_/device_components.py
  28. 3 1
      netbox/dcim/api/serializers_/devices.py
  29. 4 4
      netbox/dcim/api/serializers_/devicetype_components.py
  30. 14 103
      netbox/dcim/forms/mixins.py
  31. 33 33
      netbox/dcim/migrations/0218_owner.py
  32. 2 1
      netbox/dcim/migrations/0228_rack_group.py
  33. 2 1
      netbox/dcim/migrations/0229_cable_bundle.py
  34. 204 0
      netbox/dcim/migrations/0241_consolidate_unique_constraints.py
  35. 16 34
      netbox/dcim/models/devices.py
  36. 16 40
      netbox/dcim/models/sites.py
  37. 50 3
      netbox/dcim/tests/test_api.py
  38. 2 2
      netbox/dcim/views.py
  39. 11 0
      netbox/extras/api/customfields.py
  40. 11 11
      netbox/extras/migrations/0134_owner.py
  41. 5 1
      netbox/extras/models/customfields.py
  42. 3 0
      netbox/extras/tests/test_api.py
  43. 108 15
      netbox/extras/tests/test_customfields.py
  44. 6 1
      netbox/extras/tests/test_event_rules.py
  45. 2 2
      netbox/extras/tests/test_tags.py
  46. 10 32
      netbox/ipam/forms/bulk_edit.py
  47. 34 106
      netbox/ipam/forms/model_forms.py
  48. 16 16
      netbox/ipam/migrations/0084_owner.py
  49. 1 1
      netbox/ipam/models/ip.py
  50. 1 1
      netbox/ipam/models/services.py
  51. 1 1
      netbox/ipam/tables/template_code.py
  52. 49 7
      netbox/ipam/tests/test_api.py
  53. 6 5
      netbox/ipam/tests/test_forms.py
  54. 59 6
      netbox/ipam/tests/test_views.py
  55. 2 2
      netbox/ipam/views.py
  56. 16 3
      netbox/netbox/api/fields.py
  57. 12 3
      netbox/netbox/api/serializers/base.py
  58. 4 2
      netbox/netbox/api/serializers/generic.py
  59. 1 2
      netbox/netbox/api/serializers/nested.py
  60. 14 7
      netbox/netbox/models/features.py
  61. 1 0
      netbox/netbox/models/mixins.py
  62. 155 13
      netbox/netbox/search/backends.py
  63. 179 0
      netbox/netbox/search/deferred.py
  64. 24 0
      netbox/netbox/search/jobs.py
  65. 7 0
      netbox/netbox/tests/test_authentication.py
  66. 20 44
      netbox/netbox/tests/test_model_features.py
  67. 620 7
      netbox/netbox/tests/test_search.py
  68. 196 0
      netbox/netbox/tests/test_serializers.py
  69. 11 0
      netbox/netbox/ui/attrs.py
  70. 11 1
      netbox/netbox/views/generic/bulk_views.py
  71. 0 0
      netbox/project-static/dist/netbox.js
  72. 0 0
      netbox/project-static/dist/netbox.js.map
  73. 23 5
      netbox/project-static/src/select/classes/dynamicTomSelect.ts
  74. 1 1
      netbox/templates/ipam/prefix/prefixes.html
  75. 5 5
      netbox/tenancy/migrations/0021_owner.py
  76. 46 0
      netbox/tenancy/migrations/0026_consolidate_unique_constraints.py
  77. 2 11
      netbox/tenancy/models/tenants.py
  78. 1 1
      netbox/users/tests/query_counts.json
  79. 19 2
      netbox/users/tests/test_api.py
  80. 1 0
      netbox/utilities/forms/fields/__init__.py
  81. 249 0
      netbox/utilities/forms/fields/generic.py
  82. 63 1
      netbox/utilities/forms/mixins.py
  83. 1 0
      netbox/utilities/forms/widgets/__init__.py
  84. 39 0
      netbox/utilities/forms/widgets/generic.py
  85. 16 0
      netbox/utilities/permissions.py
  86. 27 0
      netbox/utilities/string.py
  87. 13 0
      netbox/utilities/templates/widgets/generic_object.html
  88. 9 0
      netbox/utilities/testing/api.py
  89. 24 1
      netbox/utilities/testing/base.py
  90. 3 3
      netbox/utilities/tests/test_api.py
  91. 231 0
      netbox/utilities/tests/test_forms.py
  92. 33 0
      netbox/utilities/tests/test_string.py
  93. 1 1
      netbox/virtualization/forms/bulk_edit.py
  94. 2 2
      netbox/virtualization/forms/model_forms.py
  95. 6 6
      netbox/virtualization/migrations/0049_owner.py
  96. 2 1
      netbox/virtualization/migrations/0054_virtualmachinetype.py
  97. 56 0
      netbox/virtualization/migrations/0059_consolidate_unique_constraints.py
  98. 1 1
      netbox/virtualization/models/clusters.py
  99. 3 12
      netbox/virtualization/models/virtualmachines.py
  100. 14 3
      netbox/virtualization/tests/test_api.py

+ 24 - 0
docs/customization/custom-fields.md

@@ -100,6 +100,28 @@ When retrieving an object via the REST API, all of its custom data will be inclu
     ...
 ```
 
+Selection and multiple selection fields are returned as objects exposing both the stored value and its human-friendly label, following the same convention used by NetBox's built-in choice fields:
+
+```json
+    "custom_fields": {
+        "site_type": {
+            "value": "datacenter",
+            "label": "Data Center"
+        },
+        "regions": [
+            {
+                "value": "us-east",
+                "label": "US East"
+            },
+            {
+                "value": "us-west",
+                "label": "US West"
+            }
+        ]
+    },
+    ...
+```
+
 To set or change these values, simply include nested JSON data. For example:
 
 ```json
@@ -111,3 +133,5 @@ To set or change these values, simply include nested JSON data. For example:
     }
 }
 ```
+
+As with built-in choice fields, selection custom fields are written by passing the raw value (e.g. `"site_type": "datacenter"`), not the `{value, label}` object returned on read.

+ 1 - 1
docs/features/search.md

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

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

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

+ 1 - 1
docs/installation/index.md

@@ -29,7 +29,7 @@ The following sections detail how to set up a new instance of NetBox:
 |------------|--------------------|
 | Python     | 3.12, 3.13, 3.14   |
 | PostgreSQL | 15+                |
-| Redis      | 4.0+               |
+| Redis      | 6.0+               |
 
 Below is a simplified overview of the NetBox application stack for reference:
 

+ 1 - 1
docs/installation/upgrading.md

@@ -34,7 +34,7 @@ NetBox requires the following dependencies:
 |------------|--------------------|
 | Python     | 3.12, 3.13, 3.14   |
 | PostgreSQL | 15+                |
-| Redis      | 4.0+               |
+| Redis      | 6.0+               |
 
 ### Version History
 

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

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

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

@@ -230,6 +230,18 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
     options:
       members: false
 
+## Generic Object Fields
+
+`GenericObjectChoiceField` represents a generic foreign key (a `content_type` plus `object_id` pair) as a single, REST API-backed form field. Pair it with `GenericObjectFormMixin` on the form to seed the field's initial value from the model's GFK descriptor and assign the selected object back to it automatically.
+
+::: utilities.forms.fields.GenericObjectChoiceField
+    options:
+      members: false
+
+::: utilities.forms.mixins.GenericObjectFormMixin
+    options:
+      members: false
+
 ## CSV Import Fields
 
 ::: utilities.forms.fields.CSVChoiceField

+ 9 - 34
netbox/circuits/forms/bulk_edit.py

@@ -1,6 +1,5 @@
 from django import forms
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist
 from django.utils.translation import gettext_lazy as _
 
 from circuits.choices import (
@@ -11,21 +10,19 @@ from circuits.choices import (
 )
 from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
 from circuits.models import *
-from dcim.models import Site
 from ipam.models import ASN
 from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm
 from tenancy.models import Tenant
-from utilities.forms import add_blank_choice, get_field_value
+from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms.fields import (
     ColorField,
-    ContentTypeChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
+    GenericObjectChoiceField,
 )
 from utilities.forms.rendering import FieldSet
-from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, HTMXSelect, NumberWithOptions
-from utilities.templatetags.builtins.filters import bettertitle
+from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions
 
 __all__ = (
     'CircuitBulkEditForm',
@@ -179,24 +176,18 @@ class CircuitBulkEditForm(PrimaryModelBulkEditForm):
     )
 
 
-class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
+class CircuitTerminationBulkEditForm(GenericObjectFormMixin, NetBoxModelBulkEditForm):
     description = forms.CharField(
         label=_('Description'),
         max_length=200,
         required=False
     )
-    termination_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
-        widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
-        required=False,
-        label=_('Termination type')
-    )
-    termination = DynamicModelChoiceField(
+    termination = GenericObjectChoiceField(
         label=_('Termination'),
-        queryset=Site.objects.none(),  # Initial queryset
+        content_type_queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
         required=False,
-        disabled=True,
-        selector=True
+        selector=True,
+        hx_method='post',
     )
     port_speed = forms.IntegerField(
         required=False,
@@ -215,28 +206,12 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
     model = CircuitTermination
     fieldsets = (
         FieldSet(
-            'description',
-            'termination_type', 'termination',
-            'mark_connected', name=_('Circuit Termination')
+            'description', 'termination', 'mark_connected', name=_('Circuit Termination')
         ),
         FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')),
     )
     nullable_fields = ('description', 'termination')
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        if termination_type_id := get_field_value(self, 'termination_type'):
-            try:
-                termination_type = ContentType.objects.get(pk=termination_type_id)
-                model = termination_type.model_class()
-                self.fields['termination'].queryset = model.objects.all()
-                self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
-                self.fields['termination'].disabled = False
-                self.fields['termination'].label = _(bettertitle(model._meta.verbose_name))
-            except ObjectDoesNotExist:
-                pass
-
 
 class CircuitGroupBulkEditForm(OrganizationalModelBulkEditForm):
     tenant = DynamicModelChoiceField(

+ 21 - 106
netbox/circuits/forms/model_forms.py

@@ -1,6 +1,5 @@
 from django import forms
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.utils.translation import gettext_lazy as _
 
 from circuits.choices import (
@@ -10,21 +9,20 @@ from circuits.choices import (
 )
 from circuits.constants import *
 from circuits.models import *
-from dcim.models import Interface, Site
+from dcim.models import Interface
 from ipam.models import ASN
 from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 from tenancy.forms import TenancyForm
-from utilities.forms import get_field_value
+from utilities.forms import GenericObjectFormMixin
 from utilities.forms.fields import (
-    ContentTypeChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
+    GenericObjectChoiceField,
     SlugField,
 )
 from utilities.forms.mixins import DistanceValidationMixin
 from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields
-from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
-from utilities.string import title
+from utilities.forms.widgets import DatePicker, NumberWithOptions
 
 __all__ = (
     'CircuitForm',
@@ -186,30 +184,24 @@ class CircuitForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm):
         }
 
 
-class CircuitTerminationForm(NetBoxModelForm):
+class CircuitTerminationForm(GenericObjectFormMixin, NetBoxModelForm):
     circuit = DynamicModelChoiceField(
         label=_('Circuit'),
         queryset=Circuit.objects.all(),
         selector=True
     )
-    termination_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
-        widget=HTMXSelect(hx_target_id='circuit-termination'),
-        label=_('Termination type')
-    )
-    termination = DynamicModelChoiceField(
+    termination = GenericObjectChoiceField(
         label=_('Termination'),
-        queryset=Site.objects.none(),  # Initial queryset
-        disabled=True,
-        selector=True
+        content_type_queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
+        required=True,
+        selector=True,
+        hx_target_id='circuit-termination',
     )
 
     fieldsets = (
         FieldSet(
-            'circuit', 'term_side', 'description', 'tags',
-            'termination_type', 'termination',
-            'mark_connected', name=_('Circuit Termination'),
-            html_id='circuit-termination',
+            'circuit', 'term_side', 'description', 'tags', 'termination', 'mark_connected',
+            name=_('Circuit Termination'), html_id='circuit-termination',
         ),
         FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')),
     )
@@ -217,7 +209,7 @@ class CircuitTerminationForm(NetBoxModelForm):
     class Meta:
         model = CircuitTermination
         fields = [
-            'circuit', 'term_side', 'termination_type', 'mark_connected', 'port_speed', 'upstream_speed',
+            'circuit', 'term_side', 'mark_connected', 'port_speed', 'upstream_speed',
             'xconnect_id', 'pp_info', 'description', 'tags',
         ]
         widgets = {
@@ -229,48 +221,6 @@ class CircuitTerminationForm(NetBoxModelForm):
             ),
         }
 
-    def __init__(self, *args, **kwargs):
-        instance = kwargs.get('instance')
-        initial = kwargs.get('initial', {})
-
-        if instance is not None and instance.termination:
-            initial['termination'] = instance.termination
-            kwargs['initial'] = initial
-
-        super().__init__(*args, **kwargs)
-
-        if termination_type_id := get_field_value(self, 'termination_type'):
-            try:
-                termination_type = ContentType.objects.get(pk=termination_type_id)
-                model = termination_type.model_class()
-                self.fields['termination'].queryset = model.objects.all()
-                self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
-                self.fields['termination'].disabled = False
-                self.fields['termination'].label = _(title(model._meta.verbose_name))
-            except ObjectDoesNotExist:
-                pass
-
-            if self.instance and termination_type_id != self.instance.termination_type_id:
-                self.initial['termination'] = None
-        else:
-            # Clear the initial termination value if termination_type is not set
-            self.initial['termination'] = None
-
-    def clean(self):
-        super().clean()
-
-        termination = self.cleaned_data.get('termination')
-        termination_type = self.cleaned_data.get('termination_type')
-        if termination_type and not termination:
-            raise ValidationError({
-                'termination': _('Please select a {termination_type}.').format(
-                    termination_type=_(title(termination_type.model_class()._meta.verbose_name))
-                )
-            })
-
-        # Assign the selected termination (if any)
-        self.instance.termination = self.cleaned_data.get('termination')
-
 
 class CircuitGroupForm(TenancyForm, OrganizationalModelForm):
     fieldsets = (
@@ -285,28 +235,22 @@ class CircuitGroupForm(TenancyForm, OrganizationalModelForm):
         ]
 
 
-class CircuitGroupAssignmentForm(NetBoxModelForm):
+class CircuitGroupAssignmentForm(GenericObjectFormMixin, NetBoxModelForm):
     group = DynamicModelChoiceField(
         label=_('Group'),
         queryset=CircuitGroup.objects.all(),
     )
-    member_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS),
-        widget=HTMXSelect(hx_target_id='circuit-group-assignment'),
+    member = GenericObjectChoiceField(
+        label=_('Member'),
+        content_type_queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS),
         required=False,
-        label=_('Circuit type')
-    )
-    member = DynamicModelChoiceField(
-        label=_('Circuit'),
-        queryset=Circuit.objects.none(),  # Initial queryset
-        required=False,
-        disabled=True,
-        selector=True
+        selector=True,
+        hx_target_id='circuit-group-assignment',
     )
 
     fieldsets = (
         FieldSet(
-            'group', 'member_type', 'member', 'priority', 'tags',
+            'group', 'member', 'priority', 'tags',
             name=_('Group Assignment'), html_id='circuit-group-assignment',
         ),
     )
@@ -314,38 +258,9 @@ class CircuitGroupAssignmentForm(NetBoxModelForm):
     class Meta:
         model = CircuitGroupAssignment
         fields = [
-            'group', 'member_type', 'priority', 'tags',
+            'group', 'priority', 'tags',
         ]
 
-    def __init__(self, *args, **kwargs):
-        instance = kwargs.get('instance')
-        initial = kwargs.get('initial', {})
-
-        if instance is not None and instance.member:
-            initial['member'] = instance.member
-            kwargs['initial'] = initial
-
-        super().__init__(*args, **kwargs)
-
-        if member_type_id := get_field_value(self, 'member_type'):
-            try:
-                model = ContentType.objects.get(pk=member_type_id).model_class()
-                self.fields['member'].queryset = model.objects.all()
-                self.fields['member'].widget.attrs['selector'] = model._meta.label_lower
-                self.fields['member'].disabled = False
-                self.fields['member'].label = _(title(model._meta.verbose_name))
-            except ObjectDoesNotExist:
-                pass
-
-            if self.instance.pk and member_type_id != self.instance.member_type_id:
-                self.initial['member'] = None
-
-    def clean(self):
-        super().clean()
-
-        # Assign the selected circuit (if any)
-        self.instance.member = self.cleaned_data.get('member')
-
 
 class VirtualCircuitTypeForm(OrganizationalModelForm):
     fieldsets = (

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

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

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

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

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

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

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

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

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

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

+ 8 - 9
netbox/core/checks.py

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

+ 14 - 1
netbox/core/filtersets.py

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

+ 11 - 0
netbox/core/forms/filtersets.py

@@ -80,6 +80,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
             'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
             'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
         ),
+        FieldSet('execution_time__gte', 'execution_time__lte', name=_('Execution')),
     )
     object_type_id = ContentTypeChoiceField(
         label=_('Object Type'),
@@ -140,6 +141,16 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
         required=False,
         label=_('User')
     )
+    execution_time__gte = forms.DurationField(
+        label=_('Execution time (minimum)'),
+        required=False,
+        help_text=_('Seconds, or HH:MM:SS')
+    )
+    execution_time__lte = forms.DurationField(
+        label=_('Execution time (maximum)'),
+        required=False,
+        help_text=_('Seconds, or HH:MM:SS')
+    )
 
 
 class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):

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

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

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

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

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

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

+ 8 - 1
netbox/core/tables/jobs.py

@@ -7,6 +7,7 @@ from core.constants import JOB_LOG_ENTRY_LEVELS
 from core.models import Job
 from core.tables.columns import BadgeColumn
 from netbox.tables import BaseTable, NetBoxTable, columns
+from utilities.string import humanize_duration
 
 
 class JobTable(NetBoxTable):
@@ -44,6 +45,9 @@ class JobTable(NetBoxTable):
     completed = columns.DateTimeColumn(
         verbose_name=_('Completed'),
     )
+    execution_time = tables.Column(
+        verbose_name=_('Execution Time'),
+    )
     queue_name = tables.Column(
         verbose_name=_('Queue'),
     )
@@ -58,7 +62,7 @@ class JobTable(NetBoxTable):
         model = Job
         fields = (
             'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
-            'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id',
+            'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id', 'execution_time'
         )
         default_columns = (
             'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
@@ -67,6 +71,9 @@ class JobTable(NetBoxTable):
     def render_log_entries(self, value):
         return len(value)
 
+    def render_execution_time(self, value):
+        return humanize_duration(value)
+
 
 class JobLogEntryTable(BaseTable):
     timestamp = columns.DateTimeColumn(

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

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

+ 19 - 4
netbox/core/tests/test_changelog.py

@@ -149,7 +149,14 @@ class ChangeLogViewTestCase(ModelViewTestCase):
             'path': self._get_url('delete', instance=site),
             'data': post_data({'confirm': True}),
         }
-        self.add_permissions('dcim.delete_site')
+        self.add_permissions(
+            'dcim.add_module',
+            'dcim.delete_site',
+            'dcim.view_device',
+            'dcim.view_modulebay',
+            'dcim.view_moduletype',
+            'extras.view_tag',
+        )
         response = self.client.post(**request)
         self.assertHttpStatus(response, 302)
 
@@ -449,7 +456,7 @@ class ChangeLogAPITestCase(APITestCase):
         }
         self.assertEqual(ObjectChange.objects.count(), 0)
         url = reverse('dcim-api:site-list')
-        self.add_permissions('dcim.add_site')
+        self.add_permissions('dcim.add_site', 'extras.view_tag')
 
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -481,7 +488,7 @@ class ChangeLogAPITestCase(APITestCase):
             ]
         }
         self.assertEqual(ObjectChange.objects.count(), 0)
-        self.add_permissions('dcim.change_site')
+        self.add_permissions('dcim.change_site', 'extras.view_tag')
         url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
 
         response = self.client.put(url, data, format='json', **self.header)
@@ -648,7 +655,15 @@ class ChangeLogAPITestCase(APITestCase):
         device = create_test_device('device1')
         module_bay = ModuleBay.objects.create(device=device, name='Module Bay 1')
         module_type = ModuleType.objects.create(manufacturer=Manufacturer.objects.first(), model='Module Type 1')
-        self.add_permissions('dcim.add_module', 'dcim.add_interface', 'dcim.delete_module')
+        self.add_permissions(
+            'dcim.add_module',
+            'dcim.add_interface',
+            'dcim.delete_module',
+            'dcim.view_device',
+            'dcim.view_module',
+            'dcim.view_modulebay',
+            'dcim.view_moduletype',
+        )
         self.assertEqual(ObjectChange.objects.count(), 0)  # Sanity check
 
         # Create a new Module

+ 13 - 1
netbox/core/tests/test_filtersets.py

@@ -1,5 +1,5 @@
 import uuid
-from datetime import UTC, datetime
+from datetime import UTC, datetime, timedelta
 
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
@@ -262,14 +262,17 @@ class JobTestCase(TestCase, BaseFilterSetTests):
             Job(
                 name='Job 1', job_id=uuid.uuid4(), user=users[0],
                 notifications=JobNotificationChoices.NOTIFICATION_ALWAYS,
+                execution_time=timedelta(seconds=30),
             ),
             Job(
                 name='Job 2', job_id=uuid.uuid4(), user=users[0],
                 notifications=JobNotificationChoices.NOTIFICATION_ALWAYS,
+                execution_time=timedelta(seconds=60),
             ),
             Job(
                 name='Job 3', job_id=uuid.uuid4(), user=users[1],
                 notifications=JobNotificationChoices.NOTIFICATION_ON_FAILURE,
+                execution_time=timedelta(seconds=120),
             ),
             Job(
                 name='Job 4', job_id=uuid.uuid4(), user=users[2],
@@ -293,6 +296,15 @@ class JobTestCase(TestCase, BaseFilterSetTests):
         ]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
+    def test_execution_time(self):
+        """Filter Jobs by execution time (exact value and gte/lte range)."""
+        params = {'execution_time': timedelta(seconds=60)}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'execution_time__gte': timedelta(seconds=60)}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'execution_time__lte': timedelta(seconds=60)}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class ObjectTypeTestCase(TestCase, BaseFilterSetTests):
     queryset = ObjectType.objects.all()

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

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

+ 1 - 0
netbox/core/ui/panels.py

@@ -62,6 +62,7 @@ class JobSchedulingPanel(panels.ObjectAttributesPanel):
     scheduled = attrs.TemplatedAttr('scheduled', template_name='core/job/attrs/scheduled.html')
     started = attrs.DateTimeAttr('started')
     completed = attrs.DateTimeAttr('completed')
+    execution_time = attrs.DurationAttr('execution_time')
     queue = attrs.TextAttr('queue_name', label=_('Queue'))
 
 

+ 4 - 4
netbox/dcim/api/serializers_/device_components.py

@@ -21,7 +21,7 @@ from dcim.models import (
 from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
 from ipam.api.serializers_.vrfs import VRFSerializer
 from ipam.models import VLAN
-from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
+from netbox.api.fields import ChoiceField, ContentTypeField, RestrictedPrimaryKeyRelatedField, SerializedPKRelatedField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.serializers import NetBoxModelSerializer
 from users.api.serializers_.mixins import OwnerMixin
@@ -336,7 +336,7 @@ class RearPortMappingSerializer(serializers.ModelSerializer):
     position = serializers.IntegerField(
         source='rear_port_position'
     )
-    front_port = serializers.PrimaryKeyRelatedField(
+    front_port = RestrictedPrimaryKeyRelatedField(
         queryset=FrontPort.objects.all(),
     )
 
@@ -374,7 +374,7 @@ class FrontPortMappingSerializer(serializers.ModelSerializer):
     position = serializers.IntegerField(
         source='front_port_position'
     )
-    rear_port = serializers.PrimaryKeyRelatedField(
+    rear_port = RestrictedPrimaryKeyRelatedField(
         queryset=RearPort.objects.all(),
     )
 
@@ -450,7 +450,7 @@ class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
 
 class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):
     device = DeviceSerializer(nested=True)
-    parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
+    parent = RestrictedPrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
     role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True)
     manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True, default=None)
     component_type = ContentTypeField(

+ 3 - 1
netbox/dcim/api/serializers_/devices.py

@@ -290,11 +290,13 @@ class MACAddressSerializer(PrimaryModelSerializer):
         allow_null=True
     )
     assigned_object = GFKSerializerField(read_only=True)
+    is_primary = serializers.BooleanField(read_only=True)
 
     class Meta:
         model = MACAddress
         fields = [
             'id', 'url', 'display_url', 'display', 'mac_address', 'assigned_object_type', 'assigned_object_id',
-            'assigned_object', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'assigned_object', 'is_primary', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created',
+            'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'mac_address', 'description')

+ 4 - 4
netbox/dcim/api/serializers_/devicetype_components.py

@@ -16,7 +16,7 @@ from dcim.models import (
     PowerPortTemplate,
     RearPortTemplate,
 )
-from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.fields import ChoiceField, ContentTypeField, RestrictedPrimaryKeyRelatedField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
 from wireless.choices import *
@@ -220,7 +220,7 @@ class RearPortTemplateMappingSerializer(serializers.ModelSerializer):
     position = serializers.IntegerField(
         source='rear_port_position'
     )
-    front_port = serializers.PrimaryKeyRelatedField(
+    front_port = RestrictedPrimaryKeyRelatedField(
         queryset=FrontPortTemplate.objects.all(),
     )
 
@@ -262,7 +262,7 @@ class FrontPortTemplateMappingSerializer(serializers.ModelSerializer):
     position = serializers.IntegerField(
         source='front_port_position'
     )
-    rear_port = serializers.PrimaryKeyRelatedField(
+    rear_port = RestrictedPrimaryKeyRelatedField(
         queryset=RearPortTemplate.objects.all(),
     )
 
@@ -341,7 +341,7 @@ class InventoryItemTemplateSerializer(ComponentTemplateSerializer):
     device_type = DeviceTypeSerializer(
         nested=True
     )
-    parent = serializers.PrimaryKeyRelatedField(
+    parent = RestrictedPrimaryKeyRelatedField(
         queryset=InventoryItemTemplate.objects.all(),
         allow_null=True,
         default=None

+ 14 - 103
netbox/dcim/forms/mixins.py

@@ -1,22 +1,17 @@
-import warnings
-
 from django import forms
-from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.core.exceptions import ValidationError
 from django.db import connection
 from django.db.models.signals import post_save
 from django.utils.translation import gettext_lazy as _
 
 from dcim.constants import LOCATION_SCOPE_TYPES
-from dcim.models import PortMapping, PortTemplateMapping, Site
-from utilities.forms import get_field_value
+from dcim.models import PortMapping, PortTemplateMapping
+from utilities.forms import GenericObjectFormMixin
 from utilities.forms.fields import (
-    ContentTypeChoiceField,
     CSVContentTypeField,
-    DynamicModelChoiceField,
+    GenericObjectChoiceField,
 )
-from utilities.forms.widgets import HTMXSelect
 from utilities.templatetags.builtins.filters import bettertitle
 
 __all__ = (
@@ -27,109 +22,25 @@ __all__ = (
 )
 
 
-class ScopedForm(forms.Form):
-    scope_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES),
-        # hx_target_id='scope' — all ScopedForm consumers must declare a FieldSet with html_id='scope'
-        widget=HTMXSelect(hx_target_id='scope'),
-        required=False,
-        label=_('Scope type')
-    )
-    scope = DynamicModelChoiceField(
+class ScopedForm(GenericObjectFormMixin, forms.Form):
+    scope = GenericObjectChoiceField(
         label=_('Scope'),
-        queryset=Site.objects.none(),  # Initial queryset
+        content_type_queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES),
         required=False,
-        disabled=True,
-        selector=True
+        selector=True,
+        hx_target_id='scope',
     )
 
-    def __init__(self, *args, **kwargs):
-        instance = kwargs.get('instance')
-        initial = kwargs.get('initial', {})
-
-        if instance is not None and instance.scope:
-            initial['scope'] = instance.scope
-            kwargs['initial'] = initial
-
-        super().__init__(*args, **kwargs)
-        self._set_scoped_values()
-
-        if settings.DEBUG:
-            has_scope_fieldset = any(
-                getattr(fs, 'html_id', None) == 'scope'
-                for fs in getattr(self, 'fieldsets', [])
-            )
-            if not has_scope_fieldset:
-                warnings.warn(
-                    f"{self.__class__.__name__} uses ScopedForm but declares no "
-                    "FieldSet with html_id='scope'; HTMX partial swap will fail silently.",
-                    stacklevel=2,
-                )
-
-    def clean(self):
-        super().clean()
 
-        scope = self.cleaned_data.get('scope')
-        scope_type = self.cleaned_data.get('scope_type')
-        if scope_type and not scope:
-            raise ValidationError({
-                'scope': _(
-                    "Please select a {scope_type}."
-                ).format(scope_type=scope_type.model_class()._meta.model_name)
-            })
-
-        # Assign the selected scope (if any)
-        self.instance.scope = scope
-
-    def _set_scoped_values(self):
-        if scope_type_id := get_field_value(self, 'scope_type'):
-            try:
-                scope_type = ContentType.objects.get(pk=scope_type_id)
-                model = scope_type.model_class()
-                self.fields['scope'].queryset = model.objects.all()
-                self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
-                self.fields['scope'].disabled = False
-                self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
-            except ObjectDoesNotExist:
-                pass
-
-            if self.instance and self.instance.pk and scope_type_id != self.instance.scope_type_id:
-                self.initial['scope'] = None
-
-        else:
-            # Clear the initial scope value if scope_type is not set
-            self.initial['scope'] = None
-
-
-class ScopedBulkEditForm(forms.Form):
-    scope_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES),
-        widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
-        required=False,
-        label=_('Scope type')
-    )
-    scope = DynamicModelChoiceField(
+class ScopedBulkEditForm(GenericObjectFormMixin, forms.Form):
+    scope = GenericObjectChoiceField(
         label=_('Scope'),
-        queryset=Site.objects.none(),  # Initial queryset
+        content_type_queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES),
         required=False,
-        disabled=True,
-        selector=True
+        selector=True,
+        hx_method='post',
     )
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        if scope_type_id := get_field_value(self, 'scope_type'):
-            try:
-                scope_type = ContentType.objects.get(pk=scope_type_id)
-                model = scope_type.model_class()
-                self.fields['scope'].queryset = model.objects.all()
-                self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
-                self.fields['scope'].disabled = False
-                self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
-            except ObjectDoesNotExist:
-                pass
-
 
 class ScopedImportForm(forms.Form):
     scope_type = CSVContentTypeField(

+ 33 - 33
netbox/dcim/migrations/0218_owner.py

@@ -13,231 +13,231 @@ class Migration(migrations.Migration):
             model_name='cable',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='consoleport',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='consoleserverport',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='device',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='devicebay',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='devicerole',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='devicetype',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='frontport',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='interface',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='inventoryitem',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='inventoryitemrole',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='location',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='macaddress',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='manufacturer',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='module',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='modulebay',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='moduletype',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='moduletypeprofile',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='platform',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='powerfeed',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='poweroutlet',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='powerpanel',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='powerport',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='rack',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='rackreservation',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='rackrole',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='racktype',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='rearport',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='region',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='site',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='sitegroup',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='virtualchassis',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='virtualdevicecontext',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
     ]

+ 2 - 1
netbox/dcim/migrations/0228_rack_group.py

@@ -83,7 +83,8 @@ class Migration(migrations.Migration):
                 (
                     'owner',
                     models.ForeignKey(
-                        blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                        blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+',
+                        to='users.owner'
                     ),
                 ),
                 ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),

+ 2 - 1
netbox/dcim/migrations/0229_cable_bundle.py

@@ -28,7 +28,8 @@ class Migration(migrations.Migration):
                 ('comments', models.TextField(blank=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('owner', models.ForeignKey(
-                    blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner')
+                    blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+',
+                    to='users.owner')
                  ),
                 ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
             ],

+ 204 - 0
netbox/dcim/migrations/0241_consolidate_unique_constraints.py

@@ -0,0 +1,204 @@
+import django.db.models.functions.text
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0240_device__config_context_data'),
+        ('extras', '0139_alter_customfieldchoiceset_extra_choices'),
+        ('tenancy', '0025_ltree_paths'),
+        ('users', '0016_default_ordering_indexes'),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name='devicerole',
+            name='dcim_devicerole_parent_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='devicerole',
+            name='dcim_devicerole_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='devicerole',
+            name='dcim_devicerole_parent_slug',
+        ),
+        migrations.RemoveConstraint(
+            model_name='devicerole',
+            name='dcim_devicerole_slug',
+        ),
+        migrations.RemoveConstraint(
+            model_name='location',
+            name='dcim_location_parent_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='location',
+            name='dcim_location_parent_slug',
+        ),
+        migrations.RemoveConstraint(
+            model_name='location',
+            name='dcim_location_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='location',
+            name='dcim_location_slug',
+        ),
+        migrations.RemoveConstraint(
+            model_name='platform',
+            name='dcim_platform_manufacturer_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='platform',
+            name='dcim_platform_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='platform',
+            name='dcim_platform_manufacturer_slug',
+        ),
+        migrations.RemoveConstraint(
+            model_name='platform',
+            name='dcim_platform_slug',
+        ),
+        migrations.RemoveConstraint(
+            model_name='region',
+            name='dcim_region_parent_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='region',
+            name='dcim_region_parent_slug',
+        ),
+        migrations.RemoveConstraint(
+            model_name='region',
+            name='dcim_region_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='region',
+            name='dcim_region_slug',
+        ),
+        migrations.RemoveConstraint(
+            model_name='sitegroup',
+            name='dcim_sitegroup_parent_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='sitegroup',
+            name='dcim_sitegroup_parent_slug',
+        ),
+        migrations.RemoveConstraint(
+            model_name='sitegroup',
+            name='dcim_sitegroup_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='sitegroup',
+            name='dcim_sitegroup_slug',
+        ),
+        migrations.RemoveConstraint(
+            model_name='device',
+            name='dcim_device_unique_name_site_tenant',
+        ),
+        migrations.RemoveConstraint(
+            model_name='device',
+            name='dcim_device_unique_name_site',
+        ),
+        migrations.AddConstraint(
+            model_name='devicerole',
+            constraint=models.UniqueConstraint(
+                fields=('parent', 'name'),
+                name='dcim_devicerole_parent_name',
+                nulls_distinct=False,
+                violation_error_message='A device role with this name already exists.',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='devicerole',
+            constraint=models.UniqueConstraint(
+                fields=('parent', 'slug'),
+                name='dcim_devicerole_parent_slug',
+                nulls_distinct=False,
+                violation_error_message='A device role with this slug already exists.',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='location',
+            constraint=models.UniqueConstraint(
+                fields=('site', 'parent', 'name'),
+                name='dcim_location_parent_name',
+                nulls_distinct=False,
+                violation_error_message='A location with this name already exists within the specified site.',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='location',
+            constraint=models.UniqueConstraint(
+                fields=('site', 'parent', 'slug'),
+                name='dcim_location_parent_slug',
+                nulls_distinct=False,
+                violation_error_message='A location with this slug already exists within the specified site.',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='platform',
+            constraint=models.UniqueConstraint(
+                fields=('manufacturer', 'name'),
+                name='dcim_platform_manufacturer_name',
+                nulls_distinct=False,
+                violation_error_message='Platform name must be unique.',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='platform',
+            constraint=models.UniqueConstraint(
+                fields=('manufacturer', 'slug'),
+                name='dcim_platform_manufacturer_slug',
+                nulls_distinct=False,
+                violation_error_message='Platform slug must be unique.',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='region',
+            constraint=models.UniqueConstraint(
+                fields=('parent', 'name'),
+                name='dcim_region_parent_name',
+                nulls_distinct=False,
+                violation_error_message='A region with this name already exists.',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='region',
+            constraint=models.UniqueConstraint(
+                fields=('parent', 'slug'),
+                name='dcim_region_parent_slug',
+                nulls_distinct=False,
+                violation_error_message='A region with this slug already exists.',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='sitegroup',
+            constraint=models.UniqueConstraint(
+                fields=('parent', 'name'),
+                name='dcim_sitegroup_parent_name',
+                nulls_distinct=False,
+                violation_error_message='A site group with this name already exists.',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='sitegroup',
+            constraint=models.UniqueConstraint(
+                fields=('parent', 'slug'),
+                name='dcim_sitegroup_parent_slug',
+                nulls_distinct=False,
+                violation_error_message='A site group with this slug already exists.',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='device',
+            constraint=models.UniqueConstraint(
+                django.db.models.functions.text.Lower('name'),
+                models.F('site'),
+                models.F('tenant'),
+                condition=models.Q(('name__isnull', False)),
+                name='dcim_device_unique_name_site_tenant',
+                nulls_distinct=False,
+                violation_error_message='Device name must be unique per site and tenant.',
+            ),
+        ),
+    ]

+ 16 - 34
netbox/dcim/models/devices.py

@@ -420,23 +420,15 @@ class DeviceRole(NestedLtreeGroupModel):
         constraints = (
             models.UniqueConstraint(
                 fields=('parent', 'name'),
-                name='%(app_label)s_%(class)s_parent_name'
-            ),
-            models.UniqueConstraint(
-                fields=('name',),
-                name='%(app_label)s_%(class)s_name',
-                condition=Q(parent__isnull=True),
-                violation_error_message=_("A top-level device role with this name already exists.")
+                name='%(app_label)s_%(class)s_parent_name',
+                nulls_distinct=False,
+                violation_error_message=_("A device role with this name already exists.")
             ),
             models.UniqueConstraint(
                 fields=('parent', 'slug'),
-                name='%(app_label)s_%(class)s_parent_slug'
-            ),
-            models.UniqueConstraint(
-                fields=('slug',),
-                name='%(app_label)s_%(class)s_slug',
-                condition=Q(parent__isnull=True),
-                violation_error_message=_("A top-level device role with this slug already exists.")
+                name='%(app_label)s_%(class)s_parent_slug',
+                nulls_distinct=False,
+                violation_error_message=_("A device role with this slug already exists.")
             ),
         )
         verbose_name = _('device role')
@@ -478,21 +470,13 @@ class Platform(NestedLtreeGroupModel):
             models.UniqueConstraint(
                 fields=('manufacturer', 'name'),
                 name='%(app_label)s_%(class)s_manufacturer_name',
-            ),
-            models.UniqueConstraint(
-                fields=('name',),
-                name='%(app_label)s_%(class)s_name',
-                condition=Q(manufacturer__isnull=True),
+                nulls_distinct=False,
                 violation_error_message=_("Platform name must be unique.")
             ),
             models.UniqueConstraint(
                 fields=('manufacturer', 'slug'),
                 name='%(app_label)s_%(class)s_manufacturer_slug',
-            ),
-            models.UniqueConstraint(
-                fields=('slug',),
-                name='%(app_label)s_%(class)s_slug',
-                condition=Q(manufacturer__isnull=True),
+                nulls_distinct=False,
                 violation_error_message=_("Platform slug must be unique.")
             ),
         )
@@ -764,13 +748,10 @@ class Device(
         constraints = (
             models.UniqueConstraint(
                 Lower('name'), 'site', 'tenant',
-                name='%(app_label)s_%(class)s_unique_name_site_tenant'
-            ),
-            models.UniqueConstraint(
-                Lower('name'), 'site',
-                name='%(app_label)s_%(class)s_unique_name_site',
-                condition=Q(tenant__isnull=True),
-                violation_error_message=_("Device name must be unique per site.")
+                name='%(app_label)s_%(class)s_unique_name_site_tenant',
+                condition=Q(name__isnull=False),
+                nulls_distinct=False,
+                violation_error_message=_("Device name must be unique per site and tenant.")
             ),
             models.UniqueConstraint(
                 fields=('rack', 'position', 'face'),
@@ -1407,9 +1388,10 @@ class MACAddress(PrimaryModel):
 
     @cached_property
     def is_primary(self):
-        if self.assigned_object and hasattr(self.assigned_object, 'primary_mac_address'):
-            if self.assigned_object.primary_mac_address and self.assigned_object.primary_mac_address.pk == self.pk:
-                return True
+        # Compare against primary_mac_address_id (a column already loaded on the assigned object) rather than
+        # dereferencing primary_mac_address, to avoid an extra query per object in list responses.
+        if (obj := self.assigned_object) is not None and hasattr(obj, 'primary_mac_address_id'):
+            return obj.primary_mac_address_id == self.pk
         return False
 
     def clean(self, *args, **kwargs):

+ 16 - 40
netbox/dcim/models/sites.py

@@ -53,23 +53,15 @@ class Region(ContactsMixin, NestedLtreeGroupModel):
         constraints = (
             models.UniqueConstraint(
                 fields=('parent', 'name'),
-                name='%(app_label)s_%(class)s_parent_name'
-            ),
-            models.UniqueConstraint(
-                fields=('name',),
-                name='%(app_label)s_%(class)s_name',
-                condition=Q(parent__isnull=True),
-                violation_error_message=_("A top-level region with this name already exists.")
+                name='%(app_label)s_%(class)s_parent_name',
+                nulls_distinct=False,
+                violation_error_message=_("A region with this name already exists.")
             ),
             models.UniqueConstraint(
                 fields=('parent', 'slug'),
-                name='%(app_label)s_%(class)s_parent_slug'
-            ),
-            models.UniqueConstraint(
-                fields=('slug',),
-                name='%(app_label)s_%(class)s_slug',
-                condition=Q(parent__isnull=True),
-                violation_error_message=_("A top-level region with this slug already exists.")
+                name='%(app_label)s_%(class)s_parent_slug',
+                nulls_distinct=False,
+                violation_error_message=_("A region with this slug already exists.")
             ),
         )
         verbose_name = _('region')
@@ -114,23 +106,15 @@ class SiteGroup(ContactsMixin, NestedLtreeGroupModel):
         constraints = (
             models.UniqueConstraint(
                 fields=('parent', 'name'),
-                name='%(app_label)s_%(class)s_parent_name'
-            ),
-            models.UniqueConstraint(
-                fields=('name',),
-                name='%(app_label)s_%(class)s_name',
-                condition=Q(parent__isnull=True),
-                violation_error_message=_("A top-level site group with this name already exists.")
+                name='%(app_label)s_%(class)s_parent_name',
+                nulls_distinct=False,
+                violation_error_message=_("A site group with this name already exists.")
             ),
             models.UniqueConstraint(
                 fields=('parent', 'slug'),
-                name='%(app_label)s_%(class)s_parent_slug'
-            ),
-            models.UniqueConstraint(
-                fields=('slug',),
-                name='%(app_label)s_%(class)s_slug',
-                condition=Q(parent__isnull=True),
-                violation_error_message=_("A top-level site group with this slug already exists.")
+                name='%(app_label)s_%(class)s_parent_slug',
+                nulls_distinct=False,
+                violation_error_message=_("A site group with this slug already exists.")
             ),
         )
         verbose_name = _('site group')
@@ -341,22 +325,14 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedLtreeGroupModel):
         constraints = (
             models.UniqueConstraint(
                 fields=('site', 'parent', 'name'),
-                name='%(app_label)s_%(class)s_parent_name'
-            ),
-            models.UniqueConstraint(
-                fields=('site', 'name'),
-                name='%(app_label)s_%(class)s_name',
-                condition=Q(parent__isnull=True),
+                name='%(app_label)s_%(class)s_parent_name',
+                nulls_distinct=False,
                 violation_error_message=_("A location with this name already exists within the specified site.")
             ),
             models.UniqueConstraint(
                 fields=('site', 'parent', 'slug'),
-                name='%(app_label)s_%(class)s_parent_slug'
-            ),
-            models.UniqueConstraint(
-                fields=('site', 'slug'),
-                name='%(app_label)s_%(class)s_slug',
-                condition=Q(parent__isnull=True),
+                name='%(app_label)s_%(class)s_parent_slug',
+                nulls_distinct=False,
                 violation_error_message=_("A location with this slug already exists within the specified site.")
             ),
         )

+ 50 - 3
netbox/dcim/tests/test_api.py

@@ -66,7 +66,10 @@ class Mixins:
             cable = Cable(a_terminations=[obj], b_terminations=[peer_obj], label='Cable 1')
             cable.save()
 
-            self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
+            self.add_permissions(
+                f'dcim.view_{self.model._meta.model_name}',
+                f'dcim.view_{self.peer_termination_type._meta.model_name}',
+            )
             url = reverse(f'dcim-api:{self.model._meta.model_name}-trace', kwargs={'pk': obj.pk})
             response = self.client.get(url, **self.header)
 
@@ -1444,6 +1447,7 @@ class RearPortTemplateTestCase(APIViewTestCases.APIViewTestCase):
     bulk_update_data = {
         'description': 'New description',
     }
+    user_permissions = ('dcim.view_frontporttemplate', )
 
     @classmethod
     def setUpTestData(cls):
@@ -2624,7 +2628,7 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
         'description': 'New description',
     }
     peer_termination_type = Interface
-    user_permissions = ('dcim.view_device', )
+    user_permissions = ('dcim.view_device', 'ipam.view_vlan')
 
     @classmethod
     def setUpTestData(cls):
@@ -2944,6 +2948,25 @@ class FrontPortTestCase(APIViewTestCases.APIViewTestCase):
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
+    def test_create_front_port_with_unviewable_rear_port_fails(self):
+        """A front port cannot be created against a rear port the user cannot view."""
+        self.remove_permissions('dcim.view_rearport')
+        self.add_permissions('dcim.add_frontport')
+
+        device = Device.objects.first()
+        rear_port = RearPort.objects.first()
+        data = {
+            'device': device.pk,
+            'name': 'Front Port Hidden',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'rear_ports': [
+                {'position': 1, 'rear_port': rear_port.pk, 'rear_port_position': 1},
+            ],
+        }
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('rear_ports', response.data)
+
 
 class RearPortTestCase(APIViewTestCases.APIViewTestCase):
     model = RearPort
@@ -2952,7 +2975,7 @@ class RearPortTestCase(APIViewTestCases.APIViewTestCase):
         'description': 'New description',
     }
     peer_termination_type = Interface
-    user_permissions = ('dcim.view_device', )
+    user_permissions = ('dcim.view_device', 'dcim.view_frontport')
 
     @classmethod
     def setUpTestData(cls):
@@ -3326,6 +3349,8 @@ class CableBundleTestCase(APIViewTestCases.APIViewTestCase):
 class CableTestCase(APIViewTestCases.APIViewTestCase):
     model = Cable
     brief_fields = ['description', 'display', 'id', 'label', 'url']
+    # Cable terminations are generic-FK references; view permission cannot be auto-derived
+    user_permissions = ('dcim.view_interface',)
     bulk_update_data = {
         'length': 100,
         'length_unit': 'm',
@@ -3880,3 +3905,25 @@ class MACAddressTestCase(APIViewTestCases.APIViewTestCase):
                 'mac_address': '00:00:00:00:00:06',
             },
         ]
+
+    def test_is_primary_field(self):
+        """
+        The read-only is_primary field should reflect whether the MAC address is the primary on its interface.
+        """
+        self.add_permissions('dcim.view_macaddress')
+
+        primary_mac = MACAddress.objects.get(mac_address='00:00:00:00:00:01')
+        non_primary_mac = MACAddress.objects.get(mac_address='00:00:00:00:00:02')
+
+        # Designate one MAC address as the primary on its interface
+        interface = primary_mac.assigned_object
+        interface.primary_mac_address = primary_mac
+        interface.save()
+
+        url = reverse('dcim-api:macaddress-detail', kwargs={'pk': primary_mac.pk})
+        response = self.client.get(url, **self.header)
+        self.assertTrue(response.data['is_primary'])
+
+        url = reverse('dcim-api:macaddress-detail', kwargs={'pk': non_primary_mac.pk})
+        response = self.client.get(url, **self.header)
+        self.assertFalse(response.data['is_primary'])

+ 2 - 2
netbox/dcim/views.py

@@ -2641,8 +2641,8 @@ class DeviceView(generic.ObjectView):
                     actions.AddObject(
                         'ipam.Service',
                         url_params={
-                            'parent_object_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
-                            'parent': lambda ctx: ctx['object'].pk
+                            'parent_content_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
+                            'parent_object_id': lambda ctx: ctx['object'].pk
                         }
                     ),
                 ],

+ 11 - 0
netbox/extras/api/customfields.py

@@ -74,6 +74,17 @@ class CustomFieldsDataField(Field):
             elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
                 serializer = get_serializer_for_model(cf.related_object_type.model_class())
                 value = serializer(value, nested=True, many=True, context=self.parent.context).data
+            elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_SELECT:
+                # Represent the selected choice as an object with its value and resolved label
+                value = {
+                    'value': value,
+                    'label': cf.get_choice_label(value),
+                }
+            elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
+                # Represent each selected choice as an object with its value and resolved label
+                value = [
+                    {'value': v, 'label': cf.get_choice_label(v)} for v in value
+                ]
             data[cf.name] = value
 
         return data

+ 11 - 11
netbox/extras/migrations/0134_owner.py

@@ -13,77 +13,77 @@ class Migration(migrations.Migration):
             model_name='configcontext',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='configcontextprofile',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='configtemplate',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='customfield',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='customfieldchoiceset',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='customlink',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='eventrule',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='exporttemplate',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='savedfilter',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='tag',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='webhook',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
     ]

+ 5 - 1
netbox/extras/models/customfields.py

@@ -829,7 +829,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
 
             # Validate all selected choices
             elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
-                if not set(value).issubset(self.choice_set.values):
+                # Require a list of valid string choices. The isinstance() check short-circuits the membership
+                # test so that non-string members (e.g. a client echoing back the {value, label} read
+                # representation) raise a ValidationError rather than an unhashable-type TypeError.
+                valid_values = set(self.choice_set.values)
+                if type(value) is not list or not all(isinstance(v, str) and v in valid_values for v in value):
                     raise ValidationError(
                         _("Invalid choice(s) ({value}) for choice set {choiceset}.").format(
                             value=value,

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

@@ -598,6 +598,7 @@ class BookmarkTestCase(
 ):
     model = Bookmark
     brief_fields = ['display', 'id', 'object_id', 'object_type', 'url']
+    user_permissions = ('users.view_user',)
 
     @classmethod
     def setUpTestData(cls):
@@ -1419,6 +1420,7 @@ class SubscriptionTestCase(APIViewTestCases.APIViewTestCase):
     graphql_filter = {
         'id': {'lookup': 'gt', 'value': '0'},
     }
+    user_permissions = ('users.view_user',)
 
     @classmethod
     def setUpTestData(cls):
@@ -1555,6 +1557,7 @@ class NotificationGroupTestCase(APIViewTestCases.APIViewTestCase):
 class NotificationTestCase(APIViewTestCases.APIViewTestCase):
     model = Notification
     brief_fields = ['display', 'event_type', 'id', 'object_id', 'object_type', 'read', 'url', 'user']
+    user_permissions = ('users.view_user',)
     bulk_update_data = {
         'read': now(),
     }

+ 108 - 15
netbox/extras/tests/test_customfields.py

@@ -942,6 +942,24 @@ class CustomFieldAPITestCase(APITestCase):
         }
         sites[1].save()
 
+    # Labels for the choice set created in setUpTestData, used to build the expected
+    # API representation of selection custom fields ({'value': ..., 'label': ...}).
+    CHOICE_LABELS = {'foo': 'Foo', 'bar': 'Bar', 'baz': 'Baz'}
+
+    @classmethod
+    def _select(cls, value):
+        """Return the expected API representation of a single selection choice."""
+        if value is None:
+            return None
+        return {'value': value, 'label': cls.CHOICE_LABELS[value]}
+
+    @classmethod
+    def _multiselect(cls, values):
+        """Return the expected API representation of a multiple selection value."""
+        if values is None:
+            return None
+        return [cls._select(v) for v in values]
+
     def test_get_custom_fields(self):
         TYPES = {
             CustomFieldTypeChoices.TYPE_TEXT: 'string',
@@ -1015,14 +1033,86 @@ class CustomFieldAPITestCase(APITestCase):
         self.assertEqual(response.data['custom_fields']['datetime_field'], site2_cfvs['datetime_field'])
         self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
         self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field'])
-        self.assertEqual(response.data['custom_fields']['select_field'], site2_cfvs['select_field'])
-        self.assertEqual(response.data['custom_fields']['multiselect_field'], site2_cfvs['multiselect_field'])
+        self.assertEqual(response.data['custom_fields']['select_field'], self._select(site2_cfvs['select_field']))
+        self.assertEqual(
+            response.data['custom_fields']['multiselect_field'],
+            self._multiselect(site2_cfvs['multiselect_field'])
+        )
         self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field'].pk)
         self.assertEqual(
             [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
             [obj.pk for obj in site2_cfvs['multiobject_field']]
         )
 
+    def test_get_object_selection_field_representation(self):
+        """
+        Selection custom fields are rendered as an object exposing both the stored value and its
+        human-friendly label on read access (see #20897).
+        """
+        site2 = Site.objects.get(name='Site 2')
+        url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
+        self.add_permissions('dcim.view_site')
+
+        response = self.client.get(url, **self.header)
+
+        # A single selection value is rendered as a {value, label} object
+        self.assertEqual(response.data['custom_fields']['select_field'], {
+            'value': 'bar',
+            'label': 'Bar',
+        })
+
+        # A multiple selection value is rendered as a list of {value, label} objects
+        self.assertEqual(response.data['custom_fields']['multiselect_field'], [
+            {'value': 'bar', 'label': 'Bar'},
+            {'value': 'baz', 'label': 'Baz'},
+        ])
+
+    def test_get_object_selection_field_unresolved_label(self):
+        """
+        A stored selection value with no matching choice falls back to using the raw value as its label.
+        """
+        site2 = Site.objects.get(name='Site 2')
+        site2.custom_field_data['select_field'] = 'stale'
+        site2.save()
+        url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
+        self.add_permissions('dcim.view_site')
+
+        response = self.client.get(url, **self.header)
+        self.assertEqual(response.data['custom_fields']['select_field'], {
+            'value': 'stale',
+            'label': 'stale',
+        })
+
+    @tag('regression')
+    def test_update_selection_field_rejects_read_format(self):
+        """
+        Selection fields are written by passing the raw value; submitting the {value, label} read
+        representation must be rejected with a clean 400, not a 500 (see #20897).
+        """
+        site2 = Site.objects.get(name='Site 2')
+        url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
+        self.add_permissions('dcim.change_site')
+
+        # A single selection submitted as an object is rejected
+        response = self.client.patch(
+            url, {'custom_fields': {'select_field': {'value': 'foo', 'label': 'Foo'}}}, format='json', **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        # A multiple selection submitted as a list of objects is rejected (must not raise a TypeError/500)
+        response = self.client.patch(
+            url,
+            {'custom_fields': {'multiselect_field': [{'value': 'foo', 'label': 'Foo'}]}},
+            format='json',
+            **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        # The stored values are unchanged
+        site2.refresh_from_db()
+        self.assertEqual(site2.custom_field_data['select_field'], 'bar')
+        self.assertEqual(site2.custom_field_data['multiselect_field'], ['bar', 'baz'])
+
     def test_create_single_object_with_defaults(self):
         """
         Create a new site with no specified custom field values and check that it received the default values.
@@ -1051,8 +1141,8 @@ class CustomFieldAPITestCase(APITestCase):
         self.assertEqual(response_cf['datetime_field'].isoformat(), cf_defaults['datetime_field'])
         self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
         self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
-        self.assertEqual(response_cf['select_field'], cf_defaults['select_field'])
-        self.assertEqual(response_cf['multiselect_field'], cf_defaults['multiselect_field'])
+        self.assertEqual(response_cf['select_field'], self._select(cf_defaults['select_field']))
+        self.assertEqual(response_cf['multiselect_field'], self._multiselect(cf_defaults['multiselect_field']))
         self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
         self.assertEqual(
             [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
@@ -1099,7 +1189,7 @@ class CustomFieldAPITestCase(APITestCase):
             },
         }
         url = reverse('dcim-api:site-list')
-        self.add_permissions('dcim.add_site')
+        self.add_permissions('dcim.add_site', 'ipam.view_vlan')
 
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -1116,8 +1206,8 @@ class CustomFieldAPITestCase(APITestCase):
         self.assertEqual(response_cf['datetime_field'], data_cf['datetime_field'])
         self.assertEqual(response_cf['url_field'], data_cf['url_field'])
         self.assertEqual(response_cf['json_field'], data_cf['json_field'])
-        self.assertEqual(response_cf['select_field'], data_cf['select_field'])
-        self.assertEqual(response_cf['multiselect_field'], data_cf['multiselect_field'])
+        self.assertEqual(response_cf['select_field'], self._select(data_cf['select_field']))
+        self.assertEqual(response_cf['multiselect_field'], self._multiselect(data_cf['multiselect_field']))
         self.assertEqual(response_cf['object_field']['id'], data_cf['object_field'])
         self.assertEqual(
             [obj['id'] for obj in response_cf['multiobject_field']],
@@ -1182,8 +1272,8 @@ class CustomFieldAPITestCase(APITestCase):
             self.assertEqual(response_cf['datetime_field'].isoformat(), cf_defaults['datetime_field'])
             self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
             self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
-            self.assertEqual(response_cf['select_field'], cf_defaults['select_field'])
-            self.assertEqual(response_cf['multiselect_field'], cf_defaults['multiselect_field'])
+            self.assertEqual(response_cf['select_field'], self._select(cf_defaults['select_field']))
+            self.assertEqual(response_cf['multiselect_field'], self._multiselect(cf_defaults['multiselect_field']))
             self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
             self.assertEqual(
                 [obj['id'] for obj in response_cf['multiobject_field']],
@@ -1243,7 +1333,7 @@ class CustomFieldAPITestCase(APITestCase):
             },
         )
         url = reverse('dcim-api:site-list')
-        self.add_permissions('dcim.add_site')
+        self.add_permissions('dcim.add_site', 'ipam.view_vlan')
 
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -1262,8 +1352,11 @@ class CustomFieldAPITestCase(APITestCase):
             self.assertEqual(response_cf['datetime_field'], custom_field_data['datetime_field'])
             self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
             self.assertEqual(response_cf['json_field'], custom_field_data['json_field'])
-            self.assertEqual(response_cf['select_field'], custom_field_data['select_field'])
-            self.assertEqual(response_cf['multiselect_field'], custom_field_data['multiselect_field'])
+            self.assertEqual(response_cf['select_field'], self._select(custom_field_data['select_field']))
+            self.assertEqual(
+                response_cf['multiselect_field'],
+                self._multiselect(custom_field_data['multiselect_field'])
+            )
             self.assertEqual(response_cf['object_field']['id'], custom_field_data['object_field'])
             self.assertEqual(
                 [obj['id'] for obj in response_cf['multiobject_field']],
@@ -1316,8 +1409,8 @@ class CustomFieldAPITestCase(APITestCase):
         self.assertEqual(response_cf['datetime_field'], original_cfvs['datetime_field'])
         self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
         self.assertEqual(response_cf['json_field'], original_cfvs['json_field'])
-        self.assertEqual(response_cf['select_field'], original_cfvs['select_field'])
-        self.assertEqual(response_cf['multiselect_field'], original_cfvs['multiselect_field'])
+        self.assertEqual(response_cf['select_field'], self._select(original_cfvs['select_field']))
+        self.assertEqual(response_cf['multiselect_field'], self._multiselect(original_cfvs['multiselect_field']))
         self.assertEqual(response_cf['object_field']['id'], original_cfvs['object_field'].pk)
         self.assertListEqual(
             [obj['id'] for obj in response_cf['multiobject_field']],
@@ -1423,7 +1516,7 @@ class CustomFieldAPITestCase(APITestCase):
         site1 = Site.objects.get(name='Site 1')
         vlans = VLAN.objects.all()[:3]
         url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
-        self.add_permissions('dcim.change_site')
+        self.add_permissions('dcim.change_site', 'ipam.view_vlan')
 
         # Set related objects by PK
         data = {

+ 6 - 1
netbox/extras/tests/test_event_rules.py

@@ -31,6 +31,11 @@ from utilities.testing.mixins import RQQueueTestMixin
 
 
 class EventRuleTestCase(RQQueueTestMixin, APITestCase):
+    user_permissions = (
+        'dcim.add_site',
+        'dcim.change_site',
+        'extras.view_tag',
+    )
 
     def setUp(self):
         super().setUp()
@@ -573,7 +578,7 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
             'b_terminations': [{'object_type': 'dcim.interface', 'object_id': interface_b.pk}],
         }
         url = reverse('dcim-api:cable-list')
-        self.add_permissions('dcim.add_cable')
+        self.add_permissions('dcim.add_cable', 'dcim.view_interface')
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
 

+ 2 - 2
netbox/extras/tests/test_tags.py

@@ -22,7 +22,7 @@ class TaggedItemTestCase(APITestCase):
             'tags': [t.pk for t in tags]
         }
         url = reverse('dcim-api:site-list')
-        self.add_permissions('dcim.add_site')
+        self.add_permissions('dcim.add_site', 'extras.view_tag')
 
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -50,7 +50,7 @@ class TaggedItemTestCase(APITestCase):
                 {"name": "New Tag"},
             ]
         }
-        self.add_permissions('dcim.change_site')
+        self.add_permissions('dcim.change_site', 'extras.view_tag')
         url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
 
         response = self.client.patch(url, data, format='json', **self.header)

+ 10 - 32
netbox/ipam/forms/bulk_edit.py

@@ -1,6 +1,5 @@
 from django import forms
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist
 from django.utils.translation import gettext_lazy as _
 
 from dcim.forms.mixins import ScopedBulkEditForm
@@ -11,17 +10,16 @@ from ipam.models import *
 from ipam.models import ASN
 from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm
 from tenancy.models import Tenant
-from utilities.forms import add_blank_choice, get_field_value
+from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms.fields import (
-    ContentTypeChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
+    GenericObjectChoiceField,
     NumericArrayField,
     NumericRangeArrayField,
 )
 from utilities.forms.rendering import FieldSet
-from utilities.forms.widgets import BulkEditNullBooleanSelect, HTMXSelect
-from utilities.templatetags.builtins.filters import bettertitle
+from utilities.forms.widgets import BulkEditNullBooleanSelect
 
 __all__ = (
     'ASNBulkEditForm',
@@ -230,7 +228,7 @@ class PrefixBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
     fieldsets = (
         FieldSet('tenant', 'status', 'role', 'description'),
         FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')),
-        FieldSet('scope_type', 'scope', name=_('Scope')),
+        FieldSet('scope', name=_('Scope')),
         FieldSet('vlan_group', 'vlan', name=_('VLAN Assignment')),
     )
     nullable_fields = (
@@ -357,19 +355,13 @@ class FHRPGroupBulkEditForm(PrimaryModelBulkEditForm):
     nullable_fields = ('auth_type', 'auth_key', 'name', 'description', 'comments')
 
 
-class VLANGroupBulkEditForm(OrganizationalModelBulkEditForm):
-    scope_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
-        widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
-        required=False,
-        label=_('Scope type')
-    )
-    scope = DynamicModelChoiceField(
+class VLANGroupBulkEditForm(GenericObjectFormMixin, OrganizationalModelBulkEditForm):
+    scope = GenericObjectChoiceField(
         label=_('Scope'),
-        queryset=Site.objects.none(),  # Initial queryset
+        content_type_queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
         required=False,
-        disabled=True,
-        selector=True
+        selector=True,
+        hx_method='post',
     )
     vid_ranges = NumericRangeArrayField(
         label=_('VLAN ID ranges'),
@@ -384,25 +376,11 @@ class VLANGroupBulkEditForm(OrganizationalModelBulkEditForm):
     model = VLANGroup
     fieldsets = (
         FieldSet('site', 'vid_ranges', 'description'),
-        FieldSet('scope_type', 'scope', name=_('Scope')),
+        FieldSet('scope', name=_('Scope')),
         FieldSet('tenant', name=_('Tenancy')),
     )
     nullable_fields = ('description', 'scope', 'comments')
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        if scope_type_id := get_field_value(self, 'scope_type'):
-            try:
-                scope_type = ContentType.objects.get(pk=scope_type_id)
-                model = scope_type.model_class()
-                self.fields['scope'].queryset = model.objects.all()
-                self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
-                self.fields['scope'].disabled = False
-                self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
-            except ObjectDoesNotExist:
-                pass
-
 
 class VLANBulkEditForm(PrimaryModelBulkEditForm):
     region = DynamicModelChoiceField(

+ 34 - 106
netbox/ipam/forms/model_forms.py

@@ -1,6 +1,6 @@
 from django import forms
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.core.exceptions import ValidationError
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
@@ -13,18 +13,16 @@ from ipam.models import *
 from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 from tenancy.forms import TenancyForm
 from utilities.exceptions import PermissionsViolation
-from utilities.forms import add_blank_choice
+from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms.fields import (
-    ContentTypeChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
+    GenericObjectChoiceField,
     NumericArrayField,
     NumericRangeArrayField,
 )
 from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups
-from utilities.forms.utils import get_field_value
-from utilities.forms.widgets import DatePicker, HTMXSelect
-from utilities.templatetags.builtins.filters import bettertitle
+from utilities.forms.widgets import DatePicker
 from virtualization.models import VirtualMachine, VMInterface
 
 __all__ = (
@@ -215,7 +213,7 @@ class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm):
         required=False,
         selector=True,
         query_params={
-            'available_at_site': '$scope',
+            'available_at_site': '$scope_object_id',
         },
         label=_('VLAN'),
     )
@@ -230,7 +228,7 @@ class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm):
         FieldSet(
             'prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix')
         ),
-        FieldSet('scope_type', 'scope', name=_('Scope'), html_id='scope'),
+        FieldSet('scope', name=_('Scope'), html_id='scope'),
         FieldSet('vlan', name=_('VLAN Assignment')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
@@ -238,16 +236,16 @@ class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm):
     class Meta:
         model = Prefix
         fields = [
-            'prefix', 'vrf', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'scope_type', 'tenant_group',
+            'prefix', 'vrf', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'tenant_group',
             'tenant', 'description', 'owner', 'comments', 'tags',
         ]
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        # #18605: only filter VLAN select list if scope field is a Site
+        # #18605: only filter VLAN select list if the selected scope is a Site (or none is selected yet)
         if scope_field := self.fields.get('scope', None):
-            if scope_field.queryset.model is not Site:
+            if scope_field.selected_model not in (None, Site):
                 self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None)
 
 
@@ -262,7 +260,7 @@ class PrefixBulkAddForm(PrefixForm):
         FieldSet(
             'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix')
         ),
-        FieldSet('scope_type', 'scope', name=_('Scope'), html_id='scope'),
+        FieldSet('scope', name=_('Scope'), html_id='scope'),
         FieldSet('vlan', name=_('VLAN Assignment')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
@@ -620,68 +618,32 @@ class FHRPGroupAssignmentForm(forms.ModelForm):
         return group
 
 
-class VLANGroupForm(TenancyForm, OrganizationalModelForm):
+class VLANGroupForm(GenericObjectFormMixin, TenancyForm, OrganizationalModelForm):
     vid_ranges = NumericRangeArrayField(
         label=_('VLAN IDs')
     )
-    scope_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
-        widget=HTMXSelect(hx_target_id='scope'),
-        required=False,
-        label=_('Scope type')
-    )
-    scope = DynamicModelChoiceField(
+    scope = GenericObjectChoiceField(
         label=_('Scope'),
-        queryset=Site.objects.none(),  # Initial queryset
+        content_type_queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
         required=False,
-        disabled=True,
-        selector=True
+        selector=True,
+        hx_target_id='scope',
     )
 
     fieldsets = (
         FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')),
         FieldSet('vid_ranges', name=_('Child VLANs')),
-        FieldSet('scope_type', 'scope', name=_('Scope'), html_id='scope'),
+        FieldSet('scope', name=_('Scope'), html_id='scope'),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
 
     class Meta:
         model = VLANGroup
         fields = [
-            'name', 'slug', 'description', 'vid_ranges', 'scope_type', 'tenant_group', 'tenant', 'owner', 'comments',
+            'name', 'slug', 'description', 'vid_ranges', 'tenant_group', 'tenant', 'owner', 'comments',
             'tags',
         ]
 
-    def __init__(self, *args, **kwargs):
-        instance = kwargs.get('instance')
-        initial = kwargs.get('initial', {})
-
-        if instance is not None and instance.scope:
-            initial['scope'] = instance.scope
-            kwargs['initial'] = initial
-
-        super().__init__(*args, **kwargs)
-
-        if scope_type_id := get_field_value(self, 'scope_type'):
-            try:
-                scope_type = ContentType.objects.get(pk=scope_type_id)
-                model = scope_type.model_class()
-                self.fields['scope'].queryset = model.objects.all()
-                self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
-                self.fields['scope'].disabled = False
-                self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
-            except ObjectDoesNotExist:
-                pass
-
-            if self.instance and scope_type_id != self.instance.scope_type_id:
-                self.initial['scope'] = None
-
-    def clean(self):
-        super().clean()
-
-        # Assign the selected scope (if any)
-        self.instance.scope = self.cleaned_data.get('scope')
-
 
 class VLANForm(TenancyForm, PrimaryModelForm):
     group = DynamicModelChoiceField(
@@ -798,19 +760,13 @@ class ServiceTemplateForm(PrimaryModelForm):
         fields = ('name', 'protocol', 'ports', 'description', 'owner', 'comments', 'tags')
 
 
-class ServiceForm(PrimaryModelForm):
-    parent_object_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS),
-        widget=HTMXSelect(hx_target_id='service'),
-        required=True,
-        label=_('Parent type')
-    )
-    parent = DynamicModelChoiceField(
+class ServiceForm(GenericObjectFormMixin, PrimaryModelForm):
+    parent = GenericObjectChoiceField(
         label=_('Parent'),
-        queryset=Device.objects.none(),  # Initial queryset
+        content_type_queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS),
         required=True,
-        disabled=True,
-        selector=True
+        selector=True,
+        hx_target_id='service',
     )
     ports = NumericArrayField(
         label=_('Ports'),
@@ -828,7 +784,7 @@ class ServiceForm(PrimaryModelForm):
 
     fieldsets = (
         FieldSet(
-            'parent_object_type', 'parent', 'name',
+            'parent', 'name',
             InlineFields('protocol', 'ports', label=_('Port(s)')),
             'ipaddresses', 'description', 'tags', name=_('Application Service'),
             html_id='service',
@@ -839,48 +795,20 @@ class ServiceForm(PrimaryModelForm):
         model = Service
         fields = [
             'name', 'protocol', 'ports', 'ipaddresses', 'description', 'owner', 'comments', 'tags',
-            'parent_object_type',
         ]
 
     def __init__(self, *args, **kwargs):
-        initial = kwargs.get('initial', {}).copy()
-
-        if (instance := kwargs.get('instance', None)) and instance.parent:
-            initial['parent'] = instance.parent
-
-        kwargs['initial'] = initial
-
         super().__init__(*args, **kwargs)
 
-        if parent_object_type_id := get_field_value(self, 'parent_object_type'):
-            try:
-                parent_type = ContentType.objects.get(pk=parent_object_type_id)
-                model = parent_type.model_class()
-                if model == Device:
-                    self.fields['ipaddresses'].widget.add_query_params({
-                        'device_id': '$parent',
-                    })
-                elif model == VirtualMachine:
-                    self.fields['ipaddresses'].widget.add_query_params({
-                        'virtual_machine_id': '$parent',
-                    })
-                elif model == FHRPGroup:
-                    self.fields['ipaddresses'].widget.add_query_params({
-                        'fhrpgroup_id': '$parent',
-                    })
-                self.fields['parent'].queryset = model.objects.all()
-                self.fields['parent'].widget.attrs['selector'] = model._meta.label_lower
-                self.fields['parent'].disabled = False
-                self.fields['parent'].label = _(bettertitle(model._meta.verbose_name))
-            except ObjectDoesNotExist:
-                pass
-
-            if self.instance and self.instance.pk and parent_object_type_id != self.instance.parent_object_type_id:
-                self.initial['parent'] = None
-
-    def clean(self):
-        super().clean()
-        self.instance.parent = self.cleaned_data.get('parent')
+        # Filter the IP address selector to those belonging to the selected parent. The object subwidget is
+        # named "parent_object_id", so the dynamic param references "$parent_object_id".
+        parent_model = self.fields['parent'].selected_model
+        if parent_model is Device:
+            self.fields['ipaddresses'].widget.add_query_params({'device_id': '$parent_object_id'})
+        elif parent_model is VirtualMachine:
+            self.fields['ipaddresses'].widget.add_query_params({'virtual_machine_id': '$parent_object_id'})
+        elif parent_model is FHRPGroup:
+            self.fields['ipaddresses'].widget.add_query_params({'fhrpgroup_id': '$parent_object_id'})
 
 
 class ServiceCreateForm(ServiceForm):
@@ -892,7 +820,7 @@ class ServiceCreateForm(ServiceForm):
 
     fieldsets = (
         FieldSet(
-            'parent_object_type', 'parent',
+            'parent',
             TabbedGroups(
                 FieldSet('service_template', name=_('From Template')),
                 FieldSet('name', 'protocol', 'ports', name=_('Custom')),
@@ -905,7 +833,7 @@ class ServiceCreateForm(ServiceForm):
     class Meta(ServiceForm.Meta):
         fields = [
             'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
-            'comments', 'tags', 'parent_object_type',
+            'comments', 'tags',
         ]
 
     def __init__(self, *args, **kwargs):

+ 16 - 16
netbox/ipam/migrations/0084_owner.py

@@ -13,112 +13,112 @@ class Migration(migrations.Migration):
             model_name='aggregate',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='asn',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='asnrange',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='fhrpgroup',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='ipaddress',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='iprange',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='prefix',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='rir',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='role',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='routetarget',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='service',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='servicetemplate',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='vlan',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='vlangroup',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='vlantranslationpolicy',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='vrf',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
     ]

+ 1 - 1
netbox/ipam/models/ip.py

@@ -284,7 +284,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
     objects = PrefixQuerySet.as_manager()
 
     clone_fields = (
-        'scope_type', 'scope_id', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
+        'scope', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
     )
 
     class Meta:

+ 1 - 1
netbox/ipam/models/services.py

@@ -99,7 +99,7 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel):
     )
 
     clone_fields = (
-        'protocol', 'ports', 'description', 'parent_object_type', 'parent_object_id', 'ipaddresses',
+        'protocol', 'ports', 'description', 'parent', 'ipaddresses',
     )
 
     class Meta:

+ 1 - 1
netbox/ipam/tables/template_code.py

@@ -6,7 +6,7 @@ PREFIX_LINK = """
 {% if record.pk %}
   <a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
 {% else %}
-  <a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.scope %}&scope_type={{ object.scope_type.pk }}&scope={{ object.scope.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
+  <a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.scope %}&scope_content_type={{ object.scope_type.pk }}&scope_object_id={{ object.scope.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
 {% endif %}
 """
 

+ 49 - 7
netbox/ipam/tests/test_api.py

@@ -6,10 +6,12 @@ from django.urls import reverse
 from netaddr import IPNetwork
 from rest_framework import status
 
+from core.models import ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
 from ipam.choices import *
 from ipam.models import *
 from tenancy.models import Tenant
+from users.models import ObjectPermission
 from utilities.data import string_to_ranges
 from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging
 
@@ -99,7 +101,7 @@ class ASNRangeTestCase(APIViewTestCases.APIViewTestCase):
         rir = RIR.objects.first()
         asnrange = ASNRange.objects.create(name='Range 1', slug='range-1', rir=rir, start=101, end=110)
         url = reverse('ipam-api:asnrange-available-asns', kwargs={'pk': asnrange.pk})
-        self.add_permissions('ipam.view_asnrange', 'ipam.add_asn')
+        self.add_permissions('ipam.view_asnrange', 'ipam.add_asn', 'ipam.view_rir')
 
         data = {
             'description': 'New ASN'
@@ -116,7 +118,7 @@ class ASNRangeTestCase(APIViewTestCases.APIViewTestCase):
         rir = RIR.objects.first()
         asnrange = ASNRange.objects.create(name='Range 1', slug='range-1', rir=rir, start=101, end=110)
         url = reverse('ipam-api:asnrange-available-asns', kwargs={'pk': asnrange.pk})
-        self.add_permissions('ipam.view_asnrange', 'ipam.add_asn')
+        self.add_permissions('ipam.view_asnrange', 'ipam.add_asn', 'ipam.view_rir')
 
         # Try to create eleven ASNs (only ten are available)
         data = [
@@ -488,7 +490,7 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
         vrf = VRF.objects.create(name='VRF 1')
         prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
         url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
-        self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
+        self.add_permissions('ipam.view_prefix', 'ipam.add_prefix', 'ipam.view_vrf')
 
         # Create four available prefixes with individual requests
         prefixes_to_be_created = [
@@ -525,7 +527,7 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
         vrf = VRF.objects.create(name='VRF 1')
         prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
         url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
-        self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
+        self.add_permissions('ipam.view_prefix', 'ipam.add_prefix', 'ipam.view_vrf')
 
         # Try to create five /30s (only four are available)
         data = [
@@ -576,7 +578,7 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
         vrf = VRF.objects.create(name='VRF 1')
         prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), vrf=vrf, is_pool=True)
         url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
-        self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress')
+        self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress', 'ipam.view_vrf')
 
         # Create all four available IPs with individual requests
         for i in range(1, 5):
@@ -600,7 +602,7 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
         vrf = VRF.objects.create(name='VRF 1')
         prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), vrf=vrf, is_pool=True)
         url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
-        self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress')
+        self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress', 'ipam.view_vrf')
 
         # Try to create nine IPs (only eight are available)
         data = [{'description': f'Test IP {i}'} for i in range(1, 10)]  # 9 IPs
@@ -727,7 +729,7 @@ class IPRangeTestCase(APIViewTestCases.APIViewTestCase):
             vrf=vrf
         )
         url = reverse('ipam-api:iprange-available-ips', kwargs={'pk': iprange.pk})
-        self.add_permissions('ipam.view_iprange', 'ipam.add_ipaddress')
+        self.add_permissions('ipam.view_iprange', 'ipam.add_ipaddress', 'ipam.view_vrf')
 
         # Create all three available IPs with individual requests
         for i in range(1, 4):
@@ -1501,3 +1503,43 @@ class ServiceTestCase(APIViewTestCases.APIViewTestCase):
                 'ports': [6],
             },
         ]
+
+
+class NestedObjectPermissionAPITest(APITestCase):
+    """
+    End-to-end: a constrained view permission on a related model is enforced when that object is
+    referenced via a nested serializer on write, so an object outside the user's constraint is
+    rejected as though it did not exist (the #21988 vector).
+    """
+    model = Prefix
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.tenants = Tenant.objects.bulk_create((
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+        ))
+
+    def test_create_rejects_related_object_outside_constraint(self):
+        self.add_permissions('ipam.add_prefix')
+        obj_perm = ObjectPermission(
+            name='View Tenant 1 only', actions=['view'], constraints={'pk': self.tenants[0].pk}
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Tenant))
+
+        url = self._get_list_url()
+
+        # The viewable tenant resolves and the prefix is created
+        response = self.client.post(
+            url, {'prefix': '198.51.100.0/24', 'tenant': self.tenants[0].pk}, format='json', **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
+        # The tenant outside the constraint fails resolution as a nonexistent object would
+        response = self.client.post(
+            url, {'prefix': '198.51.101.0/24', 'tenant': self.tenants[1].pk}, format='json', **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('tenant', response.data)

+ 6 - 5
netbox/ipam/tests/test_forms.py

@@ -8,7 +8,7 @@ from ipam.forms.bulk_import import IPAddressImportForm
 
 
 class PrefixFormTestCase(TestCase):
-    default_dynamic_params = '[{"fieldName":"scope","queryParam":"available_at_site"}]'
+    default_dynamic_params = '[{"fieldName":"scope_object_id","queryParam":"available_at_site"}]'
 
     @classmethod
     def setUpTestData(cls):
@@ -23,8 +23,8 @@ class PrefixFormTestCase(TestCase):
     def test_vlan_field_sets_dynamic_params_for_scope_site(self):
         """data-dynamic-params present when scope type is Site and when scope is specifc site"""
         form = PrefixForm(data={
-            'scope_type': ContentType.objects.get_for_model(Site).id,
-            'scope': self.site,
+            'scope_content_type': ContentType.objects.get_for_model(Site).id,
+            'scope_object_id': self.site.pk,
         })
 
         assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == self.default_dynamic_params
@@ -37,9 +37,10 @@ class PrefixFormTestCase(TestCase):
             SiteGroup(name='Site Group 1', slug='site-group-1'),
         ]
         for case in cases:
+            case.save()
             form = PrefixForm(data={
-                'scope_type': ContentType.objects.get_for_model(case._meta.model).id,
-                'scope': case,
+                'scope_content_type': ContentType.objects.get_for_model(case._meta.model).id,
+                'scope_object_id': case.pk,
             })
 
             assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs

+ 59 - 6
netbox/ipam/tests/test_views.py

@@ -530,8 +530,8 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
         cls.form_data = {
             'prefix': IPNetwork('192.0.2.0/24'),
-            'scope_type': ContentType.objects.get_for_model(Site).pk,
-            'scope': sites[1].pk,
+            'scope_content_type': ContentType.objects.get_for_model(Site).pk,
+            'scope_object_id': sites[1].pk,
             'vrf': vrfs[1].pk,
             'tenant': None,
             'vlan': None,
@@ -574,6 +574,38 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'description': 'New description',
         }
 
+    def test_bulk_edit_htmx_dependent_field_refresh_skips_validation(self):
+        """An HTMX content-type change (no _apply) re-renders the bulk-edit form without validation errors."""
+        prefix = Prefix.objects.create(prefix=IPNetwork('10.99.0.0/24'))
+        self.add_permissions('ipam.view_prefix', 'ipam.change_prefix')
+
+        data = {
+            'pk': [prefix.pk],
+            'scope_content_type': ContentType.objects.get_for_model(Site).pk,
+            # The client-side hx-on::config-request clears the paired object id on a type change.
+            'scope_object_id': '',
+        }
+        response = self.client.post(self._get_url('bulk_edit'), data, headers={'HX-Request': 'true'})
+        self.assertHttpStatus(response, 200)
+        self.assertNotContains(response, 'Please select a site')
+        # The object selector is rebuilt for the new type rather than erroring out.
+        self.assertContains(response, 'name="scope_object_id"')
+        self.assertContains(response, 'data-url="/api/dcim/sites/"')
+
+    def test_bulk_edit_apply_still_validates_incomplete_scope(self):
+        """A real apply with a content type but no object still surfaces the validation error."""
+        prefix = Prefix.objects.create(prefix=IPNetwork('10.99.1.0/24'))
+        self.add_permissions('ipam.view_prefix', 'ipam.change_prefix')
+
+        data = {
+            'pk': [prefix.pk],
+            '_apply': '1',
+            'scope_content_type': ContentType.objects.get_for_model(Site).pk,
+        }
+        response = self.client.post(self._get_url('bulk_edit'), data)
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, 'Please select a site')
+
     def test_bulk_add_ipv4_prefixes(self):
         """Test bulk creating IPv4 prefixes using a pattern."""
         self.add_permissions('ipam.view_prefix')
@@ -687,6 +719,23 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('ipam:prefix_prefixes', kwargs={'pk': prefixes[0].pk})
         self.assertHttpStatus(self.client.get(url), 200)
 
+    def test_prefix_prefixes_add_links_include_scope_params(self):
+        """Child-prefix Add links pre-populate scope via the GenericObjectChoiceField subwidget params."""
+        self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
+
+        site = Site.objects.create(name='Scope Site', slug='scope-site')
+        parent = Prefix.objects.create(prefix=IPNetwork('203.0.113.0/24'), scope=site)
+        Prefix.objects.create(prefix=IPNetwork('203.0.113.0/26'), scope=site)
+
+        url = reverse('ipam:prefix_prefixes', kwargs={'pk': parent.pk})
+        response = self.client.get(url)
+
+        self.assertHttpStatus(response, 200)
+        scope_ct = ContentType.objects.get_for_model(Site)
+        # The new GenericObjectChoiceField reads these subwidget-named query params.
+        self.assertContains(response, f'scope_content_type={scope_ct.pk}')
+        self.assertContains(response, f'scope_object_id={site.pk}')
+
     def test_prefix_prefixes_filter_suppresses_available_prefixes(self):
         self.add_permissions('ipam.view_prefix')
 
@@ -1340,6 +1389,8 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             'slug': 'vlan-group-x',
             'description': 'A new VLAN group',
             'vid_ranges': '100-199,300-399',
+            'scope_content_type': ContentType.objects.get_for_model(Site).pk,
+            'scope_object_id': sites[1].pk,
             'tags': [t.pk for t in tags],
         }
 
@@ -1367,6 +1418,8 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 
         cls.bulk_edit_data = {
             'description': 'New description',
+            'scope_content_type': ContentType.objects.get_for_model(Site).pk,
+            'scope_object_id': sites[1].pk,
         }
 
     def test_vlans_filter_suppresses_available_vlans(self):
@@ -1868,8 +1921,8 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
         cls.form_data = {
-            'parent_object_type': ContentType.objects.get_for_model(Device).pk,
-            'parent': device.pk,
+            'parent_content_type': ContentType.objects.get_for_model(Device).pk,
+            'parent_object_id': device.pk,
             'name': 'Service X',
             'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
             'ports': '104,105',
@@ -1978,8 +2031,8 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         request = {
             'path': self._get_url('add'),
             'data': {
-                'parent_object_type': ContentType.objects.get_for_model(Device).pk,
-                'parent': device.pk,
+                'parent_content_type': ContentType.objects.get_for_model(Device).pk,
+                'parent_object_id': device.pk,
                 'service_template': service_template.pk,
             },
         }

+ 2 - 2
netbox/ipam/views.py

@@ -1729,10 +1729,10 @@ class VLANView(generic.ObjectView):
                         'ipam.prefix',
                         url_params={
                             'tenant': lambda ctx: ctx['object'].tenant_id,
-                            'scope_type': lambda ctx: (
+                            'scope_content_type': lambda ctx: (
                                 ContentType.objects.get_for_model(Site).pk if ctx['object'].site_id else None
                             ),
-                            'scope': lambda ctx: ctx['object'].site_id,
+                            'scope_object_id': lambda ctx: ctx['object'].site_id,
                             'vlan': lambda ctx: ctx['object'].pk,
                         },
                         label=_('Add a Prefix'),

+ 16 - 3
netbox/netbox/api/fields.py

@@ -10,6 +10,7 @@ from rest_framework.exceptions import ValidationError
 from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
 
 from utilities.data import get_inclusive_integer_range_bounds
+from utilities.permissions import restrict_queryset
 
 __all__ = (
     'AttributesField',
@@ -18,6 +19,7 @@ __all__ = (
     'IPNetworkSerializer',
     'IntegerRangeSerializer',
     'RelatedObjectCountField',
+    'RestrictedPrimaryKeyRelatedField',
     'SerializedPKRelatedField',
 )
 
@@ -133,10 +135,21 @@ class IPNetworkSerializer(serializers.Serializer):
         return IPNetwork(value)
 
 
-class SerializedPKRelatedField(PrimaryKeyRelatedField):
+class RestrictedPrimaryKeyRelatedField(PrimaryKeyRelatedField):
     """
-    Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related
-    objects in a ManyToManyField while still allowing a set of primary keys to be written.
+    PrimaryKeyRelatedField that resolves write input from a permission-restricted queryset, so a
+    referenced object the user cannot view fails validation as though it did not exist. It represents the
+    object by its bare PK on read; SerializedPKRelatedField extends it to return a serialized object instead.
+    """
+    def get_queryset(self):
+        return restrict_queryset(super().get_queryset(), self.context.get('request'))
+
+
+class SerializedPKRelatedField(RestrictedPrimaryKeyRelatedField):
+    """
+    Extends RestrictedPrimaryKeyRelatedField to return a serialized object on read, inheriting its
+    permission-restricted resolution of write input. This is useful for representing related objects in a
+    ManyToManyField while still allowing a set of primary keys to be written.
     """
     def __init__(self, serializer, nested=False, **kwargs):
         self.serializer = serializer

+ 12 - 3
netbox/netbox/api/serializers/base.py

@@ -5,6 +5,7 @@ from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from utilities.api import get_related_object_by_attrs
+from utilities.permissions import restrict_queryset
 
 from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField
 
@@ -43,13 +44,21 @@ class BaseModelSerializer(serializers.ModelSerializer):
 
         super().__init__(*args, **kwargs)
 
-    def to_internal_value(self, data):
+    def get_related_object_queryset(self):
+        """
+        Return the queryset used to resolve a related object supplied via nested
+        serializer input.
+        """
+        return restrict_queryset(self.Meta.model.objects.all(), self.context.get('request'))
 
+    def to_internal_value(self, data):
+        """
+        Override to_internal_value() to handle nested serializer input.
+        """
         # If initialized as a nested serializer, we should expect to receive the attrs or PK
         # identifying a related object.
         if self.nested:
-            queryset = self.Meta.model.objects.all()
-            return get_related_object_by_attrs(queryset, data)
+            return get_related_object_by_attrs(self.get_related_object_queryset(), data)
 
         return super().to_internal_value(data)
 

+ 4 - 2
netbox/netbox/api/serializers/generic.py

@@ -4,8 +4,9 @@ from rest_framework import serializers
 
 from core.models import ObjectType
 from netbox.api.fields import ContentTypeField
-from utilities.api import get_serializer_for_model
+from utilities.api import get_related_object_by_attrs, get_serializer_for_model
 from utilities.object_types import object_type_identifier
+from utilities.permissions import restrict_queryset
 
 __all__ = (
     'GenericObjectSerializer',
@@ -25,7 +26,8 @@ class GenericObjectSerializer(serializers.Serializer):
     def to_internal_value(self, data):
         data = super().to_internal_value(data)
         model = data['object_type'].model_class()
-        return model.objects.get(pk=data['object_id'])
+        queryset = restrict_queryset(model.objects.all(), self.context.get('request'))
+        return get_related_object_by_attrs(queryset, data['object_id'])
 
     def to_representation(self, instance):
         object_type = ObjectType.objects.get_for_model(instance)

+ 1 - 2
netbox/netbox/api/serializers/nested.py

@@ -16,8 +16,7 @@ class WritableNestedSerializer(BaseModelSerializer):
     subclassed to return a full representation of the related object on read.
     """
     def to_internal_value(self, data):
-        queryset = self.Meta.model.objects.all()
-        return get_related_object_by_attrs(queryset, data)
+        return get_related_object_by_attrs(self.get_related_object_queryset(), data)
 
 
 # Declared here for use by PrimaryModelSerializer

+ 14 - 7
netbox/netbox/models/features.py

@@ -154,7 +154,21 @@ class CloningMixin(models.Model):
 
         for field_name in getattr(self, 'clone_fields', []):
             field = self._meta.get_field(field_name)
+
+            # A GenericForeignKey is cloned under the subwidget names the creation form's
+            # GenericObjectChoiceField expects (e.g. scope_content_type / scope_object_id).
+            if isinstance(field, GenericForeignKey):
+                content_type_id = getattr(self, f'{field.ct_field}_id', None)
+                object_id = getattr(self, field.fk_field, None)
+
+                if content_type_id not in (None, '') and object_id not in (None, ''):
+                    attrs[f'{field.name}_content_type'] = content_type_id
+                    attrs[f'{field.name}_object_id'] = object_id
+
+                continue
+
             field_value = field.value_from_object(self)
+
             if field_value and isinstance(field, models.ManyToManyField):
                 attrs[field_name] = [v.pk for v in field_value]
             elif field_value and isinstance(field, models.JSONField):
@@ -162,13 +176,6 @@ class CloningMixin(models.Model):
             elif field_value not in (None, ''):
                 attrs[field_name] = field_value
 
-        # Handle GenericForeignKeys. If the CT and ID fields are being cloned, also
-        # include the name of the GFK attribute itself, as this is what forms expect.
-        for field in self._meta.private_fields:
-            if isinstance(field, GenericForeignKey):
-                if field.ct_field in attrs and field.fk_field in attrs:
-                    attrs[field.name] = attrs[field.fk_field]
-
         # Include tags (if applicable)
         if is_taggable(self):
             attrs['tags'] = [tag.pk for tag in self.tags.all()]

+ 1 - 0
netbox/netbox/models/mixins.py

@@ -19,6 +19,7 @@ class OwnerMixin(models.Model):
     owner = models.ForeignKey(
         to='users.Owner',
         on_delete=models.PROTECT,
+        related_name='+',
         blank=True,
         null=True
     )

+ 155 - 13
netbox/netbox/search/backends.py

@@ -3,9 +3,8 @@ from collections import defaultdict
 
 import netaddr
 from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ImproperlyConfigured
-from django.db import ProgrammingError
+from django.db import DatabaseError, ProgrammingError, transaction
 from django.db.models import F, Q, Window, prefetch_related_objects
 from django.db.models.fields.related import ForeignKey
 from django.db.models.functions import window
@@ -22,6 +21,7 @@ from utilities.querysets import RestrictedPrefetch
 from utilities.string import title
 
 from . import FieldTypes, LookupTypes, get_indexer
+from .deferred import OP_CACHE, OP_REMOVE, mark_for_deferred_indexing
 
 DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
 MAX_RESULTS = 1000
@@ -31,7 +31,8 @@ logger = logging.getLogger(__name__)
 
 class SearchBackend:
     """
-    Base class for search backends. Subclasses must extend the `cache()`, `remove()`, and `clear()` methods below.
+    Base class for search backends. Subclasses must extend the `cache()`, `remove()`, and `clear()`
+    methods below.
     """
     _object_types = None
 
@@ -63,6 +64,11 @@ class SearchBackend:
         """
         raise NotImplementedError
 
+    # caching_handler() and removal_handler() are the default, synchronous signal receivers connected
+    # to post_save/post_delete at module load. They are internal plumbing for signal dispatch, not a
+    # documented extension point: the public backend contract is cache()/remove()/clear(). A backend
+    # that needs to do something other than index inline (e.g. defer the work) overrides these in its
+    # subclass; see CachedValueSearchBackend.
     def caching_handler(self, sender, instance, created, **kwargs):
         """
         Receiver for the post_save signal, responsible for caching object creation/changes.
@@ -115,6 +121,50 @@ class SearchBackend:
 
 class CachedValueSearchBackend(SearchBackend):
 
+    # These override the base's synchronous receivers to defer indexing past the response. They are
+    # the seam where this backend captures the `using` alias Django passes to post_save/post_delete:
+    # the deferred write runs after the transaction commits (and possibly in a worker), by which point
+    # the originating routing context is gone, so the alias must be captured here and replayed on the
+    # deferred write to keep cache entries in the originating schema (e.g. a branch schema under
+    # netbox-branching). Deferral is internal to this backend; the public contract is unchanged.
+    def caching_handler(self, sender, instance, created, using=None, **kwargs):
+        """
+        Receiver for the post_save signal, responsible for caching object creation/changes.
+        """
+        # Skip non-cacheable objects without scheduling any deferred work.
+        try:
+            indexer = get_indexer(instance)
+        except KeyError:
+            return
+
+        try:
+            object_type = ObjectType.objects.get_for_model(indexer.model)
+        except ProgrammingError as e:
+            # The schema may be incomplete during migrations; skip caching.
+            logger.warning(f"Skipping search cache update due to schema error: {e}")
+            return
+
+        mark_for_deferred_indexing(object_type.pk, instance.pk, OP_CACHE, using=using)
+
+    def removal_handler(self, sender, instance, using=None, **kwargs):
+        """
+        Receiver for the post_delete signal, responsible for caching object deletion.
+        """
+        # Skip non-cacheable objects without scheduling any deferred work.
+        try:
+            indexer = get_indexer(instance)
+        except KeyError:
+            return
+
+        try:
+            object_type = ObjectType.objects.get_for_model(indexer.model)
+        except ProgrammingError as e:
+            # The schema may be incomplete during migrations; skip caching.
+            logger.warning(f"Skipping search cache update due to schema error: {e}")
+            return
+
+        mark_for_deferred_indexing(object_type.pk, instance.pk, OP_REMOVE, using=using)
+
     def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE):
 
         # Build the filter used to find relevant CachedValue records
@@ -196,13 +246,26 @@ class CachedValueSearchBackend(SearchBackend):
 
         return ret
 
-    def cache(self, instances, indexer=None, remove_existing=True):
+    # `using` here is a PostgreSQL/schema concern specific to this backend's deferred-write path (it
+    # replays the originating alias so branch writes land in the branch schema). It is deliberately
+    # NOT on the base cache()/remove() contract: a non-PostgreSQL backend (Redis, Solr, etc.) has no
+    # such concept. Do not lift `using` onto the base for symmetry; doing so would leak this backend's
+    # storage model into the generic contract.
+    def cache(self, instances, indexer=None, remove_existing=True, using=None):
         custom_fields = None
 
         # Convert a single instance to an iterable
         if not hasattr(instances, '__iter__'):
             instances = [instances]
 
+        # Determine the queryset manager used to write cache entries. When a
+        # database alias is provided (e.g. by a deferred task replaying the alias
+        # the originating write used), entries are written to that connection;
+        # otherwise the configured router decides. `using` is expected to be a
+        # concrete alias or falsy (None) per the caller's contract; a falsy value
+        # defers to the router, which is the correct behavior either way.
+        manager = CachedValue.objects.using(using) if using else CachedValue.objects
+
         buffer = []
         counter = 0
         for instance in instances:
@@ -225,7 +288,7 @@ class CachedValueSearchBackend(SearchBackend):
 
             # Wipe out any previously cached values for the object
             if remove_existing:
-                self.remove(instance)
+                self.remove(instance, using=using)
 
             # Generate cache data
             object_type = ObjectType.objects.get_for_model(indexer.model)
@@ -243,27 +306,106 @@ class CachedValueSearchBackend(SearchBackend):
 
             # Check whether the buffer needs to be flushed
             if len(buffer) >= 2000:
-                counter += len(CachedValue.objects.bulk_create(buffer))
+                counter += len(manager.bulk_create(buffer))
                 buffer = []
 
         # Final buffer flush
         if buffer:
-            counter += len(CachedValue.objects.bulk_create(buffer))
+            counter += len(manager.bulk_create(buffer))
 
         return counter
 
-    def remove(self, instance):
+    def _remove_by_id(self, object_type_id, object_ids, using=None):
+        """
+        Delete cached values for the given content type and object IDs using a
+        single raw DELETE. Shared by remove() and the deferred search task.
+        """
+        if not object_ids:
+            return None
+
+        qs = CachedValue.objects.filter(object_type_id=object_type_id, object_id__in=object_ids)
+
+        # Call _raw_delete() on the queryset to avoid first loading instances into memory
+        return qs._raw_delete(using=using or qs.db)
+
+    def remove(self, instance, using=None):
         # Avoid attempting to query for non-cacheable objects
         try:
-            get_indexer(instance)
+            indexer = get_indexer(instance)
         except KeyError:
             return None
 
-        ct = ContentType.objects.get_for_model(instance)
-        qs = CachedValue.objects.filter(object_type=ct, object_id=instance.pk)
+        # Use the indexer's (concrete) model to resolve the object type, matching
+        # the content type that cache() writes entries under.
+        object_type = ObjectType.objects.get_for_model(indexer.model)
 
-        # Call _raw_delete() on the queryset to avoid first loading instances into memory
-        return qs._raw_delete(using=qs.db)
+        return self._remove_by_id(object_type.pk, [instance.pk], using=using)
+
+    # Postgres SQLSTATEs indicating the target schema/table no longer exists. This happens when a
+    # branch is merged or deprovisioned (its schema dropped) between the time an update was enqueued
+    # and when it is applied. Such errors are expected and safe to skip; the index is rebuilt on the
+    # next reindex. Any other DatabaseError (e.g. a deadlock or lost connection) is transient and must
+    # propagate so the work fails visibly, rather than silently dropping index updates.
+    _MISSING_SCHEMA_SQLSTATES = frozenset((
+        '3F000',  # invalid_schema_name
+        '42P01',  # undefined_table
+    ))
+
+    def _is_missing_schema(self, exc):
+        """
+        Return True if the given DatabaseError was caused by the target schema/table no longer existing
+        (vs. a transient error that should propagate).
+        """
+        sqlstate = getattr(getattr(exc, '__cause__', None), 'sqlstate', None)
+        return sqlstate in self._MISSING_SCHEMA_SQLSTATES
+
+    def _apply_deferred_updates(self, using=None, cache_groups=None, remove_groups=None, log=logger):
+        """
+        Apply a coalesced batch of updates to the search cache. Private to this backend; called by the
+        deferred-flush machinery (netbox.search.deferred) and the background job
+        (netbox.search.jobs.SearchCacheJob), not part of the public backend contract.
+
+        The `using` alias captured when each object was saved/deleted is replayed here so entries are
+        written to the originating database/schema (e.g. a branch schema under netbox-branching),
+        regardless of any routing context that is no longer active by the time this runs.
+        """
+        # Removals are a single DELETE per content type, so (unlike the cache loop below) there is no
+        # multi-step state to wrap in a transaction. A transient error here propagates and errors the
+        # caller; the remaining work is dropped rather than retried (NetBox does not retry these jobs
+        # by default) and is recovered by the next reindex.
+        for object_type_id, pks in (remove_groups or {}).items():
+            try:
+                self._remove_by_id(object_type_id, pks, using=using)
+            except DatabaseError as e:
+                if not self._is_missing_schema(e):
+                    raise
+                log.warning(f"Skipping search cache removal for object type {object_type_id}: {e}")
+
+        for object_type_id, pks in (cache_groups or {}).items():
+            try:
+                object_type = ObjectType.objects.get(pk=object_type_id)
+            except ObjectType.DoesNotExist:
+                continue
+            model = object_type.model_class()
+            if model is None:
+                continue
+
+            try:
+                # Re-fetch live instances from the originating database. Reading on `using` is
+                # required: a branch object's PK may be absent (or refer to a different object) on the
+                # default connection.
+                queryset = model.objects.using(using).filter(pk__in=pks)
+
+                # Clear any stale entries for these objects, then re-insert. Wrapping both in one
+                # transaction avoids leaving an object with no cache rows if execution fails between
+                # the delete and the insert.
+                with transaction.atomic(using=using):
+                    self._remove_by_id(object_type_id, pks, using=using)
+                    self.cache(queryset, remove_existing=False, using=using)
+            except DatabaseError as e:
+                if not self._is_missing_schema(e):
+                    raise
+                log.warning(f"Skipping search cache update for object type {object_type_id}: {e}")
 
     def clear(self, object_types=None):
         qs = CachedValue.objects.all()

+ 179 - 0
netbox/netbox/search/deferred.py

@@ -0,0 +1,179 @@
+import logging
+
+from django.db import DEFAULT_DB_ALIAS, connections, transaction
+from redis.exceptions import RedisError
+
+from netbox.constants import RQ_QUEUE_DEFAULT
+from utilities.rqworker import any_workers_for_queue
+
+# This module is internal plumbing for the search signal handlers; nothing here
+# is part of the public/plugin API, so no symbols are exported via __all__.
+
+logger = logging.getLogger(__name__)
+
+# Operation markers stored in the per-transaction buffer
+OP_CACHE = 'cache'
+OP_REMOVE = 'remove'
+
+# Attributes used to tag a flush callback so we can recognize our own callbacks
+# among those registered on a connection and reach the batch they will flush.
+_FLUSH_ALIAS_ATTR = '_netbox_search_flush_alias'
+_FLUSH_BATCH_ATTR = '_netbox_search_flush_batch'
+# The savepoint stack active when a flush callback was registered; see
+# _pending_batch() for why buffering is scoped to it.
+_FLUSH_SCOPE_ATTR = '_netbox_search_flush_scope'
+
+
+def mark_for_deferred_indexing(object_type_id, pk, op, using=None):
+    """
+    Schedule a searchable object for deferred (re)indexing.
+
+    The work is coalesced per database connection and per transaction: repeated
+    operations on the same object collapse to a single entry (a deletion always
+    wins over a create/update), and a single flush is scheduled to run after the
+    transaction commits. When no transaction is open (autocommit), the indexing
+    runs synchronously.
+
+    Args:
+        object_type_id: PK of the object's ObjectType/ContentType.
+        pk: PK of the object.
+        op: OP_CACHE or OP_REMOVE.
+        using: The database alias the originating write used. Replayed verbatim
+            on the deferred write so the cache entries land in the same schema
+            (e.g. a branch schema under netbox-branching), regardless of any
+            routing context that may be unset by the time the flush runs.
+    """
+    # Fall back to the default alias when no originating alias was captured. This is correct for the
+    # common case (autocommit / non-branch writes route to the default connection), but if a write
+    # under a branch schema ever reached here without its alias, the deferred write would silently
+    # land in the main schema. Log at debug so that case is observable rather than invisible.
+    if not using:
+        logger.debug("Search cache: no originating DB alias for object %s/%s; using default", object_type_id, pk)
+    alias = using or DEFAULT_DB_ALIAS
+    connection = connections[alias]
+
+    # No transaction in progress: index synchronously. Deferring would have
+    # nothing to defer past, and transaction.on_commit() in autocommit mode runs
+    # its callback immediately at registration (before we could populate the
+    # batch), so handle this case explicitly.
+    #
+    # On the transactional path below, transaction.on_commit(..., robust=True)
+    # ensures a flush failure can never propagate to the (already-committed)
+    # caller. The autocommit path has no such backstop, so guard it here: a
+    # broad catch is deliberate, because the originating write has committed and
+    # a search cache update must never turn a successful save into an error. The
+    # error is logged so a genuine indexing defect is still visible.
+    if not connection.in_atomic_block:
+        try:
+            _flush({(object_type_id, pk): op}, alias)
+        except Exception:
+            logger.exception("Search cache: error while indexing inline")
+        return
+
+    # Scope buffering to the current savepoint stack, not just the alias (see
+    # _pending_batch). May legitimately contain None entries for nested
+    # atomic(savepoint=False) blocks; matching is by equality, so that is fine.
+    scope = tuple(connection.savepoint_ids)
+
+    batch = _pending_batch(connection, alias, scope)
+    if batch is None:
+        batch = {}
+
+        def flush(batch=batch, alias=alias):
+            _flush(batch, alias)
+
+        setattr(flush, _FLUSH_ALIAS_ATTR, alias)
+        setattr(flush, _FLUSH_BATCH_ATTR, batch)
+        setattr(flush, _FLUSH_SCOPE_ATTR, scope)
+        # robust=True is required, not just belt-and-suspenders: Django runs
+        # on_commit callbacks synchronously as the atomic block exits (after the
+        # COMMIT), so an exception escaping the callback would propagate out of
+        # the view's transaction and become a 500 on an already-committed write.
+        # _flush handles the recoverable Redis fault itself; robust=True is the
+        # only thing that keeps any *other* failure here (logged by Django at
+        # ERROR) from surfacing as that post-commit 500.
+        transaction.on_commit(flush, using=alias, robust=True)
+
+    # Coalesce: a deletion supersedes any pending create/update for the object.
+    key = (object_type_id, pk)
+    if op == OP_REMOVE or batch.get(key) != OP_REMOVE:
+        batch[key] = op
+
+
+def _pending_batch(connection, alias, scope):
+    """
+    Return the batch dict of a flush callback already scheduled for the given
+    alias and savepoint scope on this connection's current transaction, or None
+    if there is none.
+
+    This scans `connection.run_on_commit` on each call rather than caching the
+    lookup elsewhere. That is intentional: the scan is bounded (run_on_commit
+    holds only the transaction's registered commit callbacks, not one per saved
+    object), and reading it fresh each time is what keeps the buffer correctly
+    scoped to the live transaction. Django clears run_on_commit on both commit
+    and rollback, so a rolled-back transaction's batch can never be found here.
+
+    Matching on `scope` (the savepoint stack active when the callback was
+    registered) as well as `alias` keeps each savepoint scope on its own
+    callback. Django prunes a callback when a savepoint in its registration
+    snapshot rolls back, so an op buffered inside a nested savepoint is dropped
+    with its callback if that savepoint rolls back -- it can never be found here
+    and reused by an outer scope.
+    """
+    for _sids, func, _robust in connection.run_on_commit:
+        if (
+            getattr(func, _FLUSH_ALIAS_ATTR, None) == alias
+            and getattr(func, _FLUSH_SCOPE_ATTR, None) == scope
+        ):
+            return getattr(func, _FLUSH_BATCH_ATTR)
+    return None
+
+
+def _flush(batch, using):
+    """
+    Dispatch a coalesced batch of dirty objects for (re)indexing.
+
+    `_flush` is the single guarded entry point for deferred indexing, reached
+    either directly (autocommit) or from a transaction.on_commit callback. By the
+    time it runs the originating write has already committed, so it must never
+    propagate an error back to the caller and turn a successful save into a 500.
+
+    The inline fallback is safe even during a broker outage: the search index
+    lives in PostgreSQL (the extras_cachedvalue table), so a Redis outage only
+    prevents backgrounding, not indexing itself.
+
+    If the broker fails mid-enqueue (after the probe succeeds), Job.enqueue() has
+    already saved a Job row before the Redis dispatch raised, so the fallback can
+    leave behind a PENDING Job that no worker will run. The index is still
+    correct (written inline); the stranded row is cosmetic and ages out via the
+    housekeeping job.
+    """
+    if not batch:
+        return
+
+    cache_groups = {}
+    remove_groups = {}
+    for (object_type_id, pk), op in batch.items():
+        groups = remove_groups if op == OP_REMOVE else cache_groups
+        groups.setdefault(object_type_id, []).append(pk)
+
+    # Imported here, not at module load, to avoid an import cycle: backends.py
+    # imports this module at module level (for the signal handlers), and
+    # netbox.search.jobs imports the search_backend singleton from backends.py,
+    # which is bound at the bottom of that module. A proper fix is tracked in
+    # #22485.
+    from netbox.search.backends import search_backend
+    from netbox.search.jobs import SearchCacheJob
+
+    try:
+        # Both the worker-availability check and the job enqueue talk to Redis,
+        # and a worker can die between the two. Treat any Redis failure across the
+        # whole dispatch as "no worker available" and fall back to inline
+        # indexing (a PostgreSQL write that does not depend on Redis).
+        if any_workers_for_queue(RQ_QUEUE_DEFAULT):
+            SearchCacheJob.enqueue(using=using, cache_groups=cache_groups, remove_groups=remove_groups)
+            return
+    except RedisError:
+        logger.warning("Search cache: broker unavailable; indexing inline", exc_info=True)
+
+    search_backend._apply_deferred_updates(using=using, cache_groups=cache_groups, remove_groups=remove_groups)

+ 24 - 0
netbox/netbox/search/jobs.py

@@ -0,0 +1,24 @@
+import logging
+
+from netbox.jobs import JobRunner
+from netbox.search.backends import search_backend
+
+# Internal search-indexing machinery; not part of the public/plugin API.
+
+logger = logging.getLogger(__name__)
+
+
+class SearchCacheJob(JobRunner):
+    """
+    Background job which applies deferred updates to the global search cache.
+    """
+    class Meta:
+        name = 'Search cache update'
+
+    def run(self, using=None, cache_groups=None, remove_groups=None, **kwargs):
+        search_backend._apply_deferred_updates(
+            using=using,
+            cache_groups=cache_groups,
+            remove_groups=remove_groups,
+            log=self.logger,
+        )

+ 7 - 0
netbox/netbox/tests/test_authentication.py

@@ -611,6 +611,10 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         }
         initial_count = Rack.objects.count()
 
+        # Permit resolving the related Site. This test is concerned with
+        # constrained Rack add permissions, not Site visibility.
+        self.add_permissions('dcim.view_site')
+
         # Attempt to create an object without permission
         response = self.client.post(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 403)
@@ -638,6 +642,9 @@ class ObjectPermissionAPIViewTestCase(TestCase):
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
     def test_edit_object(self):
+        # Permit resolving the related Site. This test is concerned with
+        # constrained Rack change permissions, not Site visibility.
+        self.add_permissions('dcim.view_site')
 
         # Attempt to edit an object without permission
         data = {'site': self.sites[0].pk}

+ 20 - 44
netbox/netbox/tests/test_model_features.py

@@ -1,6 +1,7 @@
 from unittest import skipIf
 
 from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from taggit.models import Tag
 
@@ -62,52 +63,27 @@ class ModelFeaturesTestCase(TestCase):
         self.assertIn('cloning', features)
         self.assertNotIn('bookmarks', features)
 
-    def test_cloningmixin_injects_gfk_attribute(self):
-        """
-        Tests the cloning mixin with GFK attribute injection in the `clone` method.
-
-        This test validates that the `clone` method correctly handles
-        and retains the General Foreign Key (GFK) attributes on an
-        object when the cloning fields are explicitly defined.
-        """
+    def test_cloningmixin_emits_gfk_subwidget_params(self):
+        """A cloned GFK is exposed as the GenericObjectChoiceField subwidget params."""
         site = Site.objects.create(name='Test Site', slug='test-site')
         prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=site)
 
-        original_clone_fields = getattr(Prefix, 'clone_fields', None)
-        try:
-            Prefix.clone_fields = ('scope_type', 'scope_id')
-            attrs = prefix.clone()
-
-            self.assertEqual(attrs['scope_type'], prefix.scope_type_id)
-            self.assertEqual(attrs['scope_id'], prefix.scope_id)
-            self.assertEqual(attrs['scope'], prefix.scope_id)
-        finally:
-            if original_clone_fields is None:
-                delattr(Prefix, 'clone_fields')
-            else:
-                Prefix.clone_fields = original_clone_fields
-
-    def test_cloningmixin_does_not_inject_gfk_attribute_if_incomplete(self):
-        """
-        Tests the cloning mixin with incomplete cloning fields does not inject the GFK attribute.
+        attrs = prefix.clone()
 
-        This test validates that the `clone` method correctly handles
-        the case where the cloning fields are incomplete, ensuring that
-        the generic foreign key (GFK) attribute is not injected during
-        the cloning process.
-        """
-        site = Site.objects.create(name='Test Site', slug='test-site')
-        prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=site)
+        content_type = ContentType.objects.get_for_model(Site)
+        self.assertEqual(attrs['scope_content_type'], content_type.pk)
+        self.assertEqual(attrs['scope_object_id'], site.pk)
+        # The bare GFK name and the raw model fields are not emitted.
+        self.assertNotIn('scope', attrs)
+        self.assertNotIn('scope_type', attrs)
+        self.assertNotIn('scope_id', attrs)
+
+    def test_cloningmixin_omits_unset_gfk(self):
+        """An unset GFK contributes no params to the clone output."""
+        prefix = Prefix.objects.create(prefix='10.0.0.0/24')
+
+        attrs = prefix.clone()
 
-        original_clone_fields = getattr(Prefix, 'clone_fields', None)
-        try:
-            Prefix.clone_fields = ('scope_type',)
-            attrs = prefix.clone()
-
-            self.assertIn('scope_type', attrs)
-            self.assertNotIn('scope', attrs)
-        finally:
-            if original_clone_fields is None:
-                delattr(Prefix, 'clone_fields')
-            else:
-                Prefix.clone_fields = original_clone_fields
+        self.assertNotIn('scope_content_type', attrs)
+        self.assertNotIn('scope_object_id', attrs)
+        self.assertNotIn('scope', attrs)

+ 620 - 7
netbox/netbox/tests/test_search.py

@@ -1,10 +1,27 @@
+from unittest import mock
+
 from django.contrib.contenttypes.models import ContentType
-from django.test import TestCase
+from django.db import DEFAULT_DB_ALIAS, connection, transaction
+from django.db.models.signals import post_delete, post_save
+from django.test import TestCase, TransactionTestCase
+from redis.exceptions import ConnectionError as RedisConnectionError
 
 from dcim.models import Site
 from dcim.search import SiteIndex
 from extras.models import CachedValue
-from netbox.search.backends import search_backend
+from netbox.search import deferred
+from netbox.search.backends import SearchBackend, search_backend
+from netbox.search.jobs import SearchCacheJob
+
+
+def scheduled_search_flushes():
+    # The deferred flush callbacks scheduled on the current connection,
+    # identified by the alias tag set in netbox.search.deferred. Django stores
+    # each registered callback as a (savepoint_ids, func, robust) tuple.
+    return [
+        entry[1] for entry in connection.run_on_commit
+        if hasattr(entry[1], deferred._FLUSH_ALIAS_ATTR)
+    ]
 
 
 class SearchBackendTestCase(TestCase):
@@ -103,7 +120,12 @@ class SearchBackendTestCase(TestCase):
             shipping_address='7915 Lilla Plains West Ladariusport TX 19429',
             comments='Lorem ipsum etcetera'
         )
-        site.save()
+
+        # Caching is deferred to a post-commit task. With no RQ worker running in
+        # the test environment it falls back to synchronous indexing; execute the
+        # on_commit callback to drive it within the test's transaction.
+        with self.captureOnCommitCallbacks(execute=True):
+            site.save()
 
         content_type = ContentType.objects.get_for_model(Site)
         self.assertEqual(
@@ -115,12 +137,24 @@ class SearchBackendTestCase(TestCase):
         """
         Test that any cached value for an object are automatically removed on delete().
         """
-        site = Site.objects.first()
-        site.delete()
-
         content_type = ContentType.objects.get_for_model(Site)
+
+        # Seed an object with cached entries, then delete it. Capture the pk before delete() (which
+        # nulls instance.pk in memory) so the post-delete assertion queries the real id rather than
+        # object_id=None. The create is wrapped in captureOnCommitCallbacks so the deferred caching
+        # actually runs (and writes rows) before we assert it was seeded.
+        with self.captureOnCommitCallbacks(execute=True):
+            site = Site.objects.create(name='Site Delete', slug='site-delete', facility='Foxtrot')
+        site_pk = site.pk
+        self.assertTrue(
+            CachedValue.objects.filter(object_type=content_type, object_id=site_pk).exists()
+        )
+
+        with self.captureOnCommitCallbacks(execute=True):
+            site.delete()
+
         self.assertFalse(
-            CachedValue.objects.filter(object_type=content_type, object_id=site.pk).exists()
+            CachedValue.objects.filter(object_type=content_type, object_id=site_pk).exists()
         )
 
     def test_clear_all(self):
@@ -151,3 +185,582 @@ class SearchBackendTestCase(TestCase):
         self.assertEqual(len(results), 1)
         results = search_backend.search('xxxxx')
         self.assertEqual(len(results), 0)
+
+
+class DeferredCachingTestCase(TestCase):
+    """
+    Tests for the deferred (post-commit) search caching machinery in
+    netbox.search.deferred.
+
+    With no RQ worker registered, deferral falls back to synchronous indexing on
+    commit, so these tests assert on real CachedValue state and on the real
+    per-transaction buffer (connection.run_on_commit) rather than mocking the
+    queue.
+    """
+
+    def _scheduled_flush_aliases(self):
+        return [getattr(func, deferred._FLUSH_ALIAS_ATTR) for func in scheduled_search_flushes()]
+
+    def _pending_batch(self):
+        for func in scheduled_search_flushes():
+            return getattr(func, deferred._FLUSH_BATCH_ATTR)
+        return None
+
+    def test_run_on_commit_entry_shape(self):
+        """
+        deferred._pending_batch() relies on Django storing each on_commit
+        callback as a (savepoint_ids, func, robust) tuple in
+        connection.run_on_commit. That structure is a Django internal, not a
+        documented API. Assert its shape explicitly so a change in a future
+        Django release fails here with a clear pointer, rather than surfacing as
+        an opaque unpack error inside the deferred-caching machinery.
+        """
+        with transaction.atomic():
+            transaction.on_commit(lambda: None)
+            entries = connection.run_on_commit
+            self.assertTrue(entries, "expected a registered on_commit callback")
+            entry = entries[-1]
+            self.assertEqual(
+                len(entry), 3,
+                "Django's connection.run_on_commit entry is no longer a 3-tuple; "
+                "netbox.search.deferred._pending_batch() unpacks (sids, func, robust) "
+                "and must be updated to match the new structure."
+            )
+            sids, func, robust = entry
+            self.assertIsInstance(sids, set)
+            self.assertTrue(callable(func))
+            self.assertIsInstance(robust, bool)
+
+    def test_savepoint_ids_shape(self):
+        """
+        deferred.mark_for_deferred_indexing() reads connection.savepoint_ids to
+        scope each flush callback to its savepoint stack. That is a Django
+        internal, not a documented API. Assert it is a list inside a nested
+        atomic() so a future change fails here rather than silently re-leaking
+        nested-savepoint ops into an outer batch.
+        """
+        with transaction.atomic():
+            with transaction.atomic():
+                self.assertIsInstance(
+                    connection.savepoint_ids, list,
+                    "Django's connection.savepoint_ids is no longer a list; "
+                    "netbox.search.deferred.mark_for_deferred_indexing() keys its "
+                    "flush callbacks on tuple(savepoint_ids) and must be updated."
+                )
+                self.assertTrue(
+                    connection.savepoint_ids,
+                    "expected at least one savepoint id inside a nested atomic()"
+                )
+
+    def test_bulk_save_schedules_single_flush(self):
+        """
+        A batch of saves within one transaction coalesces into a single flush
+        carrying every object, rather than one scheduled flush per object.
+        """
+        site_ct = ContentType.objects.get_for_model(Site)
+        with transaction.atomic():
+            for i in range(20):
+                Site.objects.create(name=f'Site {i}', slug=f'site-{i}')
+
+            # Exactly one flush is scheduled, and its batch holds all 20 objects.
+            self.assertEqual(self._scheduled_flush_aliases().count('default'), 1)
+            batch = self._pending_batch()
+            site_pks = [pk for (ot_id, pk) in batch if ot_id == site_ct.pk]
+            self.assertEqual(len(site_pks), 20)
+
+    def test_save_then_delete_in_same_scope_coalesces_to_removal(self):
+        """
+        A create and delete buffered in the same savepoint scope coalesce to a
+        single removal. Model.delete() runs in its own atomic(savepoint=False)
+        block, which pushes a scope marker, so to exercise coalescing within one
+        scope the operations are buffered directly rather than via a real
+        delete().
+        """
+        site_ct = ContentType.objects.get_for_model(Site)
+        with transaction.atomic():
+            deferred.mark_for_deferred_indexing(site_ct.pk, 1, deferred.OP_CACHE)
+            deferred.mark_for_deferred_indexing(site_ct.pk, 1, deferred.OP_REMOVE)
+            batch = self._pending_batch()
+            self.assertEqual(batch[(site_ct.pk, 1)], deferred.OP_REMOVE)
+
+    def test_save_then_delete_ends_absent_from_cache(self):
+        """
+        Creating then deleting an object within one transaction leaves it absent
+        from the cache, regardless of how the create and delete ops are scoped.
+        """
+        site_ct = ContentType.objects.get_for_model(Site)
+        with self.captureOnCommitCallbacks(execute=True):
+            site = Site.objects.create(name='Ephemeral', slug='ephemeral')
+            pk = site.pk
+            site.delete()
+
+        self.assertFalse(
+            CachedValue.objects.filter(object_type=site_ct, object_id=pk).exists()
+        )
+
+    def test_rollback_does_not_leak_buffer(self):
+        """
+        An object dirtied inside a transaction that rolls back leaves no flush
+        scheduled and no stale buffer behind, so it is never indexed.
+        """
+        content_type = ContentType.objects.get_for_model(Site)
+
+        # A nested atomic block that rolls back: its on_commit callback (and the
+        # batch it captured) are discarded by Django, so nothing is scheduled on
+        # the surrounding transaction.
+        with self.assertRaises(RuntimeError):
+            with transaction.atomic():
+                rolled_back = Site.objects.create(name='RolledBack', slug='rolled-back')
+                rolled_back_pk = rolled_back.pk
+                # A flush was scheduled within this savepoint...
+                self.assertEqual(self._scheduled_flush_aliases().count('default'), 1)
+                raise RuntimeError('abort')
+
+        # ...and is gone once the savepoint rolled back.
+        self.assertEqual(self._scheduled_flush_aliases().count('default'), 0)
+        self.assertFalse(
+            CachedValue.objects.filter(object_type=content_type, object_id=rolled_back_pk).exists()
+        )
+
+    def test_commit_after_rollback_still_indexes(self):
+        """
+        After a rolled-back transaction, a subsequent committed save on the same
+        connection still indexes correctly (no sticky buffer state survives the
+        rollback to suppress it).
+        """
+        content_type = ContentType.objects.get_for_model(Site)
+
+        with self.assertRaises(RuntimeError):
+            with transaction.atomic():
+                Site.objects.create(name='RolledBack2', slug='rolled-back-2')
+                raise RuntimeError('abort')
+
+        with self.captureOnCommitCallbacks(execute=True):
+            site = Site.objects.create(
+                name='Committed',
+                slug='committed',
+                facility='Echo',
+                description='Committed after rollback',
+                physical_address='1 Test Way',
+                shipping_address='1 Test Way',
+                comments='Lorem ipsum',
+            )
+
+        # The committed object is indexed (no sticky buffer state from the
+        # rolled-back transaction suppressed it).
+        self.assertEqual(
+            CachedValue.objects.filter(object_type=content_type, object_id=site.pk).count(),
+            len(SiteIndex.fields)
+        )
+
+    def test_non_searchable_model_schedules_no_flush(self):
+        """
+        Saving a model without a registered search index schedules no deferred
+        flush.
+        """
+        with transaction.atomic():
+            # CachedValue itself has no search indexer.
+            CachedValue.objects.create(
+                object_type=ContentType.objects.get_for_model(Site),
+                object_id=1,
+                field='name',
+                type='str',
+                value='test',
+                weight=100,
+            )
+            self.assertEqual(self._scheduled_flush_aliases(), [])
+
+    def test_flush_enqueues_job_when_worker_available(self):
+        """
+        When an RQ worker is available, the flush enqueues a SearchCacheJob
+        carrying the dirty objects (and the originating database alias) rather
+        than indexing inline.
+        """
+        site_ct = ContentType.objects.get_for_model(Site)
+
+        # Patching the worker-availability probe is the established pattern for
+        # worker-gated behavior (cf. extras/tests/test_views.py, test_api.py).
+        with mock.patch('netbox.search.deferred.any_workers_for_queue', return_value=True):
+            with mock.patch.object(SearchCacheJob, 'enqueue') as enqueue:
+                with self.captureOnCommitCallbacks(execute=True):
+                    site = Site.objects.create(name='Enqueued', slug='enqueued')
+
+        enqueue.assert_called_once()
+        kwargs = enqueue.call_args.kwargs
+        self.assertEqual(kwargs['cache_groups'], {site_ct.pk: [site.pk]})
+        self.assertEqual(kwargs['remove_groups'], {})
+        # The database alias captured from the post_save signal is forwarded to
+        # the job verbatim. This is what lets a deferred write target the
+        # originating schema (e.g. a branch schema under netbox-branching) even
+        # though the worker has no active routing context; cross-schema routing
+        # itself is covered by the netbox-branching test suite.
+        self.assertEqual(kwargs['using'], DEFAULT_DB_ALIAS)
+
+    def test_flush_falls_back_inline_when_broker_unreachable(self):
+        """
+        If the broker is unreachable (the worker-availability probe raises a
+        RedisError), the flush must not propagate the error; it falls back to
+        inline indexing, which is a PostgreSQL write with no Redis dependency.
+        """
+        content_type = ContentType.objects.get_for_model(Site)
+
+        with mock.patch(
+            'netbox.search.deferred.any_workers_for_queue',
+            side_effect=RedisConnectionError("broker down"),
+        ):
+            with self.captureOnCommitCallbacks(execute=True):
+                site = Site.objects.create(
+                    name='Broker Down',
+                    slug='broker-down',
+                    facility='Golf',
+                    description='Indexed inline despite broker outage',
+                    physical_address='3 Test Way',
+                    shipping_address='3 Test Way',
+                    comments='Lorem ipsum',
+                )
+
+        # The object was indexed inline (no exception propagated, rows written).
+        self.assertEqual(
+            CachedValue.objects.filter(object_type=content_type, object_id=site.pk).count(),
+            len(SiteIndex.fields)
+        )
+
+    def test_flush_falls_back_inline_when_enqueue_fails(self):
+        """
+        A worker can die (or the broker can drop) between the availability probe
+        and the enqueue. The flush guards the whole dispatch, not just the probe:
+        if enqueue raises a RedisError it still falls back to inline indexing.
+        """
+        content_type = ContentType.objects.get_for_model(Site)
+
+        with mock.patch('netbox.search.deferred.any_workers_for_queue', return_value=True):
+            with mock.patch.object(
+                SearchCacheJob, 'enqueue', side_effect=RedisConnectionError("broker dropped")
+            ):
+                with self.captureOnCommitCallbacks(execute=True):
+                    site = Site.objects.create(
+                        name='Enqueue Failed',
+                        slug='enqueue-failed',
+                        facility='Hotel',
+                        description='Indexed inline after enqueue failure',
+                        physical_address='4 Test Way',
+                        shipping_address='4 Test Way',
+                        comments='Lorem ipsum',
+                    )
+
+        self.assertEqual(
+            CachedValue.objects.filter(object_type=content_type, object_id=site.pk).count(),
+            len(SiteIndex.fields)
+        )
+
+    def test_cache_update_skips_deleted_object(self):
+        """
+        _apply_deferred_updates tolerates a pk that no longer exists (object
+        deleted between enqueue and execution): it must not error or create cache
+        rows.
+        """
+        site = Site.objects.create(name='Vanished', slug='vanished')
+        site_ct = ContentType.objects.get_for_model(Site)
+        pk = site.pk
+        site.delete()
+        CachedValue.objects.filter(object_type=site_ct, object_id=pk).delete()
+
+        # No exception, and no rows resurrected for the missing object.
+        search_backend._apply_deferred_updates(using=None, cache_groups={site_ct.pk: [pk]}, remove_groups={})
+        self.assertFalse(
+            CachedValue.objects.filter(object_type=site_ct, object_id=pk).exists()
+        )
+
+
+class AutocommitCachingTestCase(TransactionTestCase):
+    """
+    Tests for the synchronous (autocommit) indexing path. Uses TransactionTestCase
+    rather than TestCase so that a save outside an explicit transaction runs in
+    autocommit (connection.in_atomic_block is False), exercising mark_for_deferred_indexing's
+    inline-indexing branch rather than the deferred on_commit path.
+    """
+
+    def test_autocommit_save_indexes_synchronously(self):
+        # Saved outside any atomic() block: indexing happens inline, immediately,
+        # with no background worker and no on_commit deferral.
+        site = Site.objects.create(
+            name='Autocommit Site',
+            slug='autocommit-site',
+            facility='Foxtrot',
+            description='Indexed synchronously',
+            physical_address='2 Test Way',
+            shipping_address='2 Test Way',
+            comments='Lorem ipsum',
+        )
+
+        content_type = ContentType.objects.get_for_model(Site)
+        self.assertEqual(
+            CachedValue.objects.filter(object_type=content_type, object_id=site.pk).count(),
+            len(SiteIndex.fields)
+        )
+
+    def test_autocommit_delete_removes_synchronously(self):
+        site = Site.objects.create(name='Autocommit Del', slug='autocommit-del')
+        content_type = ContentType.objects.get_for_model(Site)
+        # Capture the pk before delete() nulls instance.pk in memory.
+        site_pk = site.pk
+        self.assertTrue(
+            CachedValue.objects.filter(object_type=content_type, object_id=site_pk).exists()
+        )
+
+        site.delete()
+        self.assertFalse(
+            CachedValue.objects.filter(object_type=content_type, object_id=site_pk).exists()
+        )
+
+    def test_inner_savepoint_rollback_does_not_leak_into_outer_batch(self):
+        """
+        Regression test for the nested-atomic leak: an operation performed inside
+        an inner atomic() block (savepoint) that rolls back must not affect the
+        search cache when the outer transaction commits.
+
+        TransactionTestCase is required so the outer atomic() is a real
+        transaction whose on_commit callbacks actually fire on commit (under
+        TestCase the test body is already inside a transaction, so nothing
+        commits and the leak is invisible).
+        """
+        content_type = ContentType.objects.get_for_model(Site)
+
+        # Object that exists before the transaction and stays cached: the inner
+        # savepoint will (transiently) delete it, then roll back. Capture the pk
+        # up front; Model.delete() nulls instance.pk in memory.
+        survivor = Site.objects.create(name='Survivor', slug='survivor')
+        survivor_pk = survivor.pk
+        self.assertTrue(
+            CachedValue.objects.filter(object_type=content_type, object_id=survivor_pk).exists()
+        )
+
+        with transaction.atomic():
+            # Outer write schedules a search-cache flush for this connection.
+            outer = self._create_indexed_site('Outer', 'outer')
+
+            # Inner savepoint deletes the survivor, then rolls back. At the DB
+            # level the survivor still exists after the rollback.
+            try:
+                with transaction.atomic():
+                    survivor.delete()
+                    self.assertFalse(Site.objects.filter(pk=survivor_pk).exists())
+                    raise RuntimeError('abort inner savepoint')
+            except RuntimeError:
+                pass
+
+            # The savepoint rolled back, so the survivor row is still present.
+            self.assertTrue(Site.objects.filter(pk=survivor_pk).exists())
+
+        # After the outer transaction commits, the deferred flush runs. The
+        # rolled-back inner delete must NOT have removed the survivor from the
+        # cache, and the committed outer object must be indexed.
+        self.assertTrue(
+            CachedValue.objects.filter(object_type=content_type, object_id=survivor_pk).exists(),
+            "rolled-back inner-savepoint delete leaked into the outer flush and "
+            "removed the survivor from the search cache",
+        )
+        self.assertEqual(
+            CachedValue.objects.filter(object_type=content_type, object_id=outer.pk).count(),
+            len(SiteIndex.fields),
+        )
+
+    @staticmethod
+    def _create_indexed_site(name, slug):
+        # SiteIndex.to_cache() emits a row only for non-empty fields, so populate
+        # every indexed field to get exactly len(SiteIndex.fields) cache rows.
+        return Site.objects.create(
+            name=name,
+            slug=slug,
+            facility='Facility',
+            description=f'{name} description',
+            physical_address='1 Test Way',
+            shipping_address='1 Test Way',
+            comments='Lorem ipsum',
+        )
+
+    def test_committed_savepoint_indexes_in_its_own_scope(self):
+        """
+        A nested atomic() that commits is scoped to its own flush callback (one
+        per savepoint scope), and both the outer and the inner-committed object
+        are indexed. The two-callback count locks in the per-scope behavior so a
+        future change that re-merges scopes cannot silently reintroduce the
+        nested-rollback leak.
+        """
+        content_type = ContentType.objects.get_for_model(Site)
+
+        with transaction.atomic():
+            outer = self._create_indexed_site('OuterC', 'outer-c')
+
+            with transaction.atomic():
+                inner = self._create_indexed_site('InnerC', 'inner-c')
+
+            # Two distinct savepoint scopes dirtied objects, so two flush
+            # callbacks are scheduled (one per scope), not one coalesced batch.
+            self.assertEqual(len(scheduled_search_flushes()), 2)
+
+        # Both objects are indexed after the outer transaction commits.
+        self.assertEqual(
+            CachedValue.objects.filter(object_type=content_type, object_id=outer.pk).count(),
+            len(SiteIndex.fields),
+        )
+        self.assertEqual(
+            CachedValue.objects.filter(object_type=content_type, object_id=inner.pk).count(),
+            len(SiteIndex.fields),
+        )
+
+    def test_cross_scope_save_then_delete_nets_to_removed(self):
+        """
+        Save an object at the transaction top level, then delete it inside a
+        committed nested savepoint. The two ops land in separate scopes (no
+        cross-scope coalescing), so two flush callbacks run in registration
+        (FIFO) order: the cache pass then the removal pass. The re-fetch at flush
+        time is the source of truth, so the end state is correctly "removed".
+        """
+        content_type = ContentType.objects.get_for_model(Site)
+
+        with transaction.atomic():
+            site = Site.objects.create(name='CrossScope', slug='cross-scope')
+            site_pk = site.pk
+
+            with transaction.atomic():
+                site.delete()
+
+        self.assertFalse(
+            CachedValue.objects.filter(object_type=content_type, object_id=site_pk).exists(),
+            "object deleted in a committed nested savepoint should not remain cached",
+        )
+        self.assertFalse(Site.objects.filter(pk=site_pk).exists())
+
+    def test_sibling_savepoints_do_not_cross_contaminate(self):
+        """
+        Two sequential nested savepoints under one outer transaction: the first
+        rolls back, the second commits, each dirtying a different object. Only
+        the committed sibling's object is indexed; the rolled-back sibling's op
+        is discarded with its own callback.
+        """
+        content_type = ContentType.objects.get_for_model(Site)
+
+        with transaction.atomic():
+            try:
+                with transaction.atomic():
+                    rolled = Site.objects.create(name='SiblingRollback', slug='sibling-rb')
+                    rolled_pk = rolled.pk
+                    raise RuntimeError('abort first sibling')
+            except RuntimeError:
+                pass
+
+            with transaction.atomic():
+                committed = self._create_indexed_site('SiblingCommit', 'sibling-commit')
+
+        self.assertFalse(
+            CachedValue.objects.filter(object_type=content_type, object_id=rolled_pk).exists(),
+            "rolled-back sibling savepoint's object should not be indexed",
+        )
+        self.assertEqual(
+            CachedValue.objects.filter(object_type=content_type, object_id=committed.pk).count(),
+            len(SiteIndex.fields),
+        )
+
+    def test_deep_nesting_middle_rollback(self):
+        """
+        Three savepoint levels, each dirtying a distinct object; the middle level
+        rolls back. The top object and the deepest object nested under the middle
+        are both discarded along with the middle savepoint (its rollback prunes
+        every callback whose registration snapshot contains the middle sid), so
+        only the top-level object survives to be indexed.
+        """
+        content_type = ContentType.objects.get_for_model(Site)
+
+        with transaction.atomic():
+            top = self._create_indexed_site('DeepTop', 'deep-top')
+
+            try:
+                with transaction.atomic():  # middle savepoint
+                    middle = Site.objects.create(name='DeepMiddle', slug='deep-middle')
+                    middle_pk = middle.pk
+
+                    with transaction.atomic():  # deepest savepoint
+                        deepest = Site.objects.create(name='DeepDeepest', slug='deep-deepest')
+                        deepest_pk = deepest.pk
+
+                    raise RuntimeError('abort middle')
+            except RuntimeError:
+                pass
+
+        # Only the top-level object survived; the middle savepoint's rollback
+        # discarded both the middle and the deepest object's flush callbacks.
+        self.assertEqual(
+            CachedValue.objects.filter(object_type=content_type, object_id=top.pk).count(),
+            len(SiteIndex.fields),
+        )
+        self.assertFalse(
+            CachedValue.objects.filter(object_type=content_type, object_id=middle_pk).exists()
+        )
+        self.assertFalse(
+            CachedValue.objects.filter(object_type=content_type, object_id=deepest_pk).exists()
+        )
+
+
+class _MinimalSearchBackend(SearchBackend):
+    """
+    A backend implementing only the documented contract (search/cache/remove/clear), with no
+    knowledge of deferral. Records cache()/remove() calls in memory so a test can assert it indexed
+    synchronously. Used to prove a custom SEARCH_BACKEND keeps working after this change.
+    """
+
+    def __init__(self):
+        self.cached = []
+        self.removed = []
+
+    def search(self, value, user=None, object_types=None, lookup=None):
+        return []
+
+    def cache(self, instances, indexer=None, remove_existing=True):
+        if not hasattr(instances, '__iter__'):
+            instances = [instances]
+        self.cached.extend(instances)
+
+    def remove(self, instance):
+        self.removed.append(instance)
+
+    def clear(self, object_types=None):
+        self.cached.clear()
+        self.removed.clear()
+
+
+class CustomBackendContractTestCase(TransactionTestCase):
+    """
+    Proves the deferred-indexing work did not change the public SearchBackend contract: a custom
+    backend implementing only search/cache/remove/clear still indexes synchronously through the base
+    signal handlers, and never schedules deferred (on_commit) work. Deferral is private to
+    CachedValueSearchBackend; it must not leak onto a backend that didn't opt into it.
+    """
+
+    def test_custom_backend_indexes_synchronously(self):
+        backend = _MinimalSearchBackend()
+
+        # Connect the custom backend's (inherited, synchronous) handlers, exactly as
+        # backends.py connects the configured backend at import. Same call site, no type-check.
+        post_save.connect(backend.caching_handler, sender=Site)
+        post_delete.connect(backend.removal_handler, sender=Site)
+        self.addCleanup(post_save.disconnect, backend.caching_handler, sender=Site)
+        self.addCleanup(post_delete.disconnect, backend.removal_handler, sender=Site)
+
+        # A backend implementing only the documented contract indexes through the base handlers
+        # inline (the handler calls self.cache()/self.remove() synchronously). It is never routed
+        # into the deferral machinery, which is private to CachedValueSearchBackend.
+        site = Site.objects.create(name='Custom Backend', slug='custom-backend')
+        self.assertIn(site, backend.cached)
+
+        site.delete()
+        self.assertEqual(len(backend.removed), 1)
+
+    def test_default_backend_defers_via_same_call_path(self):
+        # The default backend (CachedValueSearchBackend) IS connected at import, and reaches the
+        # SAME caching_handler call path -- but its override defers instead of indexing inline.
+        # This contrasts with the custom backend above: identical dispatch, polymorphic behavior.
+        with transaction.atomic():
+            Site.objects.create(name='Default Defers', slug='default-defers')
+            scheduled = scheduled_search_flushes()
+            self.assertEqual(len(scheduled), 1)

+ 196 - 0
netbox/netbox/tests/test_serializers.py

@@ -0,0 +1,196 @@
+from django.core.exceptions import ValidationError
+from django.test import RequestFactory, TestCase
+from rest_framework.exceptions import ValidationError as DRFValidationError
+
+from core.models import ObjectType
+from netbox.api.fields import SerializedPKRelatedField
+from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
+from netbox.api.serializers.generic import GenericObjectSerializer
+from netbox.tests.dummy_plugin.models import DummyNetBoxModel
+from users.models import ObjectPermission, User
+
+
+class NestedDummyNetBoxModelSerializer(NetBoxModelSerializer):
+    class Meta:
+        model = DummyNetBoxModel
+        fields = ['id', 'url', 'display_url', 'display']
+        brief_fields = ['id', 'display']
+
+
+class WritableNestedDummyNetBoxModelSerializer(WritableNestedSerializer):
+    class Meta:
+        model = DummyNetBoxModel
+        fields = ['id', 'display']
+
+
+class NestedRelatedObjectPermissionTest(TestCase):
+    """
+    Validate that nested related-object resolution honors object permissions.
+
+    This exercises the shared serializer behavior directly rather than relying
+    on any specific app's API endpoint.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create_user(username='testuser')
+
+        cls.visible_object = DummyNetBoxModel.objects.create()
+        cls.hidden_object = DummyNetBoxModel.objects.create()
+
+        obj_perm = ObjectPermission(
+            name='View visible dummy object', actions=['view'], constraints={'pk': cls.visible_object.pk}
+        )
+        obj_perm.save()
+        obj_perm.users.add(cls.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(DummyNetBoxModel))
+
+    def setUp(self):
+        self.request = RequestFactory().get('/api/')
+        self.request.user = self.user
+
+    def test_nested_serializer_represents_visible_object(self):
+        serializer = NestedDummyNetBoxModelSerializer(nested=True, context={'request': self.request})
+
+        data = serializer.to_representation(self.visible_object)
+
+        self.assertEqual(data['id'], self.visible_object.pk)
+        self.assertIn('display', data)
+
+    def test_writable_nested_serializer_represents_visible_object(self):
+        serializer = WritableNestedDummyNetBoxModelSerializer(context={'request': self.request})
+
+        data = serializer.to_representation(self.visible_object)
+
+        self.assertEqual(data['id'], self.visible_object.pk)
+        self.assertIn('display', data)
+
+    def test_nested_serializer_resolves_visible_object_by_id(self):
+        serializer = NestedDummyNetBoxModelSerializer(nested=True, context={'request': self.request})
+
+        self.assertEqual(serializer.to_internal_value(self.visible_object.pk), self.visible_object)
+
+    def test_nested_serializer_rejects_hidden_object_by_id(self):
+        serializer = NestedDummyNetBoxModelSerializer(nested=True, context={'request': self.request})
+
+        with self.assertRaises(ValidationError):
+            serializer.to_internal_value(self.hidden_object.pk)
+
+    def test_nested_serializer_rejects_hidden_object_by_attrs(self):
+        serializer = NestedDummyNetBoxModelSerializer(nested=True, context={'request': self.request})
+
+        with self.assertRaises(ValidationError):
+            serializer.to_internal_value({'id': self.hidden_object.pk})
+
+    def test_writable_nested_serializer_resolves_visible_object_by_id(self):
+        serializer = WritableNestedDummyNetBoxModelSerializer(context={'request': self.request})
+
+        self.assertEqual(serializer.to_internal_value(self.visible_object.pk), self.visible_object)
+
+    def test_writable_nested_serializer_rejects_hidden_object_by_id(self):
+        serializer = WritableNestedDummyNetBoxModelSerializer(context={'request': self.request})
+
+        with self.assertRaises(ValidationError):
+            serializer.to_internal_value(self.hidden_object.pk)
+
+    def test_writable_nested_serializer_rejects_hidden_object_by_attrs(self):
+        serializer = WritableNestedDummyNetBoxModelSerializer(context={'request': self.request})
+
+        with self.assertRaises(ValidationError):
+            serializer.to_internal_value({'id': self.hidden_object.pk})
+
+    def test_nested_serializer_resolves_object_without_request_context(self):
+        serializer = NestedDummyNetBoxModelSerializer(nested=True)
+
+        self.assertEqual(serializer.to_internal_value(self.hidden_object.pk), self.hidden_object)
+
+    def test_writable_nested_serializer_resolves_object_without_request_context(self):
+        serializer = WritableNestedDummyNetBoxModelSerializer()
+
+        self.assertEqual(serializer.to_internal_value(self.hidden_object.pk), self.hidden_object)
+
+
+class SerializedPKRelatedFieldPermissionTest(TestCase):
+    """ManyToMany input resolution honors object permissions."""
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create_user(username='m2m_user')
+        cls.visible_object = DummyNetBoxModel.objects.create()
+        cls.hidden_object = DummyNetBoxModel.objects.create()
+
+        obj_perm = ObjectPermission(
+            name='View visible dummy object (m2m)', actions=['view'], constraints={'pk': cls.visible_object.pk}
+        )
+        obj_perm.save()
+        obj_perm.users.add(cls.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(DummyNetBoxModel))
+
+    def setUp(self):
+        self.request = RequestFactory().get('/api/')
+        self.request.user = self.user
+
+    def _bind_field(self, context):
+        field = SerializedPKRelatedField(
+            queryset=DummyNetBoxModel.objects.all(),
+            serializer=NestedDummyNetBoxModelSerializer,
+            required=False,
+        )
+        # A bound parent serializer supplies the request context to the field.
+        parent = NestedDummyNetBoxModelSerializer(nested=True, context=context)
+        field.bind('related', parent)
+        return field
+
+    def test_resolves_visible_object(self):
+        field = self._bind_field({'request': self.request})
+        self.assertEqual(field.to_internal_value(self.visible_object.pk), self.visible_object)
+
+    def test_rejects_hidden_object(self):
+        field = self._bind_field({'request': self.request})
+        with self.assertRaises(DRFValidationError):
+            field.to_internal_value(self.hidden_object.pk)
+
+    def test_resolves_object_without_request_context(self):
+        field = self._bind_field({})
+        self.assertEqual(field.to_internal_value(self.hidden_object.pk), self.hidden_object)
+
+
+class GenericObjectSerializerPermissionTest(TestCase):
+    """Writable generic-FK input resolution honors object permissions."""
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create_user(username='gfk_user')
+        cls.visible_object = DummyNetBoxModel.objects.create()
+        cls.hidden_object = DummyNetBoxModel.objects.create()
+        cls.object_type = ObjectType.objects.get_for_model(DummyNetBoxModel)
+
+        obj_perm = ObjectPermission(
+            name='View visible dummy object (gfk)', actions=['view'], constraints={'pk': cls.visible_object.pk}
+        )
+        obj_perm.save()
+        obj_perm.users.add(cls.user)
+        obj_perm.object_types.add(cls.object_type)
+
+    def setUp(self):
+        self.request = RequestFactory().get('/api/')
+        self.request.user = self.user
+
+    def _payload(self, obj):
+        return {
+            'object_type': f'{self.object_type.app_label}.{self.object_type.model}',
+            'object_id': obj.pk,
+        }
+
+    def test_resolves_visible_object(self):
+        serializer = GenericObjectSerializer(context={'request': self.request})
+        self.assertEqual(serializer.to_internal_value(self._payload(self.visible_object)), self.visible_object)
+
+    def test_rejects_hidden_object(self):
+        serializer = GenericObjectSerializer(context={'request': self.request})
+        with self.assertRaises(ValidationError):
+            serializer.to_internal_value(self._payload(self.hidden_object))
+
+    def test_resolves_object_without_request_context(self):
+        serializer = GenericObjectSerializer()
+        self.assertEqual(serializer.to_internal_value(self._payload(self.hidden_object)), self.hidden_object)

+ 11 - 0
netbox/netbox/ui/attrs.py

@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
 from netbox.config import get_config
 from netbox.ui.utils import build_coords_url, is_coordinate_map_url
 from utilities.data import resolve_attr_path
+from utilities.string import humanize_duration
 
 __all__ = (
     'AddressAttr',
@@ -14,6 +15,7 @@ __all__ = (
     'ColorAttr',
     'DateTimeAttr',
     'DistanceAttr',
+    'DurationAttr',
     'GPSCoordinatesAttr',
     'GenericForeignKeyAttr',
     'ImageAttr',
@@ -562,6 +564,15 @@ class TimezoneAttr(ObjectAttribute):
     template_name = 'ui/attrs/timezone.html'
 
 
+class DurationAttr(TextAttr):
+    """
+    A duration (timedelta) value, rendered in a human-friendly format (e.g. 1h 5m 23s).
+    """
+    def get_value(self, obj):
+        value = resolve_attr_path(obj, self.accessor)
+        return humanize_duration(value) or None
+
+
 class TemplatedAttr(ObjectAttribute):
     """
     Renders an attribute using a custom template.

+ 11 - 1
netbox/netbox/views/generic/bulk_views.py

@@ -35,6 +35,7 @@ from utilities.htmx import htmx_partial
 from utilities.jobs import is_background_request, process_request_as_job
 from utilities.permissions import get_permission_for_model
 from utilities.query import reapply_model_ordering
+from utilities.querydict import normalize_querydict
 from utilities.request import safe_for_redirect
 from utilities.string import title
 from utilities.tables import get_table_configs
@@ -912,7 +913,16 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
 
         post_data = request.POST.copy()
         post_data.setlist('pk', pk_list)
-        form = self.form(post_data, initial=initial_data)
+
+        # An HTMX request without "_apply" is a dependent-field refresh (e.g. changing a content type), not a
+        # submission. Build the form unbound with the submitted state as initial data so fields reconfigure
+        # without surfacing validation errors before the user clicks Apply.
+        if htmx_partial(request) and '_apply' not in request.POST:
+            initial_data.update(normalize_querydict(post_data))
+            initial_data['pk'] = pk_list
+            form = self.form(initial=initial_data)
+        else:
+            form = self.form(post_data, initial=initial_data)
         restrict_form_fields(form, request.user)
 
         if '_apply' in request.POST:

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 23 - 5
netbox/project-static/src/select/classes/dynamicTomSelect.ts

@@ -14,6 +14,7 @@ export class DynamicTomSelect extends NetBoxTomSelect {
   public readonly nullOption: Nullable<TomOption> = null;
 
   // Transitional code from APISelect
+  public api_url: string | null = null;
   private readonly queryParams: QueryFilter = new Map();
   private readonly staticParams: QueryFilter = new Map();
   private readonly dynamicParams: DynamicParamsMap = new DynamicParamsMap();
@@ -27,7 +28,7 @@ export class DynamicTomSelect extends NetBoxTomSelect {
     super(input_arg, user_settings);
 
     // Glean the REST API endpoint URL from the <select> element
-    this.api_url = this.input.getAttribute('data-url') as string;
+    this.api_url = this.input.getAttribute('data-url');
 
     // Override any field names set as widget attributes
     this.valueField = this.input.getAttribute('ts-value-field') || this.settings.valueField;
@@ -74,6 +75,11 @@ export class DynamicTomSelect extends NetBoxTomSelect {
   load(value: string, preserveValue?: string | string[]) {
     const self = this;
 
+    // No API endpoint is configured yet (e.g. a generic object selector before a content type is chosen).
+    if (!self.api_url) {
+      return;
+    }
+
     // Automatically clear any cached options. (Only options included
     // in the API response should be present.)
     self.clearOptions();
@@ -127,7 +133,11 @@ export class DynamicTomSelect extends NetBoxTomSelect {
 
   // Formulate and return the complete URL for an API request, including any query parameters.
   getRequestUrl(search: string): string {
-    let url = this.api_url;
+    if (!this.api_url) {
+      return '';
+    }
+    const apiUrl = this.api_url;
+    let url = apiUrl;
 
     // Create new URL query parameters based on the current state of `queryParams` and create an
     // updated API query URL.
@@ -138,7 +148,7 @@ export class DynamicTomSelect extends NetBoxTomSelect {
 
     // Replace any variables in the URL with values from `pathValues` if set.
     for (const [key, value] of this.pathValues.entries()) {
-      for (const result of this.api_url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
+      for (const result of apiUrl.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
         if (value) {
           url = replaceAll(url, result[1], value.toString());
         } else {
@@ -229,6 +239,10 @@ export class DynamicTomSelect extends NetBoxTomSelect {
   // values. As those keys' corresponding form fields' values change, `pathValues` will be
   // updated to reflect the new value.
   private getPathKeys() {
+    // A generic object selector has no data-url until a content type is chosen; nothing to parse.
+    if (!this.api_url) {
+      return;
+    }
     for (const result of this.api_url.matchAll(new RegExp(`{{(.+)}}`, 'g'))) {
       this.pathValues.set(result[1], '');
     }
@@ -296,6 +310,10 @@ export class DynamicTomSelect extends NetBoxTomSelect {
 
   // Update `pathValues` based on the form value of another element.
   private updatePathValues(id: string): void {
+    if (!this.api_url) {
+      return;
+    }
+    const apiUrl = this.api_url;
     const key = replaceAll(id, /^id_/i, '');
     const element = getElement<HTMLSelectElement>(`id_${key}`);
     if (element !== null) {
@@ -303,8 +321,8 @@ export class DynamicTomSelect extends NetBoxTomSelect {
       // value. For example, if the dependency is the `rack` field, and the `rack` field's value
       // is `1`, this element's URL would change from `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
       const hasReplacement =
-        this.api_url.includes(`{{`) &&
-        Boolean(this.api_url.match(new RegExp(`({{(${id})}})`, 'g')));
+        apiUrl.includes(`{{`) &&
+        Boolean(apiUrl.match(new RegExp(`({{(${id})}})`, 'g')));
 
       if (hasReplacement) {
         if (element.value) {

+ 1 - 1
netbox/templates/ipam/prefix/prefixes.html

@@ -6,7 +6,7 @@
   {% include 'ipam/inc/max_depth.html' %}
   {% include 'ipam/inc/max_length.html' %}  
   {% if perms.ipam.add_prefix and first_available_prefix %}
-    <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.scope %}&scope_type={{ object.scope_type.pk }}&scope={{ object.scope.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-primary">
+    <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.scope %}&scope_content_type={{ object.scope_type.pk }}&scope_object_id={{ object.scope.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-primary">
       <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Prefix" %}
     </a>
   {% endif %}

+ 5 - 5
netbox/tenancy/migrations/0021_owner.py

@@ -13,35 +13,35 @@ class Migration(migrations.Migration):
             model_name='contact',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='contactgroup',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='contactrole',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='tenant',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='tenantgroup',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
     ]

+ 46 - 0
netbox/tenancy/migrations/0026_consolidate_unique_constraints.py

@@ -0,0 +1,46 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('extras', '0139_alter_customfieldchoiceset_extra_choices'),
+        ('tenancy', '0025_ltree_paths'),
+        ('users', '0016_default_ordering_indexes'),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name='tenant',
+            name='tenancy_tenant_unique_group_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='tenant',
+            name='tenancy_tenant_unique_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='tenant',
+            name='tenancy_tenant_unique_group_slug',
+        ),
+        migrations.RemoveConstraint(
+            model_name='tenant',
+            name='tenancy_tenant_unique_slug',
+        ),
+        migrations.AddConstraint(
+            model_name='tenant',
+            constraint=models.UniqueConstraint(
+                fields=('group', 'name'),
+                name='tenancy_tenant_unique_group_name',
+                nulls_distinct=False,
+                violation_error_message='Tenant name must be unique per group.',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='tenant',
+            constraint=models.UniqueConstraint(
+                fields=('group', 'slug'),
+                name='tenancy_tenant_unique_group_slug',
+                nulls_distinct=False,
+                violation_error_message='Tenant slug must be unique per group.',
+            ),
+        ),
+    ]

+ 2 - 11
netbox/tenancy/models/tenants.py

@@ -1,6 +1,5 @@
 from django.contrib.postgres.indexes import GistIndex
 from django.db import models
-from django.db.models import Q
 from django.utils.translation import gettext_lazy as _
 
 from netbox.models import NestedLtreeGroupModel, PrimaryModel
@@ -71,23 +70,15 @@ class Tenant(ContactsMixin, PrimaryModel):
             models.UniqueConstraint(
                 fields=('group', 'name'),
                 name='%(app_label)s_%(class)s_unique_group_name',
+                nulls_distinct=False,
                 violation_error_message=_("Tenant name must be unique per group.")
             ),
-            models.UniqueConstraint(
-                fields=('name',),
-                name='%(app_label)s_%(class)s_unique_name',
-                condition=Q(group__isnull=True)
-            ),
             models.UniqueConstraint(
                 fields=('group', 'slug'),
                 name='%(app_label)s_%(class)s_unique_group_slug',
+                nulls_distinct=False,
                 violation_error_message=_("Tenant slug must be unique per group.")
             ),
-            models.UniqueConstraint(
-                fields=('slug',),
-                name='%(app_label)s_%(class)s_unique_slug',
-                condition=Q(group__isnull=True)
-            ),
         )
         verbose_name = _('tenant')
         verbose_name_plural = _('tenants')

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

@@ -9,6 +9,6 @@
   "ownergroup:list_objects_with_permission": 17,
   "token:api_list_objects": 10,
   "token:list_objects_with_permission": 17,
-  "user:api_list_objects": 12,
+  "user:api_list_objects": 13,
   "user:list_objects_with_permission": 17
 }

+ 19 - 2
netbox/users/tests/test_api.py

@@ -21,6 +21,9 @@ class UserTestCase(APIViewTestCases.APIViewTestCase):
     model = User
     brief_fields = ['display', 'id', 'url', 'username']
     validation_excluded_fields = ['password']
+    # 'permissions' is a SerializedPKRelatedField sourced from object_permissions, so its view perm
+    # cannot be auto-derived from the model field name
+    user_permissions = ('users.view_objectpermission',)
     bulk_update_data = {
         'email': 'test@example.com',
     }
@@ -140,6 +143,9 @@ class UserTestCase(APIViewTestCases.APIViewTestCase):
 class GroupTestCase(APIViewTestCases.APIViewTestCase):
     model = Group
     brief_fields = ['description', 'display', 'id', 'name', 'url']
+    # 'permissions' is a SerializedPKRelatedField sourced from object_permissions, so its view perm
+    # cannot be auto-derived from the model field name
+    user_permissions = ('users.view_objectpermission',)
 
     @classmethod
     def setUpTestData(cls):
@@ -199,6 +205,7 @@ class TokenTestCase(
 ):
     model = Token
     brief_fields = ['description', 'display', 'enabled', 'id', 'key', 'url', 'version', 'write_enabled']
+    user_permissions = ('users.view_user',)
     bulk_update_data = {
         'description': 'New description',
     }
@@ -207,7 +214,10 @@ class TokenTestCase(
         super().setUp()
 
         # Apply grant_token permission to enable the creation of Tokens for other Users
-        self.add_permissions('users.grant_token')
+        self.add_permissions(
+            'users.grant_token',
+            'users.view_user',
+        )
 
     @classmethod
     def setUpTestData(cls):
@@ -294,7 +304,10 @@ class TokenTestCase(
         # Clear grant_token permission assigned by setUpTestData
         ObjectPermission.objects.filter(users=self.user).delete()
 
-        self.add_permissions('users.add_token')
+        self.add_permissions(
+            'users.add_token',
+            'users.view_user',
+        )
         user2 = User.objects.create_user(username='testuser2')
         data = {
             'user': user2.id,
@@ -677,6 +690,10 @@ class OwnerGroupTestCase(APIViewTestCases.APIViewTestCase):
 
 class OwnerTestCase(APIViewTestCases.APIViewTestCase):
     model = Owner
+    user_permissions = (
+        'users.view_group',
+        'users.view_user',
+    )
     brief_fields = ['description', 'display', 'id', 'name', 'url']
 
     @classmethod

+ 1 - 0
netbox/utilities/forms/fields/__init__.py

@@ -4,3 +4,4 @@ from .csv import *
 from .dynamic import *
 from .expandable import *
 from .fields import *
+from .generic import *

+ 249 - 0
netbox/utilities/forms/fields/generic.py

@@ -0,0 +1,249 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.forms.boundfield import BoundField
+from django.utils.translation import gettext_lazy as _
+
+from utilities.forms.widgets import APISelect, GenericObjectSelect, HTMXSelect
+from utilities.views import get_action_url
+
+from .content_types import ContentTypeChoiceField
+from .dynamic import DynamicModelChoiceField
+
+__all__ = (
+    'GenericObjectChoiceField',
+)
+
+
+class GenericObjectChoiceField(forms.MultiValueField):
+    """
+    Select an object for assignment to a generic foreign key.
+
+    Renders a content-type selector (HTMXSelect) plus an API-backed object selector (APISelect) as a single
+    field. Changing the content type re-renders the form so the object selector is rebuilt for the new model.
+    The field's cleaned value is the selected model instance (or None); assignment to the GFK descriptor is
+    handled by GenericObjectFormMixin (or the consuming form's clean()).
+
+    Args:
+        content_type_queryset: Queryset of ContentTypes the user may choose from.
+        query_params: Optional dict of static/dynamic ($field) query params forwarded to the object selector.
+        selector: If True, expose the advanced object-selector modal for the object subwidget.
+        gfk_name: Name of the model's GenericForeignKey descriptor, if it differs from the form field name.
+        hx_method: HTTP method for the content-type HTMXSelect ('get' for model forms, 'post' for bulk edit).
+        hx_include_id: HTML id of the container whose fields are included in the HTMX request. This should
+            generally remain 'form_fields' so dependent fields can resolve against the full form state.
+        hx_target_id: html_id of the enclosing FieldSet for an HTMX partial swap. If omitted, the whole
+            #form_fields container is re-rendered.
+    """
+    default_error_messages = {
+        'incomplete': _("Both an object type and an object must be specified."),
+        'invalid_object_type': _("Invalid object type."),
+    }
+
+    def __init__(
+        self, *, content_type_queryset, query_params=None, selector=False, gfk_name=None,
+        hx_method='get', hx_include_id='form_fields', hx_target_id=None, **kwargs
+    ):
+        self.content_type_queryset = content_type_queryset
+        self._content_type_cache = {}
+        self.query_params = query_params or {}
+        self.selector = selector
+        self.gfk_name = gfk_name
+        self.hx_target_id = hx_target_id
+        self.selected_model = None
+
+        # NetBox's current HTMXSelect separates the include source from the swap target. Always include the
+        # full form so server-side dependent-field resolution sees every field, while optionally targeting only
+        # the containing fieldset for the swap. Bulk edit forms keep the historical hx-select=#form_fields path.
+        htmx_attrs = {}
+        if hx_target_id is None and hx_method.lower() == 'post':
+            htmx_attrs['hx-select'] = '#form_fields'
+        content_type_widget = HTMXSelect(
+            method=hx_method,
+            hx_include_id=hx_include_id,
+            hx_target_id=hx_target_id,
+            attrs=htmx_attrs or None,
+        )
+        object_widget = APISelect()
+
+        fields = (
+            ContentTypeChoiceField(queryset=content_type_queryset, required=False, widget=content_type_widget),
+            # Empty placeholder until a content type is chosen; _configure_object_field installs the real
+            # (and possibly permission-restricted) queryset.
+            DynamicModelChoiceField(
+                queryset=ContentType.objects.none(), required=False, selector=selector, widget=object_widget
+            ),
+        )
+        widget = GenericObjectSelect(content_type_widget=content_type_widget, object_widget=object_widget)
+
+        super().__init__(fields=fields, require_all_fields=False, widget=widget, **kwargs)
+
+    @property
+    def content_type_field(self):
+        return self.fields[0]
+
+    @property
+    def object_field(self):
+        return self.fields[1]
+
+    @property
+    def queryset(self):
+        # Exposed at the top level so restrict_form_fields() (which only inspects top-level fields) can apply
+        # object-permission restrictions to the nested object selector.
+        return self.object_field.queryset
+
+    @queryset.setter
+    def queryset(self, queryset):
+        # Sync widget refs first so choices land on the rendered MultiWidget subwidget, not an orphan copy.
+        self._sync_widget_refs()
+        self._set_object_queryset(queryset)
+
+    def _set_object_queryset(self, queryset):
+        self.object_field.queryset = queryset
+        self.object_field.widget.choices = self.object_field.choices
+
+    def _get_object_queryset(self, model):
+        # Preserve a queryset already restricted by restrict_form_fields() when it targets the selected model;
+        # otherwise start from the model's default manager.
+        queryset = self.object_field.queryset
+        if getattr(queryset, 'model', None) is model:
+            return queryset.all()
+        return model.objects.all()
+
+    def _sync_widget_refs(self):
+        # The form metaclass deep-copies the field, its subfields, and the MultiWidget independently. Re-point
+        # the subfields at the MultiWidget's widgets so attrs we set on the object subfield are actually rendered.
+        # Re-assign choices as well: Select widgets keep their own choices iterator, and a copied MultiWidget
+        # subwidget can otherwise render as an empty Tom Select even though the subfield queryset is populated.
+        self.content_type_field.widget = self.widget.widgets[0]
+        self.object_field.widget = self.widget.widgets[1]
+        self.content_type_field.widget.choices = self.content_type_field.choices
+        self.object_field.widget.choices = self.object_field.choices
+
+    def _resolve_subvalue(self, form, field_name, suffix):
+        # Read the current value of one subwidget across the three paths: bound submit/POST re-render,
+        # unbound HTMX GET re-render (values arrive as initial under the subwidget keys), and normal edit
+        # (field-level initial holds the related object instance).
+        key = f'{field_name}_{suffix}'
+        if form.is_bound and key in form.data:
+            return form.data.get(key)
+        if key in form.initial:
+            return form.initial.get(key)
+        obj = form.initial.get(field_name, self.initial)
+        if obj in self.empty_values or not hasattr(obj, '_meta'):
+            return None
+        if suffix == 'content_type':
+            return ContentType.objects.get_for_model(obj).pk
+        return obj.pk
+
+    def _get_content_type(self, value):
+        if value in self.empty_values:
+            return None
+        try:
+            pk = int(value)
+        except (TypeError, ValueError):
+            return None
+        if pk not in self._content_type_cache:
+            try:
+                # Constrain to the allowed queryset so out-of-set types resolve to None.
+                self._content_type_cache[pk] = self.content_type_queryset.get(pk=pk)
+            except ObjectDoesNotExist:
+                self._content_type_cache[pk] = None
+        return self._content_type_cache[pk]
+
+    def _configure_object_field(self, content_type, object_value=None):
+        # Clear any state left over from a previously selected content type
+        widget = self.object_field.widget
+        for attr in ('data-url', 'data-dynamic-params', 'data-static-params', 'disabled', 'selector'):
+            widget.attrs.pop(attr, None)
+        widget.dynamic_params = {}
+        widget.static_params = {}
+        self.selected_model = None
+
+        model = content_type.model_class() if content_type else None
+        if model is None:
+            # No type selected: keep an empty placeholder queryset and disable the object selector.
+            self._set_object_queryset(ContentType.objects.none())
+            widget.attrs['disabled'] = 'disabled'
+            return None
+
+        self.selected_model = model
+
+        # Narrow the queryset to the current value to avoid loading the entire table for rendering
+        queryset = self._get_object_queryset(model)
+        if object_value in self.empty_values:
+            queryset = queryset.none()
+        else:
+            lookup = getattr(self.object_field, 'to_field_name', None) or 'pk'
+            try:
+                queryset = queryset.filter(**{lookup: object_value})
+            except (TypeError, ValueError, ValidationError):
+                queryset = queryset.none()
+        self._set_object_queryset(queryset)
+
+        widget.attrs['data-url'] = get_action_url(model, action='list', rest_api=True)
+        if self.query_params:
+            widget.add_query_params(self.query_params)
+        if self.selector:
+            widget.attrs['selector'] = model._meta.label_lower
+
+        return model
+
+    def prepare(self, form, field_name):
+        """Configure the object selector for the field's current content type (called during rendering)."""
+        self._sync_widget_refs()
+
+        # Clear the paired object selection client-side when the content type changes, so a stale object_id
+        # cannot cross model boundaries on the HTMX re-render (a Site pk must not resurface as a Region pk).
+        self.content_type_field.widget.attrs['hx-on::config-request'] = (
+            f"event.detail.parameters['{field_name}_object_id'] = ''"
+        )
+
+        content_type_value = self._resolve_subvalue(form, field_name, 'content_type')
+        object_value = self._resolve_subvalue(form, field_name, 'object_id')
+        self._configure_object_field(self._get_content_type(content_type_value), object_value)
+
+        # On an unbound HTMX GET re-render the submitted values live under the subwidget keys, not under the
+        # field name; seed the field initial so the subwidgets render the current selection.
+        if not form.is_bound:
+            self.initial = [content_type_value, object_value]
+
+    def get_bound_field(self, form, field_name):
+        self.prepare(form, field_name)
+        return BoundField(form, self, field_name)
+
+    def clean(self, value):
+        self._sync_widget_refs()
+        value = value or []
+        content_type_value = value[0] if len(value) > 0 else None
+        object_value = value[1] if len(value) > 1 else None
+
+        if content_type_value in self.empty_values and object_value in self.empty_values:
+            self._configure_object_field(None)
+            if self.required:
+                raise ValidationError(self.error_messages['required'], code='required')
+            return None
+
+        if content_type_value in self.empty_values or object_value in self.empty_values:
+            # Name the selected type when the object is missing so the message is actionable.
+            if content_type_value not in self.empty_values:
+                content_type = self._get_content_type(content_type_value)
+                if content_type is not None and (model := content_type.model_class()) is not None:
+                    raise ValidationError(
+                        _("Please select a {object_type}.").format(object_type=model._meta.verbose_name),
+                        code='incomplete',
+                    )
+            raise ValidationError(self.error_messages['incomplete'], code='incomplete')
+
+        # Validates the content type is within the allowed queryset
+        content_type = self.content_type_field.clean(content_type_value)
+        model = self._configure_object_field(content_type, object_value)
+        if model is None:
+            raise ValidationError(self.error_messages['invalid_object_type'], code='invalid_object_type')
+
+        # Validates the object exists (queryset was narrowed to the value)
+        return self.object_field.clean(object_value)
+
+    def compress(self, data_list):
+        # clean() returns the validated object directly; compress() is intentionally bypassed.
+        raise NotImplementedError("GenericObjectChoiceField.clean() returns the selected object directly.")

+ 63 - 1
netbox/utilities/forms/mixins.py

@@ -1,12 +1,14 @@
 import time
+import warnings
 from decimal import Decimal
 
 from django import forms
+from django.conf import settings
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.utils.translation import gettext_lazy as _
 
 from netbox.registry import registry
-from utilities.forms.fields import ColorField, QueryField, TagFilterField
+from utilities.forms.fields import ColorField, GenericObjectChoiceField, QueryField, TagFilterField
 from utilities.forms.widgets import FilterModifierWidget
 from utilities.forms.widgets.modifiers import MODIFIER_EMPTY_FALSE, MODIFIER_EMPTY_TRUE
 
@@ -16,6 +18,7 @@ __all__ = (
     'CheckLastUpdatedMixin',
     'DistanceValidationMixin',
     'FilterModifierMixin',
+    'GenericObjectFormMixin',
 )
 
 
@@ -164,6 +167,65 @@ class DistanceValidationMixin(forms.Form):
     )
 
 
+class GenericObjectFormMixin:
+    """
+    Initialize and assign any GenericObjectChoiceField fields on a form.
+
+    Seeds each field's initial value from the model's GFK descriptor, configures the API-backed object
+    selector for the current content type, and copies the cleaned object back to the instance before model
+    validation runs. Keeps the common GFK form pattern out of individual model forms.
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        instance = getattr(self, 'instance', None)
+        for field_name, field in self._generic_object_fields():
+            gfk_name = field.gfk_name or field_name
+            # On an HTMX re-render the submitted subwidget values take precedence over the stored instance value.
+            rerendered = any(f'{field_name}_{suffix}' in self.initial for suffix in ('content_type', 'object_id'))
+            if instance is not None and not self.is_bound and field_name not in self.initial and not rerendered:
+                if (initial := getattr(instance, gfk_name, None)) is not None:
+                    self.initial[field_name] = initial
+            # Prepare eagerly so forms can read field.selected_model in their own __init__ (e.g. PrefixForm).
+            # prepare() is idempotent and re-runs at render via get_bound_field().
+            field.prepare(self, field_name)
+
+        if settings.DEBUG:
+            self._warn_missing_htmx_fieldsets()
+
+    def _generic_object_fields(self):
+        for field_name, field in self.fields.items():
+            if isinstance(field, GenericObjectChoiceField):
+                yield field_name, field
+
+    def _warn_missing_htmx_fieldsets(self):
+        # Each GenericObjectChoiceField with an HTMX target needs a matching FieldSet(html_id=...) for the
+        # partial swap to land; warn in development if a consumer forgot to declare one.
+        fieldset_ids = {getattr(fs, 'html_id', None) for fs in getattr(self, 'fieldsets', [])}
+        for field_name, field in self._generic_object_fields():
+            if field.hx_target_id and field.hx_target_id not in fieldset_ids:
+                warnings.warn(
+                    f"{type(self).__name__} has a GenericObjectChoiceField '{field_name}' targeting "
+                    f"#{field.hx_target_id} for HTMX swap but declares no FieldSet with "
+                    f"html_id='{field.hx_target_id}'; the partial swap will fail silently.",
+                    stacklevel=3,
+                )
+
+    def clean(self):
+        cleaned_data = super().clean()
+        if cleaned_data is None:
+            cleaned_data = self.cleaned_data
+
+        instance = getattr(self, 'instance', None)
+        if instance is not None:
+            for field_name, field in self._generic_object_fields():
+                if field_name in cleaned_data:
+                    setattr(instance, field.gfk_name or field_name, cleaned_data[field_name])
+
+        return cleaned_data
+
+
 class FilterModifierMixin:
     """
     Mixin that enhances filter form fields with lookup modifier dropdowns.

+ 1 - 0
netbox/utilities/forms/widgets/__init__.py

@@ -1,5 +1,6 @@
 from .apiselect import *
 from .datetime import *
+from .generic import *
 from .misc import *
 from .modifiers import *
 from .select import *

+ 39 - 0
netbox/utilities/forms/widgets/generic.py

@@ -0,0 +1,39 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+
+from .apiselect import APISelect
+from .select import HTMXSelect
+
+__all__ = (
+    'GenericObjectSelect',
+)
+
+
+class GenericObjectSelect(forms.MultiWidget):
+    """
+    Composite widget pairing a content-type selector with an API-backed object selector. Used by
+    GenericObjectChoiceField to represent a generic foreign key as a single form field.
+
+    Because the subwidgets are supplied as a dict, the rendered names are suffixed with the dict keys,
+    e.g. a field named "scope" renders inputs named "scope_content_type" and "scope_object_id".
+    """
+    template_name = 'widgets/generic_object.html'
+
+    def __init__(self, content_type_widget=None, object_widget=None, attrs=None):
+        widgets = {
+            'content_type': content_type_widget or HTMXSelect(),
+            'object_id': object_widget or APISelect(),
+        }
+        super().__init__(widgets, attrs)
+
+    def decompress(self, value):
+        # An object instance: split into (content type pk, object pk)
+        if value and hasattr(value, '_meta'):
+            return [ContentType.objects.get_for_model(value).pk, value.pk]
+        # An already-decomposed [content_type, object_id] pair
+        if isinstance(value, (list, tuple)):
+            if len(value) == 2:
+                return list(value)
+            if len(value) == 1:
+                return [value[0], None]
+        return [None, None]

+ 16 - 0
netbox/utilities/permissions.py

@@ -15,6 +15,7 @@ __all__ = (
     'qs_filter_from_constraints',
     'resolve_permission',
     'resolve_permission_type',
+    'restrict_queryset',
 )
 
 
@@ -159,3 +160,18 @@ def qs_filter_from_constraints(constraints, tokens=None):
             return Q()
 
     return params
+
+
+def restrict_queryset(queryset, request, action='view'):
+    """
+    Restrict a queryset to the objects the request user may act on.
+
+    Used when resolving related objects from REST API write input so that an object the user
+    cannot view fails resolution exactly as a nonexistent object would. When no request context
+    is available (internal calls), or the queryset does not support restriction (models without a
+    RestrictedQuerySet manager have no object-level permissions to enforce), it is returned unchanged.
+    """
+    if request is not None and hasattr(queryset, 'restrict'):
+        # An unauthenticated request resolves no objects (restrict() returns none()).
+        return queryset.restrict(getattr(request, 'user', None), action)
+    return queryset

+ 27 - 0
netbox/utilities/string.py

@@ -2,12 +2,39 @@ import re
 
 __all__ = (
     'enum_key',
+    'humanize_duration',
     'remove_linebreaks',
     'title',
     'trailing_slash',
 )
 
 
+def humanize_duration(value):
+    """
+    Express a timedelta in a human-friendly format. Example: 1h 5m 23s. Returns an empty string
+    for None; zero-duration timedeltas render as "0s".
+    """
+    if value is None:
+        return ''
+
+    # Round to whole seconds and decompose
+    total_seconds = int(value.total_seconds())
+    days, remainder = divmod(total_seconds, 86400)
+    hours, remainder = divmod(remainder, 3600)
+    minutes, seconds = divmod(remainder, 60)
+
+    ret = ''
+    if days:
+        ret += f'{days}d '
+    if hours:
+        ret += f'{hours}h '
+    if minutes:
+        ret += f'{minutes}m '
+    if seconds or not ret:
+        ret += f'{seconds}s'
+    return ret.strip()
+
+
 def enum_key(value):
     """
     Convert the given value to a string suitable for use as an Enum key.

+ 13 - 0
netbox/utilities/templates/widgets/generic_object.html

@@ -0,0 +1,13 @@
+{% load i18n %}
+{% with content_type_widget=widget.subwidgets.0 object_widget=widget.subwidgets.1 %}
+  <div class="row g-2">
+    <div class="col-12 col-md-5">
+      {% include content_type_widget.template_name with widget=content_type_widget %}
+      <div class="form-text mb-1">{% trans "Object type" %}</div>
+    </div>
+    <div class="col-12 col-md-7">
+      {% include object_widget.template_name with widget=object_widget %}
+      <div class="form-text mb-1">{% trans "Object" %}</div>
+    </div>
+  </div>
+{% endwith %}

+ 9 - 0
netbox/utilities/testing/api.py

@@ -302,6 +302,8 @@ class APIViewTestCases:
             obj_perm.users.add(self.user)
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
+            self.add_related_view_permissions(self.create_data[0])
+
             data = copy.deepcopy(self.create_data[0])
 
             # If supported, add a changelog message
@@ -343,6 +345,8 @@ class APIViewTestCases:
             obj_perm.users.add(self.user)
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
+            self.add_related_view_permissions(*self.create_data)
+
             # If supported, add a changelog message
             changelog_message = get_random_string(10)
             if issubclass(self.model, ChangeLoggingMixin):
@@ -418,6 +422,8 @@ class APIViewTestCases:
             obj_perm.users.add(self.user)
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
+            self.add_related_view_permissions(update_data)
+
             data = copy.deepcopy(update_data)
 
             # If supported, add a changelog message
@@ -458,6 +464,7 @@ class APIViewTestCases:
             instance = self._get_queryset().first()
             url = self._get_detail_url(instance)
             update_data = self.update_data or getattr(self, 'create_data')[0]
+            self.add_related_view_permissions(update_data)
 
             # Fetch current ETag
             get_response = self.client.get(url, **self.header)
@@ -499,6 +506,8 @@ class APIViewTestCases:
             obj_perm.users.add(self.user)
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
+            self.add_related_view_permissions(self.bulk_update_data)
+
             id_list = list(self._get_queryset().values_list('id', flat=True)[:3])
             self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk update")
             data = [

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

@@ -18,7 +18,7 @@ from core.models import ObjectType
 from users.models import ObjectPermission, User
 from utilities.data import ranges_to_string
 from utilities.object_types import object_type_identifier
-from utilities.permissions import resolve_permission_type
+from utilities.permissions import get_permission_for_model, resolve_permission_type
 
 from .utils import DUMMY_CF_DATA, extract_form_failures
 
@@ -138,6 +138,29 @@ class ModelTestCase(TestCase):
         """
         return self.model.objects.all()
 
+    def add_related_view_permissions(self, *payloads):
+        """
+        Grant the test user view permission on each permission-controlled related model referenced
+        by the given write payload(s), so nested related-object resolution succeeds while object-level
+        permission enforcement stays active (no global view exemption).
+
+        Foreign-key and many-to-many references are derived automatically from the payload. References
+        that cannot be derived from the model fields (e.g. generic-FK targets) should be granted via the
+        test case's user_permissions.
+        """
+        permissions = set()
+        for payload in payloads:
+            if not isinstance(payload, dict):
+                continue
+            for field in self.model._meta.get_fields():
+                related_model = getattr(field, 'related_model', None)
+                if field.name not in payload or related_model is None:
+                    continue
+                if hasattr(related_model.objects, 'restrict'):
+                    permissions.add(get_permission_for_model(related_model, 'view'))
+        if permissions:
+            self.add_permissions(*permissions)
+
     def prepare_instance(self, instance):
         """
         Test cases can override this method to perform any necessary manipulation of an instance prior to its evaluation

+ 3 - 3
netbox/utilities/tests/test_api.py

@@ -38,7 +38,7 @@ class WritableNestedSerializerTestCase(APITestCase):
             'site': self.site1.pk,
         }
         url = reverse('ipam-api:vlan-list')
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('dcim.view_site', 'ipam.add_vlan')
 
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -70,7 +70,7 @@ class WritableNestedSerializerTestCase(APITestCase):
             },
         }
         url = reverse('ipam-api:vlan-list')
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('dcim.view_site', 'ipam.add_vlan')
 
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -106,7 +106,7 @@ class WritableNestedSerializerTestCase(APITestCase):
             },
         }
         url = reverse('ipam-api:vlan-list')
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('dcim.view_region', 'dcim.view_site', 'ipam.add_vlan')
 
         with disable_warnings('django.request'):
             response = self.client.post(url, data, format='json', **self.header)

+ 231 - 0
netbox/utilities/tests/test_forms.py

@@ -1,4 +1,8 @@
+import warnings
+from types import SimpleNamespace
+
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase, override_settings
 
 from dcim.models import Site
@@ -6,7 +10,9 @@ from netbox.choices import ImportFormatChoices
 from utilities.forms.bulk_import import BulkImportForm
 from utilities.forms.fields.csv import CSVSelectWidget
 from utilities.forms.fields.dynamic import DynamicChoiceField, DynamicMultipleChoiceField
+from utilities.forms.fields.generic import GenericObjectChoiceField
 from utilities.forms.forms import BulkRenameForm
+from utilities.forms.mixins import GenericObjectFormMixin
 from utilities.forms.rendering import FieldSet
 from utilities.forms.utils import (
     expand_alphanumeric_pattern,
@@ -616,6 +622,231 @@ class DynamicMultipleChoiceFieldTestCase(TestCase):
         self.assertEqual(form.fields['field'].choices, [])
 
 
+class GenericObjectChoiceFieldTestCase(TestCase):
+    """Validate generic foreign key object selection via a content type plus an API-backed object selector."""
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+        cls.site_type = ContentType.objects.get_for_model(Site)
+        cls.invalid_type = ContentType.objects.get_for_model(ContentType)
+
+    def _make_form(self, data=None, initial=None, required=False):
+        site_type = self.site_type
+
+        class TestForm(forms.Form):
+            obj = GenericObjectChoiceField(
+                content_type_queryset=ContentType.objects.filter(pk=site_type.pk),
+                required=required,
+                selector=True,
+            )
+
+        return TestForm(data=data, initial=initial)
+
+    def test_valid_value_returns_selected_object(self):
+        form = self._make_form(
+            data={'obj_content_type': str(self.site_type.pk), 'obj_object_id': str(self.site.pk)},
+            required=True,
+        )
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertEqual(form.cleaned_data['obj'], self.site)
+
+    def test_optional_empty_value_returns_none(self):
+        form = self._make_form(data={'obj_content_type': '', 'obj_object_id': ''})
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertIsNone(form.cleaned_data['obj'])
+
+    def test_required_empty_value_is_invalid(self):
+        form = self._make_form(data={'obj_content_type': '', 'obj_object_id': ''}, required=True)
+        self.assertFalse(form.is_valid())
+        self.assertIn('obj', form.errors)
+
+    def test_incomplete_value_is_invalid(self):
+        for data in (
+            {'obj_content_type': str(self.site_type.pk), 'obj_object_id': ''},
+            {'obj_content_type': '', 'obj_object_id': str(self.site.pk)},
+        ):
+            form = self._make_form(data=data)
+            self.assertFalse(form.is_valid())
+            self.assertIn('obj', form.errors)
+
+    def test_invalid_content_type_is_rejected(self):
+        form = self._make_form(
+            data={'obj_content_type': str(self.invalid_type.pk), 'obj_object_id': str(self.site.pk)}
+        )
+        self.assertFalse(form.is_valid())
+        self.assertIn('obj', form.errors)
+
+    def test_invalid_object_id_is_rejected(self):
+        form = self._make_form(
+            data={'obj_content_type': str(self.site_type.pk), 'obj_object_id': str(self.site.pk + 1000)}
+        )
+        self.assertFalse(form.is_valid())
+        self.assertIn('obj', form.errors)
+
+    def test_initial_object_configures_object_selector(self):
+        form = self._make_form(initial={'obj': self.site})
+        field = form.fields['obj']
+        bound_field = field.get_bound_field(form, 'obj')
+        self.assertEqual(field.selected_model, Site)
+        self.assertEqual(list(field.object_field.queryset), [self.site])
+        self.assertEqual(field.object_field.widget.attrs['selector'], Site._meta.label_lower)
+        self.assertIn('data-url', field.object_field.widget.attrs)
+        self.assertIn('obj_content_type', str(bound_field))
+        self.assertIn('obj_object_id', str(bound_field))
+
+    def test_unbound_htmx_rerender_preserves_selection(self):
+        # Simulates an HTMX content-type change: the view passes the submitted subwidget values
+        # via `initial` (form is unbound). The object selector must reconfigure for the new type.
+        form = self._make_form(initial={
+            'obj_content_type': str(self.site_type.pk),
+            'obj_object_id': str(self.site.pk),
+        })
+        field = form.fields['obj']
+        field.get_bound_field(form, 'obj')
+        self.assertEqual(field.selected_model, Site)
+        self.assertIn('data-url', field.object_field.widget.attrs)
+        self.assertEqual(field.initial, [str(self.site_type.pk), str(self.site.pk)])
+
+    def test_initial_content_type_is_selected_on_render(self):
+        # The content-type subwidget must render its options and mark the current type selected on edit forms.
+        form = self._make_form(initial={'obj': self.site})
+        content_type_html = str(form['obj']).split('name="obj_object_id"')[0]
+        self.assertRegex(content_type_html, rf'value="{self.site_type.pk}"\s+selected')
+
+    def test_queryset_property_proxies_object_field(self):
+        """The top-level queryset proxy reads and writes the nested object field's queryset."""
+        form = self._make_form()
+        field = form.fields['obj']
+        self.assertIs(field.queryset, field.object_field.queryset)
+        field.queryset = Site.objects.all()
+        self.assertIs(field.object_field.queryset, field.queryset)
+        self.assertEqual(list(field.queryset), list(Site.objects.all()))
+
+    def test_queryset_setter_syncs_rendered_subwidget(self):
+        """Assigning queryset (as restrict_form_fields does, pre-render) populates the rendered subwidget."""
+        form = self._make_form()
+        field = form.fields['obj']
+        field.queryset = Site.objects.all()
+        self.assertIs(field.object_field.widget, field.widget.widgets[1])
+        rendered_values = [getattr(value, 'value', value) for value, label in field.object_field.widget.choices]
+        self.assertIn(self.site.pk, rendered_values)
+
+    def test_restricted_queryset_rejects_unviewable_object(self):
+        """An object outside the restricted queryset is rejected, mirroring restrict_form_fields()."""
+        form = self._make_form(
+            data={'obj_content_type': str(self.site_type.pk), 'obj_object_id': str(self.site.pk)},
+            required=True,
+        )
+        # restrict_form_fields() narrows the nested queryset via the top-level proxy before validation.
+        form.fields['obj'].queryset = Site.objects.exclude(pk=self.site.pk)
+        self.assertFalse(form.is_valid())
+        self.assertIn('obj', form.errors)
+
+    def test_restricted_queryset_allows_viewable_object(self):
+        """An object within the restricted queryset still validates."""
+        form = self._make_form(
+            data={'obj_content_type': str(self.site_type.pk), 'obj_object_id': str(self.site.pk)},
+            required=True,
+        )
+        form.fields['obj'].queryset = Site.objects.all()
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertEqual(form.cleaned_data['obj'], self.site)
+
+    def test_incomplete_value_names_selected_type(self):
+        """When a type is chosen but no object, the error names the selected type."""
+        form = self._make_form(
+            data={'obj_content_type': str(self.site_type.pk), 'obj_object_id': ''},
+        )
+        self.assertFalse(form.is_valid())
+        self.assertIn('Please select a site.', form.errors['obj'])
+
+    def test_content_type_change_clears_stale_object_id(self):
+        """The content-type selector clears its paired object_id client-side on change (stale-PK guard)."""
+        form = self._make_form(initial={'obj': self.site})
+        field = form.fields['obj']
+        field.get_bound_field(form, 'obj')
+        self.assertEqual(
+            field.content_type_field.widget.attrs.get('hx-on::config-request'),
+            "event.detail.parameters['obj_object_id'] = ''",
+        )
+
+    def test_content_type_lookup_is_cached(self):
+        """Repeated content-type resolution hits the database only once per field instance."""
+        field = self._make_form().fields['obj']
+        with self.assertNumQueries(1):
+            first = field._get_content_type(str(self.site_type.pk))
+            second = field._get_content_type(str(self.site_type.pk))
+        self.assertEqual(first, self.site_type)
+        self.assertEqual(second, self.site_type)
+
+    def test_content_type_outside_queryset_returns_none(self):
+        """A content type outside the allowed queryset still resolves to None (constraint preserved)."""
+        field = self._make_form().fields['obj']
+        self.assertIsNone(field._get_content_type(str(self.invalid_type.pk)))
+
+    def test_compress_is_not_implemented(self):
+        """compress() is intentionally unreachable and raises to signal clean() owns the conversion."""
+        field = self._make_form().fields['obj']
+        with self.assertRaises(NotImplementedError):
+            field.compress([self.site_type, self.site])
+
+
+class GenericObjectFormMixinTestCase(TestCase):
+    """Validate the DEBUG warning emitted when an HTMX target has no matching FieldSet."""
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site = Site.objects.create(name='Mixin Test Site', slug='mixin-test-site')
+        cls.site_type = ContentType.objects.get_for_model(Site)
+
+    def _field(self):
+        return GenericObjectChoiceField(
+            content_type_queryset=ContentType.objects.filter(pk=self.site_type.pk),
+            required=False,
+            hx_target_id='scope',
+        )
+
+    @override_settings(DEBUG=True)
+    def test_missing_htmx_fieldset_warns(self):
+        field = self._field()
+
+        class MissingFieldsetForm(GenericObjectFormMixin, forms.Form):
+            obj = field
+
+        with self.assertWarns(UserWarning):
+            MissingFieldsetForm()
+
+    @override_settings(DEBUG=True)
+    def test_present_htmx_fieldset_does_not_warn(self):
+        field = self._field()
+
+        class PresentFieldsetForm(GenericObjectFormMixin, forms.Form):
+            obj = field
+            fieldsets = (FieldSet('obj', name='Scope', html_id='scope'),)
+
+        with warnings.catch_warnings(record=True) as caught:
+            warnings.simplefilter('always')
+            PresentFieldsetForm()
+        self.assertEqual([w for w in caught if 'html_id' in str(w.message)], [])
+
+    def test_gfk_name_routes_assignment(self):
+        """When gfk_name differs from the field name, the cleaned object is assigned to that attribute."""
+        site_type = self.site_type
+
+        class TargetForm(GenericObjectFormMixin, forms.Form):
+            target = GenericObjectChoiceField(
+                content_type_queryset=ContentType.objects.filter(pk=site_type.pk),
+                required=False,
+                gfk_name='assigned_object',
+            )
+
+        form = TargetForm(data={'target_content_type': str(site_type.pk), 'target_object_id': str(self.site.pk)})
+        form.instance = SimpleNamespace()
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertEqual(form.instance.assigned_object, self.site)
+
+
 class GetCapacityUnitLabelTestCase(TestCase):
     """
     Test the get_capacity_unit_label function for correct base unit label.

+ 33 - 0
netbox/utilities/tests/test_string.py

@@ -0,0 +1,33 @@
+from datetime import timedelta
+
+from django.test import TestCase
+
+from utilities.string import humanize_duration
+
+
+class HumanizeDurationTest(TestCase):
+
+    def test_none(self):
+        self.assertEqual(humanize_duration(None), '')
+
+    def test_zero_duration(self):
+        self.assertEqual(humanize_duration(timedelta(0)), '0s')
+
+    def test_seconds_only(self):
+        self.assertEqual(humanize_duration(timedelta(seconds=45)), '45s')
+
+    def test_minutes_and_seconds(self):
+        self.assertEqual(humanize_duration(timedelta(minutes=5, seconds=23)), '5m 23s')
+
+    def test_hours_minutes_seconds(self):
+        self.assertEqual(humanize_duration(timedelta(hours=1, minutes=5, seconds=23)), '1h 5m 23s')
+
+    def test_days(self):
+        self.assertEqual(humanize_duration(timedelta(days=2, hours=3, minutes=17)), '2d 3h 17m')
+
+    def test_whole_minute_omits_seconds(self):
+        self.assertEqual(humanize_duration(timedelta(minutes=2)), '2m')
+
+    def test_sub_second_rounds_down_to_zero(self):
+        # Fractional seconds are truncated; a sub-second duration reads as 0s.
+        self.assertEqual(humanize_duration(timedelta(milliseconds=500)), '0s')

+ 1 - 1
netbox/virtualization/forms/bulk_edit.py

@@ -73,7 +73,7 @@ class ClusterBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
     model = Cluster
     fieldsets = (
         FieldSet('type', 'group', 'status', 'tenant', 'description'),
-        FieldSet('scope_type', 'scope', name=_('Scope')),
+        FieldSet('scope', name=_('Scope')),
     )
     nullable_fields = (
         'group', 'scope', 'tenant', 'description', 'comments',

+ 2 - 2
netbox/virtualization/forms/model_forms.py

@@ -74,14 +74,14 @@ class ClusterForm(TenancyForm, ScopedForm, PrimaryModelForm):
 
     fieldsets = (
         FieldSet('name', 'type', 'group', 'status', 'description', 'tags', name=_('Cluster')),
-        FieldSet('scope_type', 'scope', name=_('Scope'), html_id='scope'),
+        FieldSet('scope', name=_('Scope'), html_id='scope'),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
 
     class Meta:
         model = Cluster
         fields = (
-            'name', 'type', 'group', 'status', 'tenant', 'scope_type', 'description', 'owner', 'comments', 'tags',
+            'name', 'type', 'group', 'status', 'tenant', 'description', 'owner', 'comments', 'tags',
         )
 
 

+ 6 - 6
netbox/virtualization/migrations/0049_owner.py

@@ -13,42 +13,42 @@ class Migration(migrations.Migration):
             model_name='cluster',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='clustergroup',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='clustertype',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='virtualdisk',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='virtualmachine',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
         migrations.AddField(
             model_name='vminterface',
             name='owner',
             field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
             ),
         ),
     ]

+ 2 - 1
netbox/virtualization/migrations/0054_virtualmachinetype.py

@@ -64,7 +64,8 @@ class Migration(migrations.Migration):
                 (
                     'owner',
                     models.ForeignKey(
-                        blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                        blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+',
+                        to='users.owner'
                     ),
                 ),
                 ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),

+ 56 - 0
netbox/virtualization/migrations/0059_consolidate_unique_constraints.py

@@ -0,0 +1,56 @@
+import django.db.models.functions.text
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0241_consolidate_unique_constraints'),
+        ('extras', '0139_alter_customfieldchoiceset_extra_choices'),
+        ('ipam', '0093_denormalization_triggers'),
+        ('tenancy', '0026_consolidate_unique_constraints'),
+        ('users', '0016_default_ordering_indexes'),
+        ('virtualization', '0058_virtualmachine__config_context_data'),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name='virtualmachine',
+            name='virtualization_virtualmachine_unique_name_cluster_tenant',
+        ),
+        migrations.RemoveConstraint(
+            model_name='virtualmachine',
+            name='virtualization_virtualmachine_unique_name_cluster',
+        ),
+        migrations.RemoveConstraint(
+            model_name='virtualmachine',
+            name='virtualization_virtualmachine_unique_name_device_tenant',
+        ),
+        migrations.RemoveConstraint(
+            model_name='virtualmachine',
+            name='virtualization_virtualmachine_unique_name_device',
+        ),
+        migrations.AddConstraint(
+            model_name='virtualmachine',
+            constraint=models.UniqueConstraint(
+                django.db.models.functions.text.Lower('name'),
+                models.F('cluster'),
+                models.F('tenant'),
+                condition=models.Q(('cluster__isnull', False)),
+                name='virtualization_virtualmachine_unique_name_cluster_tenant',
+                nulls_distinct=False,
+                violation_error_message='Virtual machine name must be unique per cluster and tenant.',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='virtualmachine',
+            constraint=models.UniqueConstraint(
+                django.db.models.functions.text.Lower('name'),
+                models.F('device'),
+                models.F('tenant'),
+                condition=models.Q(('cluster__isnull', True), ('device__isnull', False)),
+                name='virtualization_virtualmachine_unique_name_device_tenant',
+                nulls_distinct=False,
+                violation_error_message='Virtual machine name must be unique per device and tenant.',
+            ),
+        ),
+    ]

+ 1 - 1
netbox/virtualization/models/clusters.py

@@ -89,7 +89,7 @@ class Cluster(ContactsMixin, CachedScopeMixin, PrimaryModel):
     )
 
     clone_fields = (
-        'scope_type', 'scope_id', 'type', 'group', 'status', 'tenant',
+        'scope', 'type', 'group', 'status', 'tenant',
     )
     prerequisite_models = (
         'virtualization.ClusterType',

+ 3 - 12
netbox/virtualization/models/virtualmachines.py

@@ -264,26 +264,17 @@ class VirtualMachine(
             models.UniqueConstraint(
                 Lower('name'), 'cluster', 'tenant',
                 name='%(app_label)s_%(class)s_unique_name_cluster_tenant',
+                condition=Q(cluster__isnull=False),
+                nulls_distinct=False,
                 violation_error_message=_('Virtual machine name must be unique per cluster and tenant.')
             ),
-            models.UniqueConstraint(
-                Lower('name'), 'cluster',
-                name='%(app_label)s_%(class)s_unique_name_cluster',
-                condition=Q(tenant__isnull=True),
-                violation_error_message=_('Virtual machine name must be unique per cluster.')
-            ),
             models.UniqueConstraint(
                 Lower('name'), 'device', 'tenant',
                 name='%(app_label)s_%(class)s_unique_name_device_tenant',
                 condition=Q(cluster__isnull=True, device__isnull=False),
+                nulls_distinct=False,
                 violation_error_message=_('Virtual machine name must be unique per device and tenant.')
             ),
-            models.UniqueConstraint(
-                Lower('name'), 'device',
-                name='%(app_label)s_%(class)s_unique_name_device',
-                condition=Q(cluster__isnull=True, device__isnull=False, tenant__isnull=True),
-                violation_error_message=_('Virtual machine name must be unique per device.')
-            ),
         )
         verbose_name = _('virtual machine')
         verbose_name_plural = _('virtual machines')

+ 14 - 3
netbox/virtualization/tests/test_api.py

@@ -357,7 +357,12 @@ class VirtualMachineTestCase(APIViewTestCases.APIViewTestCase):
             'vcpus': None,
             'memory': None,
         }
-        self.add_permissions('virtualization.add_virtualmachine')
+        self.add_permissions(
+            'virtualization.add_virtualmachine',
+            'dcim.view_site',
+            'virtualization.view_cluster',
+            'virtualization.view_virtualmachinetype',
+        )
 
         response = self.client.post(self._get_list_url(), data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -378,7 +383,13 @@ class VirtualMachineTestCase(APIViewTestCases.APIViewTestCase):
             'vcpus': 6,
             'memory': 12288,
         }
-        self.add_permissions('virtualization.add_virtualmachine')
+        self.add_permissions(
+            'virtualization.add_virtualmachine',
+            'dcim.view_site',
+            'virtualization.view_cluster',
+            'virtualization.view_virtualmachinetype',
+            'dcim.view_platform',
+        )
 
         response = self.client.post(self._get_list_url(), data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -677,7 +688,7 @@ class VMInterfaceTestCase(APIViewTestCases.APIViewTestCase):
             },
         }
 
-        self.add_permissions('ipam.change_prefix')
+        self.add_permissions('ipam.change_prefix', 'virtualization.view_vminterface')
 
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 200)

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio