Coverage for utilities/forms/fields/generic.py: 27%
141 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1from django import forms
2from django.contrib.contenttypes.models import ContentType
3from django.core.exceptions import ObjectDoesNotExist, ValidationError
4from django.forms.boundfield import BoundField
5from django.utils.translation import gettext_lazy as _
7from utilities.forms.widgets import APISelect, GenericObjectSelect, HTMXSelect
8from utilities.views import get_action_url
10from .content_types import ContentTypeChoiceField
11from .dynamic import DynamicModelChoiceField
13__all__ = (
14 'GenericObjectChoiceField',
15)
18class GenericObjectChoiceField(forms.MultiValueField):
19 """
20 Select an object for assignment to a generic foreign key.
22 Renders a content-type selector (HTMXSelect) plus an API-backed object selector (APISelect) as a single
23 field. Changing the content type re-renders the form so the object selector is rebuilt for the new model.
24 The field's cleaned value is the selected model instance (or None); assignment to the GFK descriptor is
25 handled by GenericObjectFormMixin (or the consuming form's clean()).
27 Args:
28 content_type_queryset: Queryset of ContentTypes the user may choose from.
29 query_params: Optional dict of static/dynamic ($field) query params forwarded to the object selector.
30 selector: If True, expose the advanced object-selector modal for the object subwidget.
31 gfk_name: Name of the model's GenericForeignKey descriptor, if it differs from the form field name.
32 hx_method: HTTP method for the content-type HTMXSelect ('get' for model forms, 'post' for bulk edit).
33 hx_include_id: HTML id of the container whose fields are included in the HTMX request. This should
34 generally remain 'form_fields' so dependent fields can resolve against the full form state.
35 hx_target_id: html_id of the enclosing FieldSet for an HTMX partial swap. If omitted, the whole
36 #form_fields container is re-rendered.
37 """
38 default_error_messages = {
39 'incomplete': _("Both an object type and an object must be specified."),
40 'invalid_object_type': _("Invalid object type."),
41 }
43 def __init__(
44 self, *, content_type_queryset, query_params=None, selector=False, gfk_name=None,
45 hx_method='get', hx_include_id='form_fields', hx_target_id=None, **kwargs
46 ):
47 self.content_type_queryset = content_type_queryset
48 self._content_type_cache = {}
49 self.query_params = query_params or {}
50 self.selector = selector
51 self.gfk_name = gfk_name
52 self.hx_target_id = hx_target_id
53 self.selected_model = None
55 # NetBox's current HTMXSelect separates the include source from the swap target. Always include the
56 # full form so server-side dependent-field resolution sees every field, while optionally targeting only
57 # the containing fieldset for the swap. Bulk edit forms keep the historical hx-select=#form_fields path.
58 htmx_attrs = {}
59 if hx_target_id is None and hx_method.lower() == 'post':
60 htmx_attrs['hx-select'] = '#form_fields'
61 content_type_widget = HTMXSelect(
62 method=hx_method,
63 hx_include_id=hx_include_id,
64 hx_target_id=hx_target_id,
65 attrs=htmx_attrs or None,
66 )
67 object_widget = APISelect()
69 fields = (
70 ContentTypeChoiceField(queryset=content_type_queryset, required=False, widget=content_type_widget),
71 # Empty placeholder until a content type is chosen; _configure_object_field installs the real
72 # (and possibly permission-restricted) queryset.
73 DynamicModelChoiceField(
74 queryset=ContentType.objects.none(), required=False, selector=selector, widget=object_widget
75 ),
76 )
77 widget = GenericObjectSelect(content_type_widget=content_type_widget, object_widget=object_widget)
79 super().__init__(fields=fields, require_all_fields=False, widget=widget, **kwargs)
81 @property
82 def content_type_field(self):
83 return self.fields[0]
85 @property
86 def object_field(self):
87 return self.fields[1]
89 @property
90 def queryset(self):
91 # Exposed at the top level so restrict_form_fields() (which only inspects top-level fields) can apply
92 # object-permission restrictions to the nested object selector.
93 return self.object_field.queryset
95 @queryset.setter
96 def queryset(self, queryset):
97 # Sync widget refs first so choices land on the rendered MultiWidget subwidget, not an orphan copy.
98 self._sync_widget_refs()
99 self._set_object_queryset(queryset)
101 def _set_object_queryset(self, queryset):
102 self.object_field.queryset = queryset
103 self.object_field.widget.choices = self.object_field.choices
105 def _get_object_queryset(self, model):
106 # Preserve a queryset already restricted by restrict_form_fields() when it targets the selected model;
107 # otherwise start from the model's default manager.
108 queryset = self.object_field.queryset
109 if getattr(queryset, 'model', None) is model:
110 return queryset.all()
111 return model.objects.all()
113 def _sync_widget_refs(self):
114 # The form metaclass deep-copies the field, its subfields, and the MultiWidget independently. Re-point
115 # the subfields at the MultiWidget's widgets so attrs we set on the object subfield are actually rendered.
116 # Re-assign choices as well: Select widgets keep their own choices iterator, and a copied MultiWidget
117 # subwidget can otherwise render as an empty Tom Select even though the subfield queryset is populated.
118 self.content_type_field.widget = self.widget.widgets[0]
119 self.object_field.widget = self.widget.widgets[1]
120 self.content_type_field.widget.choices = self.content_type_field.choices
121 self.object_field.widget.choices = self.object_field.choices
123 def _resolve_subvalue(self, form, field_name, suffix):
124 # Read the current value of one subwidget across the three paths: bound submit/POST re-render,
125 # unbound HTMX GET re-render (values arrive as initial under the subwidget keys), and normal edit
126 # (field-level initial holds the related object instance).
127 key = f'{field_name}_{suffix}'
128 if form.is_bound and key in form.data:
129 return form.data.get(key)
130 if key in form.initial:
131 return form.initial.get(key)
132 obj = form.initial.get(field_name, self.initial)
133 if obj in self.empty_values or not hasattr(obj, '_meta'):
134 return None
135 if suffix == 'content_type':
136 return ContentType.objects.get_for_model(obj).pk
137 return obj.pk
139 def _get_content_type(self, value):
140 if value in self.empty_values:
141 return None
142 try:
143 pk = int(value)
144 except (TypeError, ValueError):
145 return None
146 if pk not in self._content_type_cache:
147 try:
148 # Constrain to the allowed queryset so out-of-set types resolve to None.
149 self._content_type_cache[pk] = self.content_type_queryset.get(pk=pk)
150 except ObjectDoesNotExist:
151 self._content_type_cache[pk] = None
152 return self._content_type_cache[pk]
154 def _configure_object_field(self, content_type, object_value=None):
155 # Clear any state left over from a previously selected content type
156 widget = self.object_field.widget
157 for attr in ('data-url', 'data-dynamic-params', 'data-static-params', 'disabled', 'selector'):
158 widget.attrs.pop(attr, None)
159 widget.dynamic_params = {}
160 widget.static_params = {}
161 self.selected_model = None
163 model = content_type.model_class() if content_type else None
164 if model is None:
165 # No type selected: keep an empty placeholder queryset and disable the object selector.
166 self._set_object_queryset(ContentType.objects.none())
167 widget.attrs['disabled'] = 'disabled'
168 return None
170 self.selected_model = model
172 # Narrow the queryset to the current value to avoid loading the entire table for rendering
173 queryset = self._get_object_queryset(model)
174 if object_value in self.empty_values:
175 queryset = queryset.none()
176 else:
177 lookup = getattr(self.object_field, 'to_field_name', None) or 'pk'
178 try:
179 queryset = queryset.filter(**{lookup: object_value})
180 except (TypeError, ValueError, ValidationError):
181 queryset = queryset.none()
182 self._set_object_queryset(queryset)
184 widget.attrs['data-url'] = get_action_url(model, action='list', rest_api=True)
185 if self.query_params:
186 widget.add_query_params(self.query_params)
187 if self.selector:
188 widget.attrs['selector'] = model._meta.label_lower
190 return model
192 def prepare(self, form, field_name):
193 """Configure the object selector for the field's current content type (called during rendering)."""
194 self._sync_widget_refs()
196 # Clear the paired object selection client-side when the content type changes, so a stale object_id
197 # cannot cross model boundaries on the HTMX re-render (a Site pk must not resurface as a Region pk).
198 self.content_type_field.widget.attrs['hx-on::config-request'] = (
199 f"event.detail.parameters['{field_name}_object_id'] = ''"
200 )
202 content_type_value = self._resolve_subvalue(form, field_name, 'content_type')
203 object_value = self._resolve_subvalue(form, field_name, 'object_id')
204 self._configure_object_field(self._get_content_type(content_type_value), object_value)
206 # On an unbound HTMX GET re-render the submitted values live under the subwidget keys, not under the
207 # field name; seed the field initial so the subwidgets render the current selection.
208 if not form.is_bound:
209 self.initial = [content_type_value, object_value]
211 def get_bound_field(self, form, field_name):
212 self.prepare(form, field_name)
213 return BoundField(form, self, field_name)
215 def clean(self, value):
216 self._sync_widget_refs()
217 value = value or []
218 content_type_value = value[0] if len(value) > 0 else None
219 object_value = value[1] if len(value) > 1 else None
221 if content_type_value in self.empty_values and object_value in self.empty_values:
222 self._configure_object_field(None)
223 if self.required:
224 raise ValidationError(self.error_messages['required'], code='required')
225 return None
227 if content_type_value in self.empty_values or object_value in self.empty_values:
228 # Name the selected type when the object is missing so the message is actionable.
229 if content_type_value not in self.empty_values:
230 content_type = self._get_content_type(content_type_value)
231 if content_type is not None and (model := content_type.model_class()) is not None:
232 raise ValidationError(
233 _("Please select a {object_type}.").format(object_type=model._meta.verbose_name),
234 code='incomplete',
235 )
236 raise ValidationError(self.error_messages['incomplete'], code='incomplete')
238 # Validates the content type is within the allowed queryset
239 content_type = self.content_type_field.clean(content_type_value)
240 model = self._configure_object_field(content_type, object_value)
241 if model is None:
242 raise ValidationError(self.error_messages['invalid_object_type'], code='invalid_object_type')
244 # Validates the object exists (queryset was narrowed to the value)
245 return self.object_field.clean(object_value)
247 def compress(self, data_list):
248 # clean() returns the validated object directly; compress() is intentionally bypassed.
249 raise NotImplementedError("GenericObjectChoiceField.clean() returns the selected object directly.")