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>
This commit is contained in:
Martin Hauser 2026-06-29 22:44:33 +02:00 committed by GitHub
parent e1f0c18c74
commit b6bdfbd2a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 866 additions and 486 deletions

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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],
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -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,
},
}

View File

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

View File

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

View File

@ -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()
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
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_does_not_inject_gfk_attribute_if_incomplete(self):
"""
Tests the cloning mixin with incomplete cloning fields does not inject the GFK attribute.
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')
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)
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)

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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) {

View File

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

View File

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

View File

@ -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.")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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],
}

View File

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

View File

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

View File

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

View File

@ -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')

View File

@ -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],
}