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

1import warnings 

2from collections.abc import Sequence 

3from typing import Any, NamedTuple 

4 

5from django import forms, template 

6from django.conf import settings 

7 

8from utilities.forms.rendering import InlineFields, M2MAddRemoveFields, ObjectAttribute, TabbedGroups 

9 

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) 

20 

21 

22register = template.Library() 

23 

24 

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 

34 

35 

36# 

37# Filters 

38# 

39 

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 

49 

50 

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) 

57 

58 

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 

69 

70 

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. 

74 

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) 

127 

128 

129# 

130# Inclusion tags 

131# 

132 

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: 

140 

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 ) 

149 

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 ) 

174 

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 ) 

188 

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 ) 

195 

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 ) 

205 

206 return { 

207 'heading': fieldset.name, 

208 'html_id': fieldset.html_id, 

209 'rows': rows, 

210 } 

211 

212 

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 } 

223 

224 

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 } 

233 

234 

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 } 

243 

244 

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 }