Coverage for utilities/templatetags/form_helpers.py: 20%
98 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 warnings
2from collections.abc import Sequence
3from typing import Any, NamedTuple
5from django import forms, template
6from django.conf import settings
8from utilities.forms.rendering import InlineFields, M2MAddRemoveFields, ObjectAttribute, TabbedGroups
10__all__ = (
11 'any_required',
12 'getfield',
13 'render_custom_fields',
14 'render_errors',
15 'render_field',
16 'render_field_with_aria',
17 'render_form',
18 'widget_type',
19)
22register = template.Library()
25class FieldsetRow(NamedTuple):
26 """
27 A single row within a rendered fieldset. `layout` determines how the row's items are
28 rendered by the template (e.g. 'field', 'inline', 'tabs', 'attribute').
29 """
30 layout: str
31 items: Sequence
32 title: Any = None
33 help_text: Any = None
36#
37# Filters
38#
40@register.filter()
41def getfield(form, fieldname):
42 """
43 Return the specified bound field of a Form.
44 """
45 try:
46 return form[fieldname]
47 except KeyError:
48 return None
51@register.filter()
52def any_required(fields):
53 """
54 Return True if any of the given bound form fields is required.
55 """
56 return any(getattr(f, 'field', None) and f.field.required for f in fields)
59@register.filter(name='widget_type')
60def widget_type(field):
61 """
62 Return the widget type
63 """
64 if hasattr(field, 'widget'):
65 return field.widget.__class__.__name__.lower()
66 if hasattr(field, 'field'):
67 return field.field.widget.__class__.__name__.lower()
68 return None
71@register.simple_tag
72def render_field_with_aria(field, has_helptext=None, element_id=None):
73 """Render a bound form field with aria-describedby/aria-invalid/aria-label wired up.
75 Pass ``element_id`` to override the widget's HTML ``id``. This is needed when the same
76 field is rendered more than once on a page (e.g. the saved-filter selector, which appears
77 both in the list controls and the filter drawer), to keep element IDs unique so that label
78 associations resolve correctly for assistive technology.
79 """
80 if has_helptext is None:
81 has_helptext = bool(field.help_text)
82 widget_attrs = field.field.widget.attrs
83 described_by = []
84 if field.errors:
85 described_by.append(f'{field.auto_id}_errors')
86 if has_helptext:
87 described_by.append(f'{field.auto_id}_helptext')
88 extra_attrs = {}
89 if element_id:
90 extra_attrs['id'] = element_id
91 if described_by:
92 # Merge with any aria-describedby already set on the widget so we
93 # append to (rather than clobber) descriptions defined elsewhere.
94 existing = widget_attrs.get('aria-describedby', '').strip()
95 extra_attrs['aria-describedby'] = ' '.join(
96 filter(None, [existing, *described_by])
97 )
98 if field.errors:
99 extra_attrs['aria-invalid'] = 'true'
100 # Mirror field.label onto <select> widgets hidden by Tom Select
101 # (ts-hidden-accessible, tabindex=-1), where scanners drop the <label for=>
102 # association. Skip selects opted out of Tom Select (``.no-ts`` class or a
103 # ``size`` attribute) since they stay visible and keep their association.
104 #
105 # When a field has no label at all (label=''), we deliberately do NOT
106 # synthesize one from the field name: that would inject an untranslated
107 # English string into the rendered DOM and degrade the experience for
108 # non-English locales. In DEBUG we emit a warning so developers add a
109 # proper translated label on the field.
110 if 'aria-label' not in widget_attrs:
111 if isinstance(field.field.widget, forms.Select) and field.label:
112 tom_select_excluded = (
113 'no-ts' in widget_attrs.get('class', '').split()
114 or 'size' in widget_attrs
115 )
116 if not tom_select_excluded:
117 extra_attrs['aria-label'] = str(field.label)
118 elif not field.label and settings.DEBUG:
119 form_name = getattr(getattr(field, 'form', None), '__class__', type(None)).__name__
120 warnings.warn(
121 f"Form field {form_name}.{field.name} has no label; no aria-label "
122 "will be set. Add a translated label to the field for proper "
123 "accessibility.",
124 stacklevel=2,
125 )
126 return field.as_widget(attrs=extra_attrs)
129#
130# Inclusion tags
131#
133@register.inclusion_tag('form_helpers/render_fieldset.html')
134def render_fieldset(form, fieldset):
135 """
136 Render a group set of fields.
137 """
138 rows = []
139 for item in fieldset.items:
141 # Multiple fields side-by-side
142 if type(item) is InlineFields:
143 fields = [
144 form[name] for name in item.fields if name in form.fields
145 ]
146 rows.append(
147 FieldsetRow('inline', fields, title=item.label, help_text=item.help_text)
148 )
150 # Tabbed groups of fields
151 elif type(item) is TabbedGroups:
152 tabs = [
153 {
154 'id': tab['id'],
155 'title': tab['title'],
156 'active': bool(form.initial.get(tab['fields'][0], False)),
157 'fields': [form[name] for name in tab['fields'] if name in form.fields]
158 } for tab in item.tabs
159 ]
160 # A field error wins over initial data so a failed submission is not hidden in a tab
161 errored = next(
162 (tab for tab in tabs if any(field.errors for field in tab['fields'])),
163 None,
164 )
165 if errored is not None:
166 for tab in tabs:
167 tab['active'] = tab is errored
168 elif not any(tab['active'] for tab in tabs):
169 # If none of the tabs has been marked as active, activate the first one
170 tabs[0]['active'] = True
171 rows.append(
172 FieldsetRow('tabs', tabs)
173 )
175 elif type(item) is M2MAddRemoveFields:
176 if item.name in form.fields:
177 # Simple mode: render a single multi-select field
178 rows.append(
179 FieldsetRow('field', [form[item.name]])
180 )
181 else:
182 # Add/remove mode: render separate add and remove fields
183 for field_name in (f'add_{item.name}', f'remove_{item.name}'):
184 if field_name in form.fields:
185 rows.append(
186 FieldsetRow('field', [form[field_name]])
187 )
189 elif type(item) is ObjectAttribute:
190 value = getattr(form.instance, item.name)
191 label = value._meta.verbose_name if hasattr(value, '_meta') else item.name
192 rows.append(
193 FieldsetRow('attribute', [value], title=label.title())
194 )
196 # A single form field
197 elif item in form.fields:
198 field = form[item]
199 # Annotate nullability for bulk editing
200 if field.name in getattr(form, 'nullable_fields', []):
201 field._nullable = True
202 rows.append(
203 FieldsetRow('field', [field])
204 )
206 return {
207 'heading': fieldset.name,
208 'html_id': fieldset.html_id,
209 'rows': rows,
210 }
213@register.inclusion_tag('form_helpers/render_field.html')
214def render_field(field, bulk_nullable=False, label=None):
215 """
216 Render a single form field from template
217 """
218 return {
219 'field': field,
220 'label': label or field.label,
221 'bulk_nullable': bulk_nullable or getattr(field, '_nullable', False),
222 }
225@register.inclusion_tag('form_helpers/render_custom_fields.html')
226def render_custom_fields(form):
227 """
228 Render all custom fields in a form
229 """
230 return {
231 'form': form,
232 }
235@register.inclusion_tag('form_helpers/render_form.html')
236def render_form(form):
237 """
238 Render an entire form from template
239 """
240 return {
241 'form': form,
242 }
245@register.inclusion_tag('form_helpers/render_errors.html')
246def render_errors(form):
247 """
248 Render form errors, if they exist.
249 """
250 return {
251 "form": form
252 }