Coverage for utilities/forms/mixins.py: 20%

99 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import time 

2import warnings 

3from decimal import Decimal 

4 

5from django import forms 

6from django.conf import settings 

7from django.core.validators import MaxValueValidator, MinValueValidator 

8from django.utils.translation import gettext_lazy as _ 

9 

10from netbox.registry import registry 

11from utilities.forms.fields import ColorField, GenericObjectChoiceField, QueryField, TagFilterField 

12from utilities.forms.widgets import FilterModifierWidget 

13from utilities.forms.widgets.modifiers import MODIFIER_EMPTY_FALSE, MODIFIER_EMPTY_TRUE 

14 

15__all__ = ( 

16 'FORM_FIELD_LOOKUPS', 

17 'BackgroundJobMixin', 

18 'CheckLastUpdatedMixin', 

19 'DistanceValidationMixin', 

20 'FilterModifierMixin', 

21 'GenericObjectFormMixin', 

22) 

23 

24 

25# Mapping of form field types to their supported lookups 

26FORM_FIELD_LOOKUPS = { 

27 QueryField: [], 

28 forms.BooleanField: [], 

29 forms.NullBooleanField: [], 

30 forms.CharField: [ 

31 ('exact', _('is')), 

32 ('n', _('is not')), 

33 ('ic', _('contains')), 

34 ('isw', _('starts with')), 

35 ('iew', _('ends with')), 

36 ('ie', _('equals (case-insensitive)')), 

37 ('regex', _('matches pattern')), 

38 ('iregex', _('matches pattern (case-insensitive)')), 

39 (MODIFIER_EMPTY_TRUE, _('is empty')), 

40 (MODIFIER_EMPTY_FALSE, _('is not empty')), 

41 ], 

42 forms.IntegerField: [ 

43 ('exact', _('is')), 

44 ('n', _('is not')), 

45 ('gt', _('greater than')), 

46 ('gte', _('at least')), 

47 ('lt', _('less than')), 

48 ('lte', _('at most')), 

49 (MODIFIER_EMPTY_TRUE, _('is empty')), 

50 (MODIFIER_EMPTY_FALSE, _('is not empty')), 

51 ], 

52 forms.DecimalField: [ 

53 ('exact', _('is')), 

54 ('n', _('is not')), 

55 ('gt', _('greater than')), 

56 ('gte', _('at least')), 

57 ('lt', _('less than')), 

58 ('lte', _('at most')), 

59 (MODIFIER_EMPTY_TRUE, _('is empty')), 

60 (MODIFIER_EMPTY_FALSE, _('is not empty')), 

61 ], 

62 forms.DateField: [ 

63 ('exact', _('is')), 

64 ('n', _('is not')), 

65 ('gt', _('after')), 

66 ('gte', _('on or after')), 

67 ('lt', _('before')), 

68 ('lte', _('on or before')), 

69 (MODIFIER_EMPTY_TRUE, _('is empty')), 

70 (MODIFIER_EMPTY_FALSE, _('is not empty')), 

71 ], 

72 forms.ModelChoiceField: [ 

73 ('exact', _('is')), 

74 ('n', _('is not')), 

75 (MODIFIER_EMPTY_TRUE, _('is empty')), 

76 (MODIFIER_EMPTY_FALSE, _('is not empty')), 

77 ], 

78 ColorField: [ 

79 ('exact', _('is')), 

80 ('n', _('is not')), 

81 (MODIFIER_EMPTY_TRUE, _('is empty')), 

82 (MODIFIER_EMPTY_FALSE, _('is not empty')), 

83 ], 

84 TagFilterField: [ 

85 ('exact', _('has these tags')), 

86 ('any', _('has any of these tags')), 

87 ('n', _('does not have these tags')), 

88 (MODIFIER_EMPTY_TRUE, _('is empty')), 

89 (MODIFIER_EMPTY_FALSE, _('is not empty')), 

90 ], 

91 forms.ChoiceField: [ 

92 ('exact', _('is')), 

93 ('n', _('is not')), 

94 (MODIFIER_EMPTY_TRUE, _('is empty')), 

95 (MODIFIER_EMPTY_FALSE, _('is not empty')), 

96 ], 

97 forms.MultipleChoiceField: [ 

98 ('exact', _('is')), 

99 ('n', _('is not')), 

100 (MODIFIER_EMPTY_TRUE, _('is empty')), 

101 (MODIFIER_EMPTY_FALSE, _('is not empty')), 

102 ], 

103} 

104 

105 

106class BackgroundJobMixin(forms.Form): 

107 background_job = forms.BooleanField( 

108 label=_('Background job'), 

109 help_text=_("Execute this task via a background job"), 

110 required=False, 

111 ) 

112 

113 def __init__(self, *args, **kwargs): 

114 super().__init__(*args, **kwargs) 

115 

116 # Declare background_job a meta field 

117 if hasattr(self, 'meta_fields'): 

118 self.meta_fields.append('background_job') 

119 else: 

120 self.meta_fields = ['background_job'] 

121 

122 

123class CheckLastUpdatedMixin(forms.Form): 

124 """ 

125 Checks whether the object being saved has been updated since the form was initialized. If so, validation fails. 

126 This prevents a user from inadvertently overwriting any changes made to the object between when the form was 

127 initialized and when it was submitted. 

128 

129 This validation does not apply to newly created objects, or if the `_init_time` field is not present in the form 

130 data. 

131 """ 

132 _init_time = forms.DecimalField( 

133 initial=time.time, 

134 required=False, 

135 widget=forms.HiddenInput() 

136 ) 

137 

138 def clean(self): 

139 super().clean() 

140 

141 # Skip for absent or newly created instances 

142 if not self.instance or not self.instance.pk: 

143 return 

144 

145 # Skip if a form init time has not been specified 

146 if not (form_init_time := self.cleaned_data.get('_init_time')): 

147 return 

148 

149 # Skip if the object does not have a last_updated value 

150 if not (last_updated := getattr(self.instance, 'last_updated', None)): 

151 return 

152 

153 # Check that the submitted initialization time is not earlier than the object's modification time 

154 if form_init_time < last_updated.timestamp(): 

155 raise forms.ValidationError(_( 

156 "This object has been modified since the form was rendered. Please consult the object's change " 

157 "log for details." 

158 )) 

159 

160 

161class DistanceValidationMixin(forms.Form): 

162 distance = forms.DecimalField( 

163 required=False, 

164 validators=[ 

165 MinValueValidator(Decimal(0)), 

166 MaxValueValidator(Decimal(100000)), 

167 ] 

168 ) 

169 

170 

171class GenericObjectFormMixin: 

172 """ 

173 Initialize and assign any GenericObjectChoiceField fields on a form. 

174 

175 Seeds each field's initial value from the model's GFK descriptor, configures the API-backed object 

176 selector for the current content type, and copies the cleaned object back to the instance before model 

177 validation runs. Keeps the common GFK form pattern out of individual model forms. 

178 """ 

179 

180 def __init__(self, *args, **kwargs): 

181 super().__init__(*args, **kwargs) 

182 

183 instance = getattr(self, 'instance', None) 

184 for field_name, field in self._generic_object_fields(): 

185 gfk_name = field.gfk_name or field_name 

186 # On an HTMX re-render the submitted subwidget values take precedence over the stored instance value. 

187 rerendered = any(f'{field_name}_{suffix}' in self.initial for suffix in ('content_type', 'object_id')) 

188 if instance is not None and not self.is_bound and field_name not in self.initial and not rerendered: 

189 if (initial := getattr(instance, gfk_name, None)) is not None: 

190 self.initial[field_name] = initial 

191 # Prepare eagerly so forms can read field.selected_model in their own __init__ (e.g. PrefixForm). 

192 # prepare() is idempotent and re-runs at render via get_bound_field(). 

193 field.prepare(self, field_name) 

194 

195 if settings.DEBUG: 

196 self._warn_missing_htmx_fieldsets() 

197 

198 def _generic_object_fields(self): 

199 for field_name, field in self.fields.items(): 

200 if isinstance(field, GenericObjectChoiceField): 

201 yield field_name, field 

202 

203 def _warn_missing_htmx_fieldsets(self): 

204 # Each GenericObjectChoiceField with an HTMX target needs a matching FieldSet(html_id=...) for the 

205 # partial swap to land; warn in development if a consumer forgot to declare one. 

206 fieldset_ids = {getattr(fs, 'html_id', None) for fs in getattr(self, 'fieldsets', [])} 

207 for field_name, field in self._generic_object_fields(): 

208 if field.hx_target_id and field.hx_target_id not in fieldset_ids: 

209 warnings.warn( 

210 f"{type(self).__name__} has a GenericObjectChoiceField '{field_name}' targeting " 

211 f"#{field.hx_target_id} for HTMX swap but declares no FieldSet with " 

212 f"html_id='{field.hx_target_id}'; the partial swap will fail silently.", 

213 stacklevel=3, 

214 ) 

215 

216 def clean(self): 

217 cleaned_data = super().clean() 

218 if cleaned_data is None: 

219 cleaned_data = self.cleaned_data 

220 

221 instance = getattr(self, 'instance', None) 

222 if instance is not None: 

223 for field_name, field in self._generic_object_fields(): 

224 if field_name in cleaned_data: 

225 setattr(instance, field.gfk_name or field_name, cleaned_data[field_name]) 

226 

227 return cleaned_data 

228 

229 

230class FilterModifierMixin: 

231 """ 

232 Mixin that enhances filter form fields with lookup modifier dropdowns. 

233 

234 Automatically detects fields that could benefit from multiple lookup options 

235 and wraps their widgets with FilterModifierWidget. 

236 """ 

237 

238 def __init__(self, *args, **kwargs): 

239 super().__init__(*args, **kwargs) 

240 self._enhance_fields_with_modifiers() 

241 

242 def _enhance_fields_with_modifiers(self): 

243 """Wrap compatible field widgets with FilterModifierWidget.""" 

244 

245 model = getattr(self, 'model', None) 

246 if model is None and hasattr(self, '_meta'): 

247 model = getattr(self._meta, 'model', None) 

248 

249 filterset_class = None 

250 if model: 

251 key = f'{model._meta.app_label}.{model._meta.model_name}' 

252 filterset_class = registry['filtersets'].get(key) 

253 

254 filterset = filterset_class() if filterset_class else None 

255 

256 for field_name, field in self.fields.items(): 

257 lookups = self._get_lookup_choices(field) 

258 

259 if filterset: 

260 lookups = self._verify_lookups_with_filterset(field_name, lookups, filterset) 

261 

262 if len(lookups) > 1: 

263 field.widget = FilterModifierWidget( 

264 widget=field.widget, 

265 lookups=lookups 

266 ) 

267 

268 def _get_lookup_choices(self, field): 

269 """Determine the available lookup choices for a given field. 

270 

271 Returns an empty list for fields that should not be enhanced. 

272 """ 

273 for field_class in field.__class__.__mro__: 

274 if field_lookups := FORM_FIELD_LOOKUPS.get(field_class): 

275 return field_lookups 

276 

277 return [] 

278 

279 def _verify_lookups_with_filterset(self, field_name, lookups, filterset): 

280 """Verify which lookups are actually supported by the FilterSet.""" 

281 verified_lookups = [] 

282 

283 for lookup_code, lookup_label in lookups: 

284 if lookup_code in (MODIFIER_EMPTY_TRUE, MODIFIER_EMPTY_FALSE): 

285 filter_key = f'{field_name}__empty' 

286 else: 

287 filter_key = f'{field_name}__{lookup_code}' if lookup_code != 'exact' else field_name 

288 

289 if filter_key in filterset.filters: 

290 verified_lookups.append((lookup_code, lookup_label)) 

291 

292 return verified_lookups