ソースを参照

Closes #19821: Consolidate GFK form handling with GenericObjectChoiceField (#22537)

* refactor(forms): Add GenericObjectChoiceField

Replace separate scope_type/scope and parent_object_type/parent field
pairs with unified GenericObjectChoiceField. Introduce
GenericObjectFormMixin to handle GFK descriptor initialization and
assignment.

This removes redundant HTMX/queryset setup logic from ScopedForm,
VLANGroupForm, and ServiceForm by delegating GFK presentation to a
single reusable field and mixin pair. Field query param references now
use `$scope_object_id` instead of `$scope` to match the subwidget name.

Fixes #19821

* fix(forms): Skip validation on HTMX bulk-edit dependent field refresh

Render bulk-edit form unbound when an HTMX request changes a dependent
field (e.g. content type) without clicking Apply. This prevents
validation errors from surfacing before the user submits.

Cache ContentType lookups in GenericObjectChoiceField and sync widget
references before setting queryset to ensure choices land on the
rendered subwidget.

* fix(ipam): Update scope query params for GenericObjectChoiceField

Change available-prefix Add links to use `scope_content_type` and
`scope_object_id` query parameters instead of `scope_type` and `scope`.
This aligns with the GenericObjectChoiceField subwidget naming
introduced in the earlier refactor.

* refactor(models): Simplify GFK handling in clone_fields

Replace `scope_type`/`scope_id` pairs with bare `scope` GFK names in
clone_fields across models. Update CloningMixin to emit GFK subwidget
parameters (`scope_content_type`, `scope_object_id`) directly when a
GenericForeignKey appears in clone_fields.

* Update pre-populated links

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Martin Hauser 6 日 前
コミット
b6bdfbd2a5
39 ファイル変更862 行追加482 行削除
  1. 12 0
      docs/plugins/development/forms.md
  2. 9 34
      netbox/circuits/forms/bulk_edit.py
  3. 21 106
      netbox/circuits/forms/model_forms.py
  4. 4 4
      netbox/circuits/tests/test_forms.py
  5. 4 4
      netbox/circuits/tests/test_views.py
  6. 2 2
      netbox/circuits/ui/panels.py
  7. 14 103
      netbox/dcim/forms/mixins.py
  8. 2 2
      netbox/dcim/views.py
  9. 10 32
      netbox/ipam/forms/bulk_edit.py
  10. 34 106
      netbox/ipam/forms/model_forms.py
  11. 1 1
      netbox/ipam/models/ip.py
  12. 1 1
      netbox/ipam/models/services.py
  13. 1 1
      netbox/ipam/tables/template_code.py
  14. 6 5
      netbox/ipam/tests/test_forms.py
  15. 59 6
      netbox/ipam/tests/test_views.py
  16. 2 2
      netbox/ipam/views.py
  17. 14 7
      netbox/netbox/models/features.py
  18. 20 44
      netbox/netbox/tests/test_model_features.py
  19. 11 1
      netbox/netbox/views/generic/bulk_views.py
  20. 0 0
      netbox/project-static/dist/netbox.js
  21. 0 0
      netbox/project-static/dist/netbox.js.map
  22. 23 5
      netbox/project-static/src/select/classes/dynamicTomSelect.ts
  23. 1 1
      netbox/templates/ipam/prefix/prefixes.html
  24. 1 0
      netbox/utilities/forms/fields/__init__.py
  25. 249 0
      netbox/utilities/forms/fields/generic.py
  26. 63 1
      netbox/utilities/forms/mixins.py
  27. 1 0
      netbox/utilities/forms/widgets/__init__.py
  28. 39 0
      netbox/utilities/forms/widgets/generic.py
  29. 13 0
      netbox/utilities/templates/widgets/generic_object.html
  30. 231 0
      netbox/utilities/tests/test_forms.py
  31. 1 1
      netbox/virtualization/forms/bulk_edit.py
  32. 2 2
      netbox/virtualization/forms/model_forms.py
  33. 1 1
      netbox/virtualization/models/clusters.py
  34. 2 2
      netbox/virtualization/tests/test_views.py
  35. 2 2
      netbox/virtualization/views.py
  36. 1 1
      netbox/wireless/forms/bulk_edit.py
  37. 2 2
      netbox/wireless/forms/model_forms.py
  38. 1 1
      netbox/wireless/models.py
  39. 2 2
      netbox/wireless/tests/test_views.py

+ 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 = (

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

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

+ 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
                         }
                     ),
                 ],

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

+ 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 %}
 """
 

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

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

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

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

ファイルの差分が大きいため隠しています
+ 0 - 0
netbox/project-static/dist/netbox.js


ファイルの差分が大きいため隠しています
+ 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 %}

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

+ 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 %}

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

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

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

+ 2 - 2
netbox/virtualization/tests/test_views.py

@@ -152,8 +152,8 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'type': clustertypes[1].pk,
             'status': ClusterStatusChoices.STATUS_OFFLINE,
             'tenant': None,
-            '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,
             'comments': 'Some comments',
             'tags': [t.pk for t in tags],
         }

+ 2 - 2
netbox/virtualization/views.py

@@ -497,8 +497,8 @@ class VirtualMachineView(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,
                         },
                     ),
                 ],

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

@@ -79,7 +79,7 @@ class WirelessLANBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
     model = WirelessLAN
     fieldsets = (
         FieldSet('group', 'ssid', 'status', 'vlan', 'tenant', 'description'),
-        FieldSet('scope_type', 'scope', name=_('Scope')),
+        FieldSet('scope', name=_('Scope')),
         FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
     )
     nullable_fields = (

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

@@ -52,7 +52,7 @@ class WirelessLANForm(ScopedForm, TenancyForm, PrimaryModelForm):
 
     fieldsets = (
         FieldSet('ssid', 'group', 'vlan', 'status', 'description', 'tags', name=_('Wireless LAN')),
-        FieldSet('scope_type', 'scope', name=_('Scope'), html_id='scope'),
+        FieldSet('scope', name=_('Scope'), html_id='scope'),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
         FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
     )
@@ -61,7 +61,7 @@ class WirelessLANForm(ScopedForm, TenancyForm, PrimaryModelForm):
         model = WirelessLAN
         fields = [
             'ssid', 'group', 'status', 'vlan', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk',
-            'scope_type', 'description', 'owner', 'comments', 'tags',
+            'description', 'owner', 'comments', 'tags',
         ]
         widgets = {
             'auth_psk': PasswordInput(

+ 1 - 1
netbox/wireless/models.py

@@ -110,7 +110,7 @@ class WirelessLAN(WirelessAuthenticationBase, CachedScopeMixin, PrimaryModel):
         null=True
     )
 
-    clone_fields = ('ssid', 'group', 'scope_type', 'scope_id', 'tenant', 'description')
+    clone_fields = ('ssid', 'group', 'scope', 'tenant', 'description')
 
     class Meta:
         ordering = ('ssid', 'pk')

+ 2 - 2
netbox/wireless/tests/test_views.py

@@ -110,8 +110,8 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'ssid': 'WLAN2',
             'group': groups[1].pk,
             'status': WirelessLANStatusChoices.STATUS_DISABLED,
-            '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,
             'tenant': tenants[1].pk,
             'tags': [t.pk for t in tags],
         }

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません