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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import time
2import warnings
3from decimal import Decimal
5from django import forms
6from django.conf import settings
7from django.core.validators import MaxValueValidator, MinValueValidator
8from django.utils.translation import gettext_lazy as _
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
15__all__ = (
16 'FORM_FIELD_LOOKUPS',
17 'BackgroundJobMixin',
18 'CheckLastUpdatedMixin',
19 'DistanceValidationMixin',
20 'FilterModifierMixin',
21 'GenericObjectFormMixin',
22)
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}
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 )
113 def __init__(self, *args, **kwargs):
114 super().__init__(*args, **kwargs)
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']
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.
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 )
138 def clean(self):
139 super().clean()
141 # Skip for absent or newly created instances
142 if not self.instance or not self.instance.pk:
143 return
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
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
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 ))
161class DistanceValidationMixin(forms.Form):
162 distance = forms.DecimalField(
163 required=False,
164 validators=[
165 MinValueValidator(Decimal(0)),
166 MaxValueValidator(Decimal(100000)),
167 ]
168 )
171class GenericObjectFormMixin:
172 """
173 Initialize and assign any GenericObjectChoiceField fields on a form.
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 """
180 def __init__(self, *args, **kwargs):
181 super().__init__(*args, **kwargs)
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)
195 if settings.DEBUG:
196 self._warn_missing_htmx_fieldsets()
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
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 )
216 def clean(self):
217 cleaned_data = super().clean()
218 if cleaned_data is None:
219 cleaned_data = self.cleaned_data
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])
227 return cleaned_data
230class FilterModifierMixin:
231 """
232 Mixin that enhances filter form fields with lookup modifier dropdowns.
234 Automatically detects fields that could benefit from multiple lookup options
235 and wraps their widgets with FilterModifierWidget.
236 """
238 def __init__(self, *args, **kwargs):
239 super().__init__(*args, **kwargs)
240 self._enhance_fields_with_modifiers()
242 def _enhance_fields_with_modifiers(self):
243 """Wrap compatible field widgets with FilterModifierWidget."""
245 model = getattr(self, 'model', None)
246 if model is None and hasattr(self, '_meta'):
247 model = getattr(self._meta, 'model', None)
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)
254 filterset = filterset_class() if filterset_class else None
256 for field_name, field in self.fields.items():
257 lookups = self._get_lookup_choices(field)
259 if filterset:
260 lookups = self._verify_lookups_with_filterset(field_name, lookups, filterset)
262 if len(lookups) > 1:
263 field.widget = FilterModifierWidget(
264 widget=field.widget,
265 lookups=lookups
266 )
268 def _get_lookup_choices(self, field):
269 """Determine the available lookup choices for a given field.
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
277 return []
279 def _verify_lookups_with_filterset(self, field_name, lookups, filterset):
280 """Verify which lookups are actually supported by the FilterSet."""
281 verified_lookups = []
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
289 if filter_key in filterset.filters:
290 verified_lookups.append((lookup_code, lookup_label))
292 return verified_lookups