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

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 _ 

6 

7from utilities.forms.widgets import APISelect, GenericObjectSelect, HTMXSelect 

8from utilities.views import get_action_url 

9 

10from .content_types import ContentTypeChoiceField 

11from .dynamic import DynamicModelChoiceField 

12 

13__all__ = ( 

14 'GenericObjectChoiceField', 

15) 

16 

17 

18class GenericObjectChoiceField(forms.MultiValueField): 

19 """ 

20 Select an object for assignment to a generic foreign key. 

21 

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()). 

26 

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 } 

42 

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 

54 

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

68 

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) 

78 

79 super().__init__(fields=fields, require_all_fields=False, widget=widget, **kwargs) 

80 

81 @property 

82 def content_type_field(self): 

83 return self.fields[0] 

84 

85 @property 

86 def object_field(self): 

87 return self.fields[1] 

88 

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 

94 

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) 

100 

101 def _set_object_queryset(self, queryset): 

102 self.object_field.queryset = queryset 

103 self.object_field.widget.choices = self.object_field.choices 

104 

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

112 

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 

122 

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 

138 

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] 

153 

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 

162 

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 

169 

170 self.selected_model = model 

171 

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) 

183 

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 

189 

190 return model 

191 

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

195 

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 ) 

201 

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) 

205 

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] 

210 

211 def get_bound_field(self, form, field_name): 

212 self.prepare(form, field_name) 

213 return BoundField(form, self, field_name) 

214 

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 

220 

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 

226 

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

237 

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

243 

244 # Validates the object exists (queryset was narrowed to the value) 

245 return self.object_field.clean(object_value) 

246 

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